Skip to content

Sans-IO engine and harness product - #61

Merged
vinniefalco merged 65 commits into
cppalliance:masterfrom
vinniefalco:master
Sep 20, 2026
Merged

vinniefalco merged 65 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

@vinniefalco vinniefalco commented Sep 19, 2026

Copy link
Copy Markdown
Member

Sans-IO engine and harness product

All paths are relative to the PromptForge repository root. Repository facts are cited by path; every fact is included so a reader without the conversation can act on this plan.

Status: decomposed into 51 steps and reviewed against every AGENTS.md in the repository on 2026-09-18 (see the "AGENTS.md review" entry in the Decision Record). No step has started. Execution begins at Step 1; Steps 1-7 touch no engine crate and may run on a branch alongside Steps 8-40.

Product Requirements

PromptForge runs Markdown prompts whose sections contain Lua. Today the component that runs them (the executor) mixes a pure scheduler with the machinery that talks to the outside world: it spawns tokio tasks for network calls, builds an HTTP client, holds callbacks into the host, and implements two author features (models.loop and fanout) in Rust in ways that block or serialize other work. This plan splits it into an engine that never touches the outside world and a harness product that does all of it, records everything, and is what Workshop and Papergate depend on. The engine's whole host interface becomes four functions.

Terms used throughout

  • Prompt: a Markdown file with front matter, an H1 title, and H2 sections; each section holds prose and fenced Lua blocks. Source: crates/promptforge/parser/.

  • Section VM: the sandboxed Lua state created fresh for each section entry. No Lua state survives leaving a section.

  • Chain: one line of section execution inside a run (walk the sections in order, jump, fall through). The scheduler keeps an arena of chains. A call(target) starts a child chain and blocks the caller; fanout starts many.

  • Yield and shim: Lua cannot suspend across a call into Rust, so every suspending author function (models.infer, call, tools.call, ...) is a few lines of Lua that coroutine.yield a request table and receive an answer. The scheduler validates the yield, acts on it, and resumes the coroutine. Source: crates/promptforge/lua/src/coro.rs, __impl_coro.lua.

  • Structural yield: a request the scheduler answers by itself (start a chain, wait for a task). Leaf yield: a request that needs the outside world (a model round, a tool call, operator input, a store operation, a timer).

  • Effect: a leaf yield turned into a value the engine returns to its host; the host performs it and returns an EffectAnswer. The term is from algebraic effect handlers: the engine performs an effect, the harness handles it.

  • Event: something the engine reports (a section started, a model replied, a tool ran). Returned as values alongside effects; replaces today's Observer callback trait.

  • Task: a chain started with spawn and tracked by id so its owner can wait on it, inspect it, or cancel it. fanout becomes a Lua function over tasks.

  • Engine: the promptforge-* crates after this plan. Pure: no async, no network, no clock, no callbacks.

  • Harness: the new harness-* product. Owns tokio, performs every effect, keeps the run log, supervises sessions.

  • var: the author's clipboard table that rolls forward across sections within a chain and is discarded when a call chain ends. Tasks follow var.

  • Store and claims: the run-scoped virtual filesystem authors reach through store.*, with a claims model that detects two concurrent identities touching one path. Source: crates/shared-vfs/, crates/promptforge/store/.

  • Capability: a host-installed bundle of tools a prompt declares in front matter (web search, shell, ...). Source today: crates/promptforge-api-types/src/capabilities.rs.

  • Workshop: the desktop product (crates/workshop/). Papergate: an external consumer of the executor in its own repository.

  • Problem and users:

    • The scheduler (crates/promptforge-api-runtime/src/execute/scheduler.rs) is already a coroutine driver with all Lua on one thread, but four things keep it from being a pure state machine: models.loop runs as Rust async on the driver thread holding the section VM across network waits, so while one fanout arm's loop waits on a model no other arm can be resumed (documented in dispatch_loop); fanout is about 600 lines of scheduler-internal join code; leaf I/O is tokio::spawned inside the crate and collected on a channel; the HTTP client, capability registry, input broker, observer, debug capture, and ui() snapshot are all host callbacks reaching into or out of the run.
    • Users: prompt authors (Lua surface), models running inside prompts (tool surface), the Workshop desktop app and Papergate (host surface), and maintainers who need a testable core.
  • Goals:

    • The engine crates (promptforge-api-runtime, promptforge-api-types, everything under crates/promptforge/) declare no dependency on tokio, tokio-util, async-trait, or reqwest, enforced by a test on declared manifest dependencies.
    • The engine's host interface is Run::new, Run::step, Run::resume, Run::cancel. step returns the effects to perform and the events produced; nothing in the engine awaits, blocks, reads a clock, or calls a host callback.
    • Fanout arms that run models.loop interleave at every model round and tool call, so N arms have up to N model rounds in flight.
    • Authors gain spawn, when_any, when_all, ready, status, note, events, cancel under a tasks namespace; fanout keeps its signature and semantics.
    • Models gain background tasks and a bounded blocking wait, in the same shape Cursor gives its own agent: start, cancel, inspect, await with a timeout, results pushed as messages.
    • A harness-* product owns the effect loop, every performer, session supervision, and a Turso-backed log of every effect, answer, and event in one ordered stream.
    • workshop-sessions is dissolved into the harness; Workshop and Papergate depend on harness-api.
  • Non-goals:

    • Replaying a recorded run, or resuming a cancelled task by re-execution. The log is written so both are possible later; nothing reads it back into the engine. Their contracts are recorded under Deferred.
    • A live clock in Lua (now()), an author-visible timer task, Lua string-hash seed control, parallel Lua within one run, the compactor framework, any gateway product change.
  • Success criteria:

    • cargo check -p promptforge-api-runtime succeeds, and cargo test -p build-xtask proves that its non-dev dependency tables, and those of every crate under crates/promptforge/, contain none of the forbidden crates.
    • A plain #[test] drives a three-arm fanout to completion with a serial performer, feeding answers in reverse order, with no tokio and no mock HTTP server.
    • Two runs with the same seed, started_at, and answers produce identical effect and event sequences, including every Provenance and sys.id, on prompts that avoid Lua pairs.
    • Workshop's agent integration suites pass against harness-api with import and construction changes only; their assertions are unchanged.
    • Every existing engine test suite passes through a test-support driver (which adapts the returned event stream to the recording observers those suites install), or is rewritten at prompt level where it called the deleted Rust loop directly.
  • Constraints:

    • One thread runs every chain step; the scheduler is unreachable from Lua; the coroutine global is stripped after the shims capture yield (crates/promptforge/lua/src/coro.rs).
    • Yield cannot cross the C boundary, so every author-visible suspending function is a Lua shim, never an mlua callback.
    • Claims model: a chain's store access is spawned from its parent's at chain start and released at chain end before any waiter resumes; a run result is never delivered while an in-flight store operation still holds an access clone.
    • Typed errors never flatten: when an answer fails, the scheduler keeps the typed error and substitutes it when the shim's Lua error() surfaces as the coroutine's failure.
    • Product matrix (crates/build-xtask/src/product.rs): families by name prefix, private containers with one named public door, shared-* depends on no product. New structural checks need explicit approval; the user approved four in this plan (2026-09-18): the harness family row, the engine manifest test, the retired-symbol source scan, and the harness clippy.toml check. Extending the existing 500-line ceiling check to harness-* crates is a scope change to a check that already exists, not a new check.
    • Workspace lints: unsafe_code forbidden, unwrap_used/expect_used denied, pedantic clippy; files under 500 lines; flat source directories; Cargo features gate real constraints only.
    • {{ }} prose substitution stays data-only; no call syntax is added.
  • Open questions: None.

Functional Specification

Three actors see the change. Prompt authors keep every function they have and gain a tasks namespace for background work with timeouts. Models inside a prompt gain five tools, enabled by the author, for starting, inspecting, awaiting, and cancelling background tasks, with results arriving as messages. Hosts drive a run by asking the engine what it needs, doing it, and handing the result back; the harness is the one host in the workspace and Workshop talks to it.

  • Actors and workflows:
    • Author, existing surface unchanged: models.infer(handle?, prompt), models.loop(handle?, messages, compactor?), call(target, input?), fanout(worker, collection), tools.call(alias_or_tool, args), user_input(), store.*. models.loop and fanout are now written in Lua but behave the same, except as listed under acceptance criteria.
    • Author, new tasks namespace (available in every section and in the H1 pass):
      • spawn(target, opts?) -> Task starts a chain over section target and returns at once. opts.input overrides the chain's args; opts.item becomes the item global and {{ item }} in the target; opts.index becomes sys.index. The caller's var seeds the chain. Depth is the caller's plus one, capped as call is.
      • tasks.when_any(set, opts?) -> Task, ok, result waits until the first task in set finishes and returns which one, whether it succeeded, and its final text or error value. opts.timeout (seconds) returns nil if nothing finished in time; the tasks keep running.
      • tasks.when_all(set, opts?) -> results, timed_out waits for every task and returns { task, ok, result } per member in input order. It never raises because a member failed; the author decides. With a timeout, unfinished members are absent and timed_out is true.
      • tasks.ready(task), tasks.pending(filter?), tasks.cancel(task): non-blocking check, list of the caller's live tasks (optionally by origin author or model), abort.
      • tasks.status(task) -> table: target, origin, state (running/done/cancelled/abandoned), ok, current section, what it is blocked on (chat, tool_call, user_input, store, timer, tasks, call, or nil), turns, owned tasks, depth, latest note. No elapsed time, because the engine has no clock.
      • tasks.note(text): from inside a task, publish a one-line progress note visible in status.
      • tasks.events(task, opts?) -> sequence: the task's content events so far ({ kind, section, turn, text }), answered from the harness's log, so unbounded; opts.last = n for the most recent n.
      • Task is a plain table { task = id } with no methods (Lua host handles are methodless per crates/promptforge/lua/AGENTS.md and archdoc A9; every operation is a tasks.* namespace function); every tasks.* function accepts the table or the bare integer, so a handle stored in var works unchanged.
    • Author, ownership rules (tasks follow var): only the spawning chain may wait on, inspect, or cancel a task, with one addition: a chain may call tasks.status, tasks.events, and tasks.note on its own task (sys.taskid), which is how a task reports progress and how an agent reads its own history. Tasks survive jump and fall-through, keep running while the owner is blocked in call or a wait, transfer from the H1 pass to the main walk, and end when their owner's call chain or spawned chain ends. A chain that ends with live author-spawned tasks fails with an error naming them; aborting a chain aborts everything it owns.
    • Model, enabled by the author calling tools.allow_tasks(targets?) in a section (targets optionally restricts which sections may be started):
      • task { target, input? } starts a background chain and returns Task id=N started.
      • task_cancel { id }, task_status { id }, task_events { id, last? } mirror the author functions; status is trusted, events are marked untrusted because they contain another chain's model output.
      • await_tasks { timeout? } blocks the model's tool call until one of its tasks finishes or the timeout passes, returning the finished tasks' results, or timed out; tasks 3, 5 still running. With no tasks and a timeout it is a sleep. With neither it returns nothing to wait for.
      • Results the model did not await are appended as messages (Task id=N (## Heading) completed: ..., failed: ..., was canceled: the author cancelled it after an explicit tasks.cancel, was abandoned: <why the owner ended> with the reason the section ended, the tool loop was exhausted, or the owner failed) before its next model round. A chain ending with live model-started tasks abandons them and records TaskAbandoned; the model is not told because it has no next round in that chain.
    • Harness (host of the engine): parse the prompt; resolve declared capabilities and assemble a tool catalog; build a RunContext with a fresh random seed and the wall-clock start time; call Run::new; loop: step, perform each returned effect on tokio, log every effect, answer, and event, resume each answer as it arrives, until Done.
    • Workshop: opens sessions through harness-api, renders the event and delta streams, supplies operator input, builds the ui() snapshot, and pushes the gateway binding (base URL, key, generation) to the harness whenever the gateway it supervises is started or replaced; the harness rebuilds its capability registry on each push, as today's EffectExecutor does on a generation change. Papergate: switches its dependency from the engine to harness-api and supplies its own gateway binding the same way.
  • Inputs and outputs:
    • Engine in: a parsed prompt (shared through Arc), its args string, and a RunContext (name, seed, started_at, limits, current model, per-run VFS, tool catalog, filled tool and model bindings, ui snapshot, cancel handle). Engine out per step: a list of (EffectId, Effect) to perform and a list of Events produced, or the final RunResult with the last events.
    • Effects: Chat (one model round: binding, messages, the concrete list of advertised tool schemas, options), ToolCall (tool id, alias, JSON args), UserInput, Store (a store operation with the chain's access handle), Timer (seconds), TaskEvents (task id, optional last-n). Answers mirror them, plus Dropped meaning the host will not perform this effect. Each effect has a serializable projection, EffectRecord, which is the effect minus live handles (the store access); the log stores records, and only records deserialize.
    • Harness out: session events and streaming deltas to its client; a Turso database with runs and records tables.
  • States and validation:
    • A run is Pending (effects may be outstanding) until Done. Done is never reported while any issued effect is unanswered; the host must answer or drop every effect first. This preserves the claims rule that a store operation's access handle is released before the result is delivered.
    • A task is running, done (result undelivered), delivered, cancelled (someone called cancel on it), or abandoned (its owner chain ended while it was live, so the engine ended it). abandoned is a distinct terminal state so the log and the model notice can tell "was stopped on purpose" from "lost its owner". Waiting on a delivered task is an error; cancelling anything is idempotent.
    • {{ }} paths must resolve to JSON data; a function, userdata, or thread is rejected. sys holds only data fields (when, id, model, index, taskid); sys.now is removed.
    • Structural identity is deterministic: every chain (the main walk, a call child, a spawned task) has a hierarchical chain id, its parent chain's id extended by the parent's local child counter, with call children and spawns sharing that counter; a task's id is its chain's id; a section's sys.id is its chain's id extended by the chain's local entry counter. Two runs with the same inputs produce the same ids regardless of how their chains interleave, and a call child never collides with its parent because they are different chains.
  • Errors and recovery:
    • Every failure that reaches Lua is a table { kind, message, ... } whose tostring is the message, so pcall callers that print it see no change and callers that branch can read kind. Kinds: tool_loop_exhausted, context_exhausted (with reason), empty_model_reply (with finish_reason), out_of_scope_tool, unbound_tool, tool, task_not_owned, task_consumed, tasks_live, cancelled, lua, internal.
    • A model-issued tool call whose tool fails yields the failure text to the model as an untrusted result and the loop continues; a script-issued tools.call whose tool fails raises at the call site. Model tasks that outlive their owner are abandoned (ended by the engine, recorded as TaskAbandoned); author tasks that outlive their owner are a hard error. The two principals are treated differently on purpose: the author's leak is a bug, the model's is recoverable.
    • Store claims violations still end the run without resuming Lua. Cancellation from the host aborts every chain and reports Cancelled once outstanding effects are answered or dropped.
  • Security and privacy behavior:
    • Trust boundaries are unchanged: bound tool output and any text from another chain's model (task results, task_events) is wrapped as untrusted before a model sees it; task_status is trusted because it is scheduler fact.
    • The engine holds no credentials and opens no connections; the harness holds the model client and capability implementations, as workshop-sessions does today.
    • The run log contains model inputs and outputs verbatim, as the current JSONL session log does; it lives in the same state directory.
  • Acceptance criteria:
    • Behavior changes an author can observe, all accepted: fanout arms running loops interleave instead of serializing; models.loop counts against the Lua instruction quota (a few hundred instructions per round); a section ending with live author tasks fails (no existing prompt spawns tasks); pcall error values are tables (nothing in prompts/ or tests compares one to a string); fanout iterates a hash-shaped collection in sorted key order instead of undefined order; ui() is the snapshot taken at run start (the documented contract already says a change takes effect on the next run); sys.now is removed and two guide sentences change (guide/src/language/04-lua-globals-and-store.md); sys.id values change form but remain unique within a run.
    • Everything else authors can observe is unchanged, including every error message text that fanout and models.loop produce today.

Technical Design

The engine keeps the scheduler's existing shape (one thread, a chain arena, yield and resume) and removes everything that reached outside it. Two author features move from Rust into the Lua shim file so that every network wait inside them becomes an ordinary yield. A task arena replaces the fanout join machinery. The host boundary becomes four methods on Run, exchanging effects and events as serializable values. The harness is a new product family that performs effects on tokio, logs them to Turso, and absorbs the session machinery from Workshop.

flowchart LR
    WS["Workshop"] --> HAPI["harness-api"]
    PG["Papergate"] --> HAPI
    HAPI --> Runner["harness runner"]
    Runner -->|"step()"| Engine["Run"]
    Engine -->|"effects, events"| Runner
    Runner -->|"resume(id, a)"| Engine
    Runner --> Caps["capabilities"]
    Runner --> Models["model client"]
    Runner --> Log["Turso run log"]
    Engine --> Sched["scheduler"]
    Sched --> VM["section VM"]
    VM -->|yield| Sched
Loading
  • Architecture:
    • Engine (promptforge-api-runtime and its private crates under crates/promptforge/): a deterministic state machine. Given the same RunContext and the same sequence of answers it produces the same effects, events, and ids. It performs no I/O, reads no clock, and holds no host trait objects.
    • Harness (harness-*): the engine's only production host. It owns the tokio runtime, one performer per effect kind, the model HTTP client (moved from crates/promptforge/model-client/src/client/), the capability registry and first-party capabilities (moved from crates/promptforge-api-types/src/capabilities.rs and crates/promptforge/{web,webfetch,web-search}/), the input wait registry and supervisor (moved from crates/workshop/sessions/), and the run log.
    • Family rules added to crates/build-xtask/src/product.rs: harness-* may depend on promptforge-api-runtime, promptforge-api-types, gateway-api, gateway-api-discovery, and shared-*, never on workshop-* or private gateway-* crates; workshop-* may depend on harness-api; promptforge-* and gateway-* never depend on harness-*. Container crates/harness/ is private with harness-api as its door, the same shape as crates/promptforge/ with promptforge-api-runtime.
    • Layout: crates/harness-api/ (public), crates/harness/runner/ (effect loop, performer traits, cancellation, supervision), crates/harness/models/ (model client), crates/harness/capabilities/ (registry, activation, and the Capability, Tool, and InputBroker traits; depends on no provider), crates/harness/{web,webfetch,web-search}/ (the first-party capabilities as harness-web, harness-webfetch, harness-web-search; the two providers depend on harness-capabilities for the Tool trait, and harness-sessions depends on all of them to register the first-party set), crates/harness/log/ (Turso), crates/harness/sessions/ (discovery, session state, waits, supervisor state machine).
  • Modules and interfaces:
    • Run (crates/promptforge-api-runtime/src/execute/run.rs, new):

      pub struct Run { /* Arc<Prompt>, run state, scheduler, cancel flag, seed */ }
      pub enum Step {
          Pending { effects: Vec<(EffectId, Provenance, Effect)>, events: Vec<Event> },
          Done { result: RunResult, events: Vec<Event> },
      }
      // Every Event variant carries `provenance: Provenance` beside `execution` and `section`.
      pub enum Effect {
          Chat { binding: ModelBinding, messages: Vec<Message>, tools: Vec<ToolSchema>, options: CompletionOptions },
          ToolCall { tool: ToolId, alias: String, args: serde_json::Value },
          UserInput { execution: String, section: String },
          Store { access: Arc<Access>, op: StoreOp },
          Timer { seconds: f64 },
          TaskEvents { task: TaskId, last: Option<u32> },
      }
      pub enum EffectAnswer {
          Chat(Result<Completion, CompletionError>),
          ToolCall(Result<ToolOutput, ToolError>),
          UserInput(Result<InputOutcome, InputError>),
          Store(Result<StoreOutcome, shared_vfs::VfsError>),
          Timer,
          TaskEvents(Vec<Event>),
          Dropped,
      }
      impl Run {
          pub fn new(prompt: Arc<Prompt>, args: &str, ctx: RunContext) -> Run;
          pub fn step(&mut self) -> Step;
          pub fn resume(&mut self, id: EffectId, answer: EffectAnswer);
          pub fn cancel(&mut self);
      }
    • Run contract: step drains the ready queue and returns when no chain can proceed without an answer, or when the run is over. Pending with an empty effect list means "waiting on effects already issued." Done is withheld while any effect is unanswered. resume applies one answer, buffers that round's events, and re-queues the chain; an unknown id is an internal error; Dropped resumes the chain with a cancelled error and is itself an answer, so the harness writes an answer row for it and every effect in the log has exactly one answer. cancel sets a flag the Lua instruction hook already polls; the next step aborts every chain. Run is Send; one caller at a time; the thread may change between calls. The contract is incremental: one resume per arriving answer, then step, so an arm advances while its siblings' effects are still in flight.

    • Event (crates/promptforge-api-types/src/event.rs, replacing observe.rs and the Observer and DebugCapture traits): one enum. Lifecycle variants for run, section, model turn, tool call, input wait, and store operations (one per member of today's Observation enum), plus TaskStarted { task, target, origin, input, item, index, var } carrying the spawn seeds, TaskSucceeded, TaskFailed, TaskCancelled, TaskAbandoned (the owner ended first), and TaskResumed (reserved, unused until resume lands). Content variants: Thinking, AssistantReply, AssistantToolCalls, ToolResult, UserInput, TaskNotice, TaskNote. Debug variants: Request, Response. Every variant carries execution, section, and provenance; effects carry theirs in the Step::Pending tuple, (EffectId, Provenance, Effect), so the harness can write task_id and task_seq for every record without inspecting the payload.

    • Provenance { task: TaskId, seq: u32 } on every effect and event (not Origin: shared_vfs::observe::Origin already names the claims origin label one crate below, and TaskOrigin names the spawning principal; three Origins in one dependency chain would mislead): the nearest enclosing task (the main walk is task 0; a call child reports its parent's task, which is unambiguous because a call blocks its parent, so the two never interleave) and a per-task counter. This lets the harness slice its log by task and order within a task, and it is what a UI groups by; in durable-execution vocabulary it is the effect's replay key, and its doc comment says so. EffectId stays an opaque run-wide handle for in-flight correlation.

    • ReplayError { Nondeterminism, Fatal } in promptforge-api-types, defined now and unused until replay lands: Nondeterminism means a re-executed run or task issued an effect or event that disagrees with its record; Fatal means the record itself is malformed or internally inconsistent. The two are kept apart because the first is a property of the code under replay and the second of the log, and each demands a different remedy.

    • Flags (a #[repr(u32)] bitset, reserve-forever numbering) on RunContext and in the run record, empty in this plan. When a future engine change alters an exit rule or protocol detail, it runs the new behavior live and sets its flag, and a later replay honors the flag only if the original run recorded it. The gate function is deferred with replay; the field exists so the first such change has somewhere to record itself.

    • Event implements Serialize and Deserialize. Effect implements Serialize only through EffectRecord, its projection minus live handles (Effect::record(&self) -> EffectRecord); EffectRecord implements both. The store access handle cannot be deserialized into existence, and nothing in this plan reads an effect back into the engine, so the asymmetry costs nothing.

    • Protocol (crates/promptforge/lua/src/protocol.rs), the request vocabulary Lua yields: leaf requests Infer, Chat { messages, binding, tools }, ToolCall { alias, args, call_id }, UserInput, Store, TaskEvents; structural requests Call, Spawn { target, input, item, index, var, origin }, Timer { seconds }, WhenAny { tasks }, Ready, Status, Note, Cancel, Pending, DrainTaskNotices. Removed: Loop, Fanout, Mcp and their answers, parse_loop, parse_fanout, LuaFanoutResult, the registry-key plumbing that let Rust append to the author's message table, append_message_record, invoke_selected. A Chat from a section VM with tools: None means "the section's current tool scope, including local Lua tools"; the agent VM keeps passing an explicit list, and one arm serves both. ChatResult gains overflow (the request was refused before or by the provider as too large; no round ran) and reports an empty reply as a completed round with reply absent, so Lua applies the exit rules. ToolCall.call_id: Some marks a model-issued call: it always resumes with content (a tool's own failure becomes untrusted failure text), and ToolResult fires under that id; None is a script call and keeps today's behavior. A call to a local Lua tool is answered inside step on the parked chain's VM; no effect is issued. The five model built-ins (task, task_cancel, task_status, task_events, await_tasks) are recognized by name in the tool_call arm before alias lookup.

    • models.loop shim (crates/promptforge/lua/src/__impl_coro.lua): per round, drain pending model-task notices into messages; yield chat; on overflow call the compactor (default raises context_exhausted); on tool calls, yield one tool_call per call with its call_id, buffer the results, then append the assistant tool-call record and one tool record per result so the author's list never shows a half-answered batch; on a reply, append and return; on an empty reply with finish_reason == "stop" after at least one answered tool call, append an empty assistant record and return (the model's clean exit); otherwise raise empty_model_reply; after the iteration cap raise tool_loop_exhausted. New chunk captures beside yield and var_snapshot: max_tool_iterations, max_fanout_concurrency, compactors, raise(kind, fields), collection_members, render_item, drain_task_notices. The shim emits no events; the scheduler emits each round's events (turn advance, debug capture, turn completed or failed or truncated, thinking, reply or tool calls) when it applies the Chat answer, and rejects an out-of-scope tool name against the scope it advertised for that round.

    • fanout shim (same file): collection_members (array part in order, then hash part as { key, value } sorted by key); empty collection raises before any spawn; worker validation happens in the Spawn arm so its message is byte-identical; up to max_fanout_concurrency arms live, refilled on every when_any completion; results placed by collection index; tool_loop_exhausted in an arm becomes the incomplete stub (## <item>\n\nUNKNOWN\n\n(section incomplete: tool loop exhausted)) and the fanout continues; any other arm failure cancels the live arms and re-raises. when_all is not used because refill must happen between completions. The fanout cannot leak tasks: every arm is delivered by when_any or cancelled before the function returns or raises.

    • Scheduler (crates/promptforge-api-runtime/src/execute/scheduler.rs, split into scheduler.rs plus the scheduler/ directory holding dispatch.rs, tasks.rs, walk.rs in standard module layout, because a three-file group is a directory under the repository's flat-directory rule, and to stay under the 500-line ceiling): keeps chains, ready, pending, stack; adds tasks: HashMap<TaskId, TaskSlot { backing: Chain | Effect, owner, origin, target, state }> (an effect-backed slot is the internal timeout timer), and on each chain owner, waiting_on, advertised (the last round's tool scope), task_notices (undelivered model-task notices), note; keeps a run-level event buffer drained by step. Removes the fanout join tables and arm templates, the tokio channel, join handles, the abort bookkeeping, the lazy gateway client, and the run-global id counters. finish(chain) checks the chain's own live tasks (author-origin: the outcome becomes tasks_live; model-origin: abandoned, each slot set to Abandoned with a TaskAbandoned event carrying why the owner ended), then completes the chain's task slot and wakes a waiting owner or queues a notice. Terminal slots (Done, Cancelled, Abandoned) persist until their result is delivered or their owner ends, so status can report the terminal state; Cancelled and Abandoned are delivered to a waiter as ok = false with a cancelled or abandoned error value. abort_subtree also aborts every chain the aborted chain owns. A stall (nothing ready, nothing pending, nothing waiting) is an internal error. The H1 hand-off reassigns H1's tasks to the main walk chain. await_tasks reuses the WhenAny arm from the tool_call path: park on the chain's model tasks plus an optional timer, drain notices on wake, cancel an unfired timer, resume with the rendered text.

    • Identity: no run-global counters. Every chain has a hierarchical chain id: its parent chain's id extended by the parent's local child counter, which call children and spawned tasks share, so a parent and its call child are distinct chains with distinct ids. A task's id is its chain's id. A section's sys.id is its chain's id extended by the chain's local entry counter. EffectId is allocated from a run-wide counter because it is an opaque in-flight handle that need not reproduce. The encoding of chain ids and sys.id (packed integer or path string) is chosen after surveying what reads sys.id; the property is the requirement.

    • Tool bindings and Environment (crates/promptforge-api-runtime/src/execute/{bindings,environment}.rs): ToolBinding carries id, alias, schema, description, output kind, and conflicts, never an implementation. Environment keeps base_vfs, max_depth, and a ToolCatalog the host supplies; prepare builds the per-run VFS, fills tool slots by id against the catalog, fills model bindings against the current model, and reports Requirements. Capability activation and conflict checking leave the engine.

    • RunContext after the change: name, seed (u64, host-drawn; the nonce guard derives from it), flags (empty Flags), started_at (Timestamp, UTC milliseconds, rendered to RFC 3339 for sys.when by a std-only formatter), limits, model, vfs, tools (catalog), tool_bindings, model_bindings, ui (a JSON value snapshot), cancel (a sync CancelHandle: an AtomicBool parent-child tree with cancel, is_cancelled, child). Removed: observer, client, input_broker, on_delta, debug.

    • Harness effect loop (crates/harness/runner/): step; append events to the log; for each effect append it and spawn a performer (tokio::spawn, or spawn_blocking for Store) that sends (id, answer) on a channel; select! over the channel and the session's cancel; on an answer, append it and resume; on cancel, run.cancel(), abort in-flight performers, await blocking-pool store operations so their access handles release, answer each outstanding effect Dropped, then step to Done. Performers are one trait per effect kind: ChatPerformer (model client, streams deltas to the session), ToolPerformer (resolves the tool id against activated capabilities), InputPerformer (wait registry), StorePerformer, TimerPerformer (tokio::time::sleep; tokio's timer wheel multiplexes every pending sleep, so no harness-side heap is needed), TaskEventsPerformer (reads the log). Events are committed before the producing step's effects are issued, because a running task may read history through TaskEvents.

    • Harness run preparation: parse; resolve declared capabilities, check co-activation conflicts, activate with RunServices { vfs, cancel }, assemble the ToolCatalog and the id-to-implementation table; build RunContext with a fresh seed and started_at, both logged; Environment::prepare; fail on unmet requirements with today's model-readable notice; Run::new; loop.

    • harness-api exposes Harness (from config: agents path, state dir), Harness::set_gateway(binding) (base URL, key, generation; the client calls it at startup and on every gateway replacement, and the harness rebuilds its capability registry and model client when the generation changes, which is what EffectExecutor does today against workshop-gateway's snapshot), Session (launch, send input, cancel, close, subscribe to events and deltas), and the event and delta types clients render. The harness never depends on workshop-gateway; the binding is data pushed across the door.

    • Harness session lifecycle: a run is Alive, then Closing once cancel or close is requested (outstanding effects are being answered or dropped), then Closed once Run reports Done; the supervisor's pure transition reducer (moved from crates/workshop/sessions/src/agents/supervisor/transition.rs) gains one pure rule, effective_interrupt(interrupt, saw_terminal): a genuine terminal outcome that arrives before a late cancel or timeout wins, and the synthetic terminal frame for an interrupt is rendered in exactly one place. This replaces the hand-managed active_run.take(), finish_run, and generation bookkeeping in today's EffectExecutor. The reducer's matches stay wildcard-free so a new variant is a compile error, with a fixture-coverage test.

  • File and public API changes:
    • promptforge-api-runtime: removes execute::run, Environment::run, execute/gateway.rs, GatewaySource, execute/tool_loop.rs, dispatch_loop, run_loop, the client module, and the dependencies tokio, async-trait, tracing, rand, time. Adds execute/run.rs. now_rfc3339_checked in execute/support.rs becomes an infallible formatter over Timestamp; Error::TimestampFormat goes. Public surface: Run, Step, Effect, EffectAnswer, EffectId, Event, Provenance, RunContext, RunLimits, Environment, Requirements, RunResult, RunError, Prompt, promptforge_version, types. A test-support feature provides a serial driver (drive(run, perform) -> (RunResult, Vec<Event>)), an adapter that replays a returned Vec<Event> into the recording-observer trait the existing suites install (so those suites compile and pass without rewriting their assertions), and, under dev-dependencies, a tokio driver with the existing axum mock gateway, so the current suites keep running while they migrate.
    • promptforge-api-types: removes tokio, tokio-util, async-trait, rand, the async Tool trait, and capabilities.rs (the InputBroker trait is in promptforge-api-runtime/src/input.rs and leaves from there); observe.rs becomes event.rs; events.rs (EventLog, RuntimeEvent, RuntimeEventKind) is deleted, its read-side role passing to the TaskEvents effect; adds Timestamp, Provenance, TaskId, ReplayError, Flags. Keeps ToolId, ToolSchema, ToolOutput, ToolError, InputOutcome, InputError, catalogs, metrics, untrusted guards.
    • promptforge-model-client: client/transport.rs, reqwest, and url move to crates/harness/models/; client/wire.rs (serde wire shapes, no HTTP) stays as vocabulary so the engine's own suites can speak to the mock gateway without depending on a harness crate. Vocabulary (Message, Completion, CompletionResult, CompletionError, ToolSchema, ToolCall, CompletionOptions, ModelBinding, metrics) stays.
    • promptforge-lua: removes tokio and runtime_events.rs (the agent-only runtime.events() view; tasks.events replaces it); dispatch_tool in src/dispatch.rs splits into the sync prepare_dispatch (counts, trust classification, nonce wrap, ToolResult event) used at resume, and the async race, which leaves. __impl_coro.lua gains the loop, fanout, tasks, and timeout shims and loses nothing authors call.
    • crates/promptforge/{web,webfetch,web-search}/ move to crates/harness/{web,webfetch,web-search}/ as harness-web, harness-webfetch, harness-web-search; the registry and activation move to crates/harness/capabilities/.
    • crates/workshop/sessions/ is deleted; its agents/supervisor/*, agents/lifecycle.rs, agents/environment.rs, agents/session.rs, input.rs, input-tool.rs, agent discovery, and the embedded chat.md move to crates/harness/sessions/; session-log.rs (JSONL) is deleted in Step 35 and its role is taken by crates/harness/log/ (Turso) in Step 48. agents.rs, agents/socket.rs, session.rs, session-menu.rs, relay.rs, relay-tests.rs, state.rs, and the protocol frames stay in Workshop (moved into workshop-server); workshop-server depends on harness-api.
    • crates/build-xtask/src/product.rs: the harness family, its matrix row, the container door, and a manifest test that every engine crate's non-dev dependency tables exclude tokio, tokio-util, async-trait, reqwest (declared dependencies, so workspace-hack unification is irrelevant). Two further guards in the same crate: a source-identifier scan over the engine crates (comments and strings stripped) that fails when a retired symbol reappears, seeded with install_agent_chat_shim, EventsSnapshot, install_runtime_events, GatewaySource, run_models_loop, LuaFanoutResult, Observer, DebugCapture; and a check that every crates/harness/ crate carries a clippy.toml whose disallowed-methods names tokio::spawn and tokio::task::spawn_blocking, so the harness spawns only through one instrumented wrapper in harness-runner that tags the task with its EffectId and Provenance.
    • Guide: guide/src/language/04-lua-globals-and-store.md loses the sys.now sentence and documents sys.id's hierarchical form and the tasks namespace; regenerate the assembled guide. Root AGENTS.md Roles and Structure gain the harness in Step 1 (so the authoritative doc never lags the tree); crate-level AGENTS.md files are rewritten in the step that moves or renames what they describe (Steps 27, 36, 37, 38, 47). Papergate's migration (its current engine calls and their harness-api replacements) is written as a note for its own repository.
  • Data, persistence, failure, security, and privacy constraints:
    • Run log (crates/harness/log/, Turso, already a workspace dependency): runs (run_id, session_id, agent, prompt_hash, seed, flags, started_at, ended_at, outcome, final_text, error_kind, error_message) and records (run_id, seq per run, task_id, task_seq, kind in effect | answer | event, effect_id, payload JSON holding an EffectRecord, EffectAnswer, or Event, at), indexed on (run_id, task_id, task_seq). Append-only; seq is the loop's order, not the clock's. Session transcript views, Workshop reconnect, and TaskEventsPerformer read records where kind = 'event'. Nothing reads answer rows back into the engine.
    • Determinism delivered: given the same RunContext and answer sequence, effects, events, Provenances, and sys.ids are identical, except where author Lua iterates a table with pairs (Lua randomizes the string hash seed per state; control of it is deferred).
    • The engine reads no clock: started_at is an input, timeouts are Timer effects. Under a future replay no timer would sleep.
    • Cancellation aborts in-flight performers in the harness; the engine only observes a flag and drops. The claims rule holds because Done is withheld until every store effect is answered or dropped and the harness awaits blocking-pool store operations before dropping them.
    • Trust: tool output and cross-chain model text are nonce-wrapped as untrusted before a model reads them, as today; task_status is trusted.

Testing Plan

The existing engine suites (about 14,000 lines under crates/promptforge-api-runtime/src/execute/tests/ and tests/suite/, mostly #[tokio::test] against an axum mock gateway) remain the acceptance tests for the Lua loop and the Lua fanout, run through the test-support tokio driver. New engine behavior is tested with the serial sans-IO driver in plain #[test]s, which need no runtime and no HTTP. Harness crates get unit tests with fake performers and an in-memory Turso database; Workshop's integration suites run unchanged against harness-api.

  • Unit:
    • promptforge-lua: the structured error table round-trips (kind, tostring, typed substitution when it surfaces as a coroutine failure); every ChatResult rendering including overflow; collection_members ordering; the tasks shims' argument handling.
    • Scheduler and tasks: spawn/when_any/when_all/ready/status/note/cancel semantics; when_all reporting a failed member without raising; both timeout outcomes for each wait (timer wins: nil or timed_out, members keep running, no tasks_live at chain end; member wins: timer cancelled and its effect dropped); status for a parked and a finished task; ownership errors; survival across jump; termination at call end; H1 transfer; the tasks_live message text; abort_subtree over owned tasks; stall detection with a waiting chain; hierarchical ids identical across two runs whose fanout arms finish in different orders.
    • Model tasks: a scripted mock model emitting task, task_cancel, task_status, task_events, await_tasks; notices delivered before the next round; await_tasks returning on completion and on timeout with the still-running list; nothing to wait for; canceled for you on loop exhaustion; cancellation recorded only as an event at chain end; author adoption via tasks.pending; allowlist rejection; sibling chains stepping while one is parked in await_tasks.
    • Run with the serial driver: the doc example; a three-arm fanout with answers fed in reverse order; Done withheld while a Store effect is outstanding and delivered after Dropped; the event stream matching the former observer sequence; the determinism property (same seed, started_at, answers: identical effects, events, Provenances, sys.ids); the batching-pairing property: the same answers delivered one per step and all at once per step (and in shuffled arrival order within a batch) produce identical effects, events, and ids, which is the engine-side analogue of Temporal's incremental-versus-replay pairing; a task whose owner ends first reports abandoned, not cancelled, in both its event and the model notice.
    • harness-runner with fake performers: log record order, cancellation drops outstanding effects, store answers awaited before Done, timers answered and aborted. harness-log: round-trip against in-memory Turso; per-task slice ordering.
  • Integration and end-to-end:
    • execute/tests/{tool_loop,models_loop,exec_flow,model_and_reply,local_tools,tool_scoping,exit_rules,observations}.rs through the tokio test-support driver for the Lua loop; tool_loop.rs tests that called run_prose_inference directly are rewritten at prompt level.
    • fanout.rs and execute/tests/scheduler.rs for the Lua fanout: collection order, refill on any completion, fail-fast with exactly one terminal event per arm, exhausted stub, empty collection, list-section worker, nested fanout, claims violation across arms.
    • harness-sessions inherits workshop-sessions' suites (agents/tests.rs, input-tests.rs, transition-tests.rs) relocated. Workshop server suites (crates/workshop/server/tests/it/agents/*, chat_gate/*, realtime_relay.rs) unchanged against harness-api.
  • Regression, security, and performance:
    • The build-xtask manifest test fails on any forbidden dependency in an engine crate; the retired-symbol scan fails on a seeded name reintroduced in a fixture and passes on a fixture where the same name appears only in a #[cfg(test)] module, a comment, or a string (the scan covers non-test engine sources only); the harness clippy.toml check fails on a harness crate missing the disallowed-methods entries; the family matrix fixtures cover the harness row and the container door.
    • Identity: a prompt whose section calls a child section produces distinct sys.ids for parent and child entries; a fanout inside a call child produces ids nested under the child's chain id.
    • Supervisor reducer: table tests for effective_interrupt (terminal before interrupt wins; interrupt before terminal renders the synthetic frame once) and a fixture-coverage test over every interrupt variant.
    • The models_loop criterion bench (crates/promptforge-api-runtime/benches/models_loop.rs) shows no round-overhead regression from the Lua loop.
    • Trust wrapping asserted on task results and task_events reaching a model.
  • Exit criteria:
    • The workspace gate list in AGENTS.md: cargo fmt --all --check; both clippy invocations with -D warnings; cargo check -p gateway --no-default-features; nextest for the workspace set and the workshop set; doctests; rustdoc with -D warnings; cargo test -p build-xtask; cargo deny check; cargo hakari verify.
    • Stop and re-plan when an existing test's expected event order or error text changes for a reason not listed under Acceptance criteria, or after two consecutive failures with the same signature on one item.

Decision Record

  • Decisions:
    • Invert control: the engine returns the work it needs instead of performing it. Rationale: the scheduler was already a yield/resume state machine with I/O bolted on at the leaves; returning leaf yields as values removes the runtime dependency and makes the engine a deterministic function of its inputs. User's words: "the executor runs until it reaches a point where it wants inference or a tool call, and then it returns to the caller and then the caller provides the service."
    • models.loop in Lua over chat and tool_call yields, not a Rust state machine on the chain. Rationale: the loop's state becomes coroutine locals, the VM is live between rounds because the yields are the coroutine suspending, the fanout-with-loop serialization disappears, and the compactor framework gets a natural home. User's words: "rewrite models.loop in Lua so it can yield as a coroutine more often."
    • spawn, when_any, when_all, cancel as Lua shims over a task arena; fanout rewritten in Lua on top. Rationale: the scheduler already held an open set of chains; the task shims expose it and about 600 lines of join code become forty lines of Lua. User's words: "Can we then reimplement fanout() in lua, in terms of spawn()?"
    • when_any over a set is the only scheduler wait primitive; when_all and join are Lua over it, and when_all never raises for a member's failure. Rationale: fanout's refill and fail-fast need "first of any"; a raising when_all would force a cancel-or-leak choice on the other members that is wrong for half the callers. User's words: "having tasks.when_any tasks.when_all instead of just tasks.wait."
    • Tasks follow var: survive jump and fall-through, end with call and spawned chains, transfer from H1 to the walk, owner-only access, plain-data handles. Rationale: authors already hold the var model, and it matches the chain arena exactly.
    • Author task leak is a hard error; model task leak is a soft cancel with a notice to the model. Rationale: mirrors the existing rule that a tool's failure is the model's result record rather than the run's error; the author's mistake is a bug, the model's is recoverable. User's words: "subagents spawned by Lua become a hard error on chain termination. While subagents spawned by the model ... become soft warnings."
    • Effects and events are values returned from step; no callbacks remain. Rationale: the engine becomes pure, the harness gets one ordered stream, tests assert on a vector; delivery granularity is one step, which is Lua-fast. User's words: "that Vec<EffectId,Effect> and Vec sounds amazing!"
    • Incremental step/resume, never a batch API; Done withheld while effects are outstanding, Dropped as the host's release. Rationale: fanout parallelism at the leaves depends on resuming one arm while others' effects are in flight; the claims rule needs no join handles inside the engine.
    • Tool bindings carry ids, not implementations; capabilities move to the harness. Rationale: the harness performs tool calls, so the engine never needs the async Tool trait, and async-trait leaves the engine's types.
    • Tokio lives in the harness. execute::run and Environment::run are removed rather than preserved. Rationale: their one production consumer is moving into the harness; test-support drivers serve the engine's own suites. User's words: "so where does the tokio go" and "the engine's public API has to change ... its not a big change."
    • A harness-* product rather than a layer inside promptforge-* or workshop-*. Rationale: its own dependency discipline (tokio, Turso, the model client, capabilities; never Workshop), its own public door, and a second consumer (Papergate), the test a separate engine-* product failed. User's words: "move workshop/sessions functionality into a new top-level product called harness-*."
    • The harness owns the effect loop, not a wrapper around run. Rationale: replay, resume, and a single ordered log only exist if the harness sees every effect and answer. User's words: "yes of course the harness owns the effect loop."
    • Turso for the run log. Rationale: already a workspace dependency, and the run history is the workload that justifies it. User's words: "I am 100% certain on Turso, the value-add is enormous."
    • {{ }} stays data-only; sys holds only data. Rationale: substitution is documented as data, and admitting calls opens prose to invoking tools. User's words: "I want the narrow rule. No function calls in {{ }}."
    • No clock in the engine: sys.when is an input, timeouts are Timer effects, sys.now is removed. Rationale: nothing reads sys.now; effects are for rare explicit reads. User's words: "We should ship without now() and only when we need it."
    • Timeouts as an option on the wait shims, with the timer internal. Rationale: the shim always cancels the timer, so no leak question and no new author-visible task kind. User's words: "What about when_any_with_timeout(60, {t1,t2,t3})?"
    • The model gets task, task_cancel, task_status, task_events, await_tasks, not when_any/when_all. Rationale: Cursor's own shape (fire-and-forget with pushed results plus a bounded blocking wait like AwaitShell); a model has no idle loop; await_tasks covers the defensive check-in. User's words: "it might want to defensively put a 60s checkup timer on it."
    • History lives in the harness, present state in the engine; tasks.events is an effect answered from the log. Rationale: unbounded there, free here, reached like every other external read; the step stream is pulled so it needs no buffer or backpressure. User's words: "there should not be a limit."
    • Replay hints in the engine, replay logic in the harness: Provenance on every effect and event, hierarchical deterministic ids, spawn seeds on TaskStarted, TaskResumed reserved. Rationale: cheap now and painful to retrofit into a log schema; the engine knows a task is new or revived (a structural fact) but not where a replayed prefix ends (a harness fact). User's words: "The engine can distinguish between 'new task starting' versus 'existing task resumed'."
    • Manifest test on declared dependencies rather than cargo tree on the closure. Rationale: immune to workspace-hack feature unification; the shape crates/shared-vfs/Cargo.toml already uses.
    • Adopt the ten ranked findings of the 2026-09-18 field study of six references (lash, everruns, paigasus-helikon, Temporal sdk-core, zed, str0m), with three timing adjustments: the behavior-flag gate, at-most-once claim/settle for tool effects, and the child token-budget auto-cancel wait for replay or for harness policy; the flag field and column land now so the first replay-breaking change has somewhere to record itself. Rationale: every ranked finding maps to a named deficit and all but one were confirmed by two or more references. User's words: "should we simply adopt all the findings?" and "Yep. Do you recommendation."
    • Take the field's names where the field converges and we have no local reason otherwise: Effect, Event, Nondeterminism and Fatal for the two replay error kinds, Abandoned for a task that lost its owner, perform for what the harness does to an effect. Keep ours where the name carries a semantic the field's does not: Run::step and Run::resume (a whole batch per step, and a Lua coroutine really resumes; the sans-IO poll_output/handle_input pair implies one item per call and a drain contract we do not have), when_any/when_all (chosen from C++; no convergent alternative in the field), Task, Provenance for the replay key (the field's word would be Origin, but shared-vfs already uses Origin for claims labels and TaskOrigin names the spawning principal), Dropped (the host declined an effect, a different thing from Abandoned). Rationale: shared vocabulary helps readers who know the references, but borrowing a name without its contract misleads them.
    • Retired-symbol scan and a harness-side tokio::spawn ban as guards, not conventions. Rationale: the subject's fingerprint found 90-plus "legacy engine" anchors and dead protocol arms that a review did not catch; everruns and lash make the same class of rule a test or a lint, and the repository already has the build-xtask harness to hold them.
    • Abandoned distinct from Cancelled. Rationale: a task that lost its owner and a task someone stopped are different facts for the log, the UI, and the model notice; lash keeps them apart for the same reason.
    • A pure "terminal beats late interrupt" rule and an Alive/Closing/Closed lifecycle in the harness supervisor. Rationale: paigasus-helikon reduces the race to one function with wildcard-free matches, str0m's three-state lifecycle makes the host's question "is it closed"; both replace bookkeeping the subject's EffectExecutor does by hand.
    • Deterministic fanout member order; FANOUT_ARM_* events retired for TASK_*; spawned chains use call's target resolution. Rationale: one rule per concept; fanout is no longer a scheduler concept.
    • Interim dependency shape (added at decomposition): between the step that retires execute::run and the step that deletes workshop-sessions, tokio is an optional dependency of promptforge-api-runtime enabled only by test-support, and the engine manifest guard exempts an optional dependency whose sole enabling feature is test-support; the deletion step moves tokio to dev-dependencies and removes the exemption. Rationale: a dev-dependency is invisible to workshop-sessions, so the plan's interim cannot run on a dev-only driver; the exemption is the smallest bend and ends with the interim.
    • client/wire.rs stays in promptforge-model-client as pure vocabulary; only client/transport.rs, reqwest, and url move to harness-models (added at decomposition). Rationale: the engine's own suites drive the axum mock gateway through a dev-only tokio driver that needs the wire types and a dev-dependency on reqwest; a dev-dependency on harness-models would violate the product matrix.
    • harness-api carries a temporary bridge module of re-exports (model client, capability registry and activation, moved session pieces) during the migration, removed when workshop-sessions is deleted (added at decomposition). Rationale: workshop-* may name only harness-api, so each move can be one small commit instead of one deletion commit that moves everything.
    • The three web crates move as siblings crates/harness/{web,webfetch,web-search}/ renamed with the harness- prefix, and crates/harness/capabilities/ holds the Tool trait, registry, and activation and depends on no provider; the providers depend on it for the trait, and harness-sessions is the one crate that depends on both sides and registers the first-party capabilities (added at decomposition; direction fixed at the AGENTS.md review because the reverse is a cycle: webfetch/src/tool.rs and web-search/src/web_search.rs implement Tool). Rationale: families are keyed by name prefix and the matrix has no nested containers; folding three crates into one would erase harness-webfetch's own test target for invariant A3.
    • tasks.events, task_events, and the TaskEvents effect land with the Run API, not with the model-tasks component (added at decomposition). Rationale: before Run exists the engine has no history source to answer them; the test drivers answer from their event buffer.
    • AGENTS.md review (2026-09-18, after decomposition): every AGENTS.md in the repository (root plus 27 crate files) was checked against every step. Decisions, each recorded where it applies in the text above: Task handles are methodless (lua AGENTS.md and archdoc A9 forbid colon methods on host handles; user chose to drop the methods rather than take an exception); the retired-symbol scan and the harness clippy.toml check received the explicit approval the root Engineering rule requires; the three-file scheduler split is a scheduler/ directory and protocol.rs is split before it is edited (flat-directory and 500-line rules); parentless kebab filenames became plain modules and loop.rs was renamed because loop is a keyword; harness-capabilities depends on no provider and the providers depend on it (the plan's original direction was a cycle); code moved out of workshop-sessions sheds workshop_registry and workshop-server registers the Harness handle at boot (matrix rule and workshop-server AGENTS.md); root AGENTS.md Roles and Structure are updated in Step 1 and every crate AGENTS.md in the step that changes what it describes, because AGENTS.md is authoritative and must not lag the tree; harness-* crates carry the ## Invariants marker and the ceiling check extends to them; EventLog, events.rs, runtime_events.rs, and the JSONL session log are deleted rather than adapted (user accepted that Workshop writes no JSONL between Steps 35 and 48); the runtime AGENTS.md store-scope rule is reworded around minted Access handles; the replay key is Provenance, not Origin, because shared_vfs::observe::Origin and TaskOrigin already hold that word; a chain may call status, events, and note on its own task.
    • Execution review (2026-09-18, after the AGENTS.md review): a full read of the step text for data flow and ambiguity fixed eight things: Provenance has a concrete home (a field on every Event, the middle element of the Step::Pending effect tuple); test_support::Performers is a struct of boxed async closures, so workshop-sessions implements no test-support trait in the interim; the Step 28 events-to-observer adapter moves into test_support at Step 35 when its last production consumer dies; Step 8 records the models_loop bench baseline that Step 14 compares against; the harness spawn-ban check also covers the door crate harness-api; drive_run's sink is typed as FnMut(Event) and deltas travel on a separate DeltaSink; every file in workshop-sessions is named in either the move-to-harness list or the stay-in-Workshop list, and only the two moved files shed workshop_registry; the async InputBroker trait is deleted at Step 47 once InputPerformer replaces its only implementor.
    • Run owns its prompt (Arc<Prompt>) rather than borrowing it. Rationale: the harness holds a Run across awaits for the run's whole life and stores it beside the prompt it came from; a borrowed prompt would make that pair self-referential. Added at review.
    • How the execution steps are cut: dependency order first; between independent steps the less risky one first (risk being files touched, public interface or persisted shape changed, existing test expectations changed); safe additive work front-loaded as far as dependencies allow; fine-grained steps with one narrow test each and light per-step testing; the rich suites concentrated in checkpoint steps that add no product code, placed at least at the Lua loop complete, tasks and fanout complete, model tasks complete, the Run API complete with the engine dependency-free, the harness runner and log complete, and workshop-sessions deleted. Rationale: small commits are easy to review and revert, and a regression surfaces at a known checkpoint rather than anywhere. User's words: "ordered in dependency order, and for tie breaker from least risky to most risky. Front load the safe stuff as much as possible. Use fine grained steps but if you do that then go VERY light on the testing. Bake well-defined more rich test checkpoints into the plan."
    • This plan supersedes the earlier "Harness API crate" plan (workspace plan file harness_api_crate_7eaf6056), which created harness-api on the callback Observer design with a per-run Turso record ordered by causal position, section name, and chain id, plus a workshop-runs crate and a Run-button table. What carries over from it: PaperGate lives at wg21-paperflow/crates/papergate and path-depends on promptforge-core, a crate that no longer exists, so its migration note (Step 50) starts from a broken dependency, not a working one; turso is pinned =0.7.2 and workshop-workspace already has the open_database, SCHEMA_V1 with PRAGMA user_version, one-actor-per-file pattern the run log copies; WorkshopObserver in crates/workshop/gateway/src/observer.rs is an in-tree Observer implementor that Step 35 converts; and the Run window's requirement that events appear in one deterministic order never decided by wall clock is met by the log's loop-assigned seq and per-task Provenance, so a client can order by (seq) for arrival or by (task_id, task_seq) for per-task causality without a clock. Not carried over: the observer-based recorder, the Coordinates { chain_id } change to Observer::observe, and the workshop-runs crate and Run-button UI, which are a later client of harness-api outside this plan.
  • Rejected alternatives:
    • A Rust state machine for models.loop stored on the chain. Reason: an explicit phase enum re-entering every exit rule across two resume points per round, and the compactor framework stays hard. Revisit: never, unless Lua instruction cost per round proves measurable.
    • A single blocking join instead of when_any(set). Reason: fanout's refill and fail-fast react to any arm ending. Revisit: none.
    • A when_all that raises on the first member failure. Reason: forces cancel-or-leak on the remaining members. Revisit: none.
    • step(now) threading a timestamp through every step. Reason: feeds a sys.now field nobody reads; effects are for rare explicit reads. Revisit: none; now() as an effect is the deferred design.
    • now() as an effect, or sys.now() as a function, shipped now. Reason: no consumer in the tree. Revisit: the first prompt that needs a live clock (the known candidate is the Mentographist, an interviewer prompt kept outside this repository in the workspace's tools-public/agents/mentograph.md, which stamps each transcript turn with the time it was asked).
    • An author-visible completes_after(seconds) timer task. Reason: an internal timer on the wait shims covers timeouts with no leak question. Revisit: a prompt needing a bare sleep or a timer composed with something that is not a task.
    • Zero-argument function calls inside {{ }}. Reason: opens prose to tool invocation; the user chose data-only. Revisit: none.
    • when_any/when_all as model tools. Reason: a model has no idle loop; results arrive as messages; await_tasks covers the blocking case. Revisit: none.
    • A bounded ring buffer of events inside the engine for tasks.events. Reason: a bound on retained history is a memory policy with no correct value, and the harness log already holds all of it. Revisit: none.
    • Scheduler-level pause/resume of a parked chain (freeze, later re-dispatch its pending request). Reason: re-issues the stuck operation and re-runs non-idempotent tool calls; resume-by-re-execution with a substituted answer is the correct mechanism. Revisit: none.
    • The engine consuming replay history (resume_task(id, history)). Reason: puts the log's shape and a replay mode inside the pure core; the harness can match exactly because the engine is deterministic. Revisit: none.
    • A separate engine-* product for the sans-IO core. Reason: an engine- prefix classifies as no family in product.rs (fewer rules, not more); the core has no consumer independent of PromptForge. Revisit: a second consumer wanting the engine without the product (another workspace product, a WASM build, a separate release).
    • Keeping a tokio driver and execute::run inside promptforge-api-runtime behind a feature. Reason: superseded once the harness exists as the only production host. Revisit: none.
    • A cargo tree CI gate on the feature-off closure. Reason: collides with workspace-hack unification. Revisit: none; the manifest test replaces it.
    • Doing nothing. Reason: leaves the fanout-with-loop serialization, the tokio coupling, and the join machinery; forecloses replay and the compactor work. Revisit: none.
  • Assumptions, risks, and notes:
    • Lua randomizes its string hash seed per state, so author code iterating with pairs is not reproducible across runs. The plan's determinism claims exclude that case; bit-exact replay needs the deferred seed control.
    • The sys.id encoding is chosen during implementation after surveying readers; the guide (guide/promptforge-language-guide.md) documents it as an id, and no prompt in the tree assumes consecutive integers.
    • The engine tests total about 14,000 lines; they migrate gradually behind the test-support drivers, not in one change.
    • Between the engine change landing and the harness sessions crate landing, workshop-sessions runs on the test-support tokio driver, which means a production crate enables the runtime's test-support feature for that interval. This bends the repository's rule that features gate constraints rather than product shape; it is accepted as a temporary state, called out in the commit that introduces it, and removed by the commit that deletes workshop-sessions. The interim state must pass the Workshop suites.
    • Local Lua tool handlers run inside step synchronously; they are sandboxed Lua whose only effect is on VM state, so they are compatible with re-execution.
    • Turso's footprint (59 exclusive crates per vibe/dependency-surface.md) is accepted; the run log is its justifying workload.
    • promptforge-tool-picker is being removed by a separate plan (vibe/2026-09-18-2-remove-tool-picker.md); this plan assumes it is gone or ignores it. Found at Step 8 (2026-09-18): that removal also deleted the models_loop criterion bench that Steps 8, 14, and 40 and the Testing Plan's exit criterion rely on. Decision: Step 8 restores the bench without its picker usage (a dev-dependency on criterion only) rather than dropping the regression gate. Falsifier: the restored bench cannot be adapted to today's Environment::run API without measuring something other than round overhead.
    • models_loop bench baseline (pre-Step-8): recorded 2026-09-18 on the restored bench (cargo bench -p promptforge-api-runtime --bench models_loop, criterion 0.5, 100 samples, two consecutive runs) before either file split, on x86_64-pc-windows-msvc. models_loop: run 1 [1.9309 ms 1.9529 ms 1.9786 ms], run 2 [2.0687 ms 2.0896 ms 2.1136 ms]. compactors_fail: run 1 [770.94 µs 773.35 µs 775.57 µs], run 2 [756.91 µs 761.78 µs 766.26 µs]. Run-to-run spread on models_loop is about 7%, so Step 14 should treat anything inside 10% as noise and compare against the mean of these two runs (about 2.02 ms and 768 µs).
    • Papergate's code change lands in its own repository; this plan produces only the migration note.
    • The field study these additions come from ("What to steal for PromptForge: sans-IO engine, harness, effect and event streams, run log, replay, subtasks", 2026-09-18, six references at pinned commits) found the subject already matches or beats the references on effects-as-data at the script boundary, the single-owner scheduler, structural cancellation, the pure supervisor reducer, and test-enforced tiers; those are preserved, not redesigned.
    • Gateway supervision stays in Workshop (crates/workshop/gateway/); the harness receives the binding as data through Harness::set_gateway and never depends on workshop-gateway. Papergate supplies its own binding the same way.

Deferred and Out of Scope

  • Deferred, replay: a run is bit-exact reproducible from its log. Contract: construct RunContext with the logged seed and started_at, feed answer rows through resume in seq order, expect identical effects and events including Provenance and sys.id; no timer sleeps. Prerequisites: Lua string-hash seed control (a build-level define of luai_makeseed calling a function the engine sets before creating the state), a replay driver in harness-runner, a divergence report. Revisit: when resume or audit needs it.
  • Deferred, resume a cancelled task by re-execution: spawn the task again from its TaskStarted seeds under its original id (a revive variant of Spawn emitting TaskResumed), feed its own recorded answers until exhausted, then go live; the resumer may supply the answer for the effect the task was cancelled inside (task_resume { id, answer? }, tasks.resume(t, answer?)). The harness owns history, matching, divergence, and the substituted answer; the engine owns only the revive request. Revisit: with replay.
  • Deferred, now(): a bare global yield shim producing a Now effect answered with a Timestamp. Revisit: first consumer.
  • Deferred, author-visible completes_after: one exposed line plus a tasks_live exemption for effect-backed tasks. Revisit: first consumer.
  • Deferred, a task mailbox (tasks.send, inbox()) for redirecting a running task without cancelling it. Revisit: a case resume-by-re-execution does not cover.
  • Deferred, the behavior-flag gate (try_use_flag(flag, should_record)): run new behavior live and record the flag; on replay honor it only if the original run recorded it. The Flags field and column land in this plan; the gate has no job until replay exists. Revisit: the first engine change that would alter a recorded run's effects or events.
  • Deferred, at-most-once claim/settle for tool effects across a restart (Claimed | AlreadySettled | AlreadyRunning | DeterminismViolation). The harness performs each effect exactly once within a run today. Revisit: with resume-by-re-execution, where a re-issued tool effect must not run twice.
  • Deferred, child budget auto-cancel: the harness cancels a spawned task whose token usage or context crosses a threshold, a policy computed from the log. Revisit: when a prompt's subtasks are observed to run away.
  • Deferred, the field-study idioms that map to no listed deficit or do not apply: Temporal's per-effect fsm! state-machine macro (we have one scheduler, not dozens of machines), its non-SDK wake detection (the engine holds no futures), its lookahead pre-resolution and transition coverage; zed's two-projection tool output; str0m's borrowed &mut handles and per-subsystem output queues. Revisit: none scheduled.
  • Deferred, the compactor framework (replacement-returning callbacks, in-place history rewrite). Revisit: after the Lua loop lands.
  • Deferred, publishing the events-to-observer adapter (which this plan ships under the runtime's test-support feature for its own suites) as a supported API for out-of-tree consumers. Revisit: an out-of-tree consumer asks.
  • Out of scope: parallel Lua within one run; gateway product changes; Papergate's own code changes; the workshop-runs crate and the Run-button flat event table from the superseded "Harness API crate" plan (a later harness-api client).

@vinniefalco
vinniefalco force-pushed the master branch 5 times, most recently from 438a5c8 to aaf4f43 Compare September 19, 2026 23:33
The Workshop web UI package moves out of the server crate to sit beside it as a visible peer, and the feature directory inside its source tree is renamed from a second ui folder to parts, so a component path no longer repeats a word and the tree can be audited by eye. The shared UI build helper gains a general entry that resolves the package from a caller-supplied path relative to the crate manifest, and the original entry becomes a thin default for the nested layout. The server build script now points at the sibling package. Every repository reference follows: CI workflows, ignore and attribute rules, the shared-ui link in the package manifest and lockfile, the parent-directory walks in the bundler script and fixture-reading tests, the lazy-load import paths, and the documentation and rule files that describe the layout.

- `build_sibling` is a new public entry in build-ui that joins a caller-supplied relative path onto the crate manifest directory to find the UI package; `build` now forwards to it with "ui", so the nested layout stays the default with no removal condition.
- `crates/workshop/server/build.rs` calls `build_sibling("../ui", ...)`, so the server crate's build reads a directory outside its own tree that no manifest declares; the bundle still lands in `$OUT_DIR/ui-dist/`.
- `crates/workshop/ui/src/parts/` is the former `src/ui/` directory. The eighteen imports in `main.ts` and the five lazy-load thunks in `panel-registry.ts` follow; shared-ui specifiers and CSS imports are untouched.
- `build_sibling` rejects a resolved path that is not a directory with an error naming both the relative path and the resolved location. The removed code joined "ui" with no such check.
- `build.mjs` walks three parent directories to the workspace Cargo.toml instead of four; seven tests drop one ".." from their fixture and repo-root walks (`agent-stt.mjs`, `agent-wire-fixtures.mjs`, `docs-claims.mjs`, `pcm-worklet.mjs`, `realtime-wire-fixtures.mjs`, `run-panel.mjs`, `stt-stream.mjs`).
- `package.json` links shared-ui as `file:../../shared-ui`, and the lockfile's resolved link follows.
- `src/` module bodies are unchanged: apart from the import rewrites in `main.ts` and `panel-registry.ts`, every moved source file differs only in header comments or not at all.
- `build_sibling` has no test covering the new not-a-directory error; the build-ui test only drops the "server" segment from its path.

Design: new surface-growth @ crates/build-ui/src/lib.rs::build_sibling deps: &str,UiBuild boundary: pub
Design: new shim @ crates/build-ui/src/lib.rs::build deps: UiBuild
Design: new hidden-dependency @ crates/workshop/server/build.rs::main
Plan: vibe/2026-09-19-1-chatbox-extraction.md
The five files that make up the chat box leave the agent feature directory for a directory of their own, and the prompt input source and stylesheet take the chat box name on the way. The files themselves are unchanged apart from the stylesheet import and the path comments, so the move is pure relocation with history intact. The session view and the three tests that bundle these files follow the new location. Keeping the chat box as one unit in its own directory is what lets it grow a contract without the agent code reaching into it.

- `crates/workshop/ui/src/parts/chatbox/chat-box.ts` is the former `crates/workshop/ui/src/parts/agent/prompt-input.ts`, renamed on the move; `chat-box.css`, `mention-chip.ts`, `typeahead-popup.ts`, and `typeahead-popup.css` move beside it unrenamed. Beyond two path comments in `chat-box.css`, the only change inside them is `import "./chat-box.css";` replacing `import "./prompt-input.css";`.
- `agent-session-view.ts` imports `PromptInput` from `../chatbox/chat-box`; the class name and every use of it are unchanged.
- `crates/workshop/ui/test/chat-box.mjs` is the former `test/prompt-input.mjs`; it, `test/mention-chip.mjs`, and `test/typeahead-popup.mjs` point their esbuild stdin exports at `./src/parts/chatbox/`. Every assertion in the three files is untouched.
- `parts/chatbox/` gains no new module, type, attribute, dependency, or test in this change; the directory holds only the moved files.

Design: new shotgun-surgery @ crates/workshop/ui/src/parts/chatbox/chat-box.ts
Deferred: `src/parts/chatbox/types.ts` with the chat box contract types is not yet written
Deferred: `@tiptap/suggestion` is not yet a direct dependency in `package.json`
Deferred: `mention-chip.ts` does not yet carry the kind, icon, preview, tone, and data attributes
Deferred: `chip-view.ts` with `renderChip` is not yet extracted from the NodeView
Deferred: `chat-box-view.ts` with `renderDraft` and the `ws-draft-view` rules are not yet written
Deferred: the chip attribute and `renderDraft` assertions in `test/mention-chip.mjs` and `test/chat-box.mjs` are not yet added
Plan: vibe/2026-09-19-1-chatbox-extraction.md
Declares the chat box component's contract in one place: the props it takes, the events it emits, its imperative handle, the chip model, and the versioned draft shape it persists. Extends the mention node with the chip model's fields, each mirrored to a data attribute so a pill survives copy, paste, and JSON round trips, and moves pill drawing into one shared function that the editor's node view and a new read-only draft renderer both call. The renderer draws a serialized draft as plain DOM with no editor behind it, so a sent turn can look the same as the box that produced it. The two host-facing types the contract needs are declared structurally rather than imported, keeping the component directory free of imports from the services and dictation layers.

- `types.ts` declares `ChatBoxTextControl` and `ChatBoxHandle` structurally, mirroring `TextControl` and `SttInputTarget` member for member so no import crosses the boundary in either direction. The file's only relative import is `../../base/lifecycle`.
- `renderChip` is the one pill-drawing function. `MentionChip`'s NodeView now calls `renderChip(chipFromAttrs(node.attrs as ChipNodeAttrs))` and only wires the remove button; `renderStaticChip` removes that button for read-only display.
- `addAttributes` on `MentionChip` adds `kind`, `icon`, `preview`, `tone`, and `data` on top of upstream's, each with `renderHTML`/`parseHTML` to `data-kind`, `data-icon`, `data-preview`, `data-tone`, and a JSON-encoded `data-payload`.
- `@tiptap/suggestion` becomes a direct dependency at `^3.31.0`; the lock drops its `peer` flag.
- `parsePayload` returns null for an unparseable `data-payload` instead of throwing, and `parseTone` returns null for any value outside default, expired, and uploading. Both are exercised through the HTML parse path in `test/mention-chip.mjs`.
- `iconFor` resolves a named icon first, then the label's extension, then the generic file glyph; an unknown name falls through to the extension map.
- `renderDraft` emits one `ws-draft-view` root with `ws-draft-view__strip` always present (collapsed by an `:empty` rule), then one `ws-draft-view__paragraph` per paragraph node. `renderInline` renders `text`, `hardBreak`, and `mentionNode`, and renders nothing for any other node type.
- `chipFromAttrs` falls the label back to the id and omits fields whose attrs are null, so a chip inserted without them serializes with null attrs.
- `ChatBoxProps`, `ChatBoxDynamicProps`, `ChatBoxEvent`, `ChatBoxEventSink`, `ChatBoxHandle`, `ChipSource`, `ChatBoxTextControl`, and `TextControlRegistrar` have no reference in the touched files; only `ChipRef`, `JsonValue`, and `SerializedDraft` are consumed.
- `preview` is parsed from `data-preview`, carried on the node, and copied by `chipFromAttrs`, but `renderChip` never reads it; `tone` is stamped as `data-tone` and this diff adds no rule keyed on it.
- `test/mention-chip.mjs` header now cites `src/parts/agent/mention-chip.ts` while its stdin block exports `./src/parts/chatbox/mention-chip.ts`.

Design: new parallel-abstraction @ crates/workshop/ui/src/parts/chatbox/types.ts::ChatBoxTextControl instead-of: layer-violation: importing TextControl from services/text-control-service.ts into chatbox/
Design: new parallel-abstraction @ crates/workshop/ui/src/parts/chatbox/types.ts::ChatBoxHandle instead-of: layer-violation: importing SttInputTarget from parts/stt/stt.ts into chatbox/
Design: extends surface-growth @ crates/workshop/ui/src/parts/chatbox/mention-chip.ts::MentionChip boundary: persisted
Design: new swallowed-exception @ crates/workshop/ui/src/parts/chatbox/mention-chip.ts::parsePayload deps: string|null
Design: new dispatch-on-tag @ crates/workshop/ui/src/parts/chatbox/chat-box-view.ts::renderInline deps: HTMLElement,JSONContent
Deferred: ChipRef.preview and the expired and uploading tones are carried on the node and stamped on the pill but not rendered.
Plan: vibe/2026-09-19-1-chatbox-extraction.md
Every dictation surface in a window shares one microphone capture service, so a second surface pressing its mic was told capture was already active while its own control kept showing idle, and every surface's audio subscription saw the owner's chunks. Capture now belongs to the caller that opened it: only that owner can stop or clear the take, any other caller's start is refused as busy, and ownership changes are announced so each surface can reflect them. Dictation no longer touches a button element; it exposes a press action and a mic state that the host paints, with a local recording taking precedence over another surface's ownership. The agent session view bridges its own mic button to that press action and paints the published state.

- `SpeechCaptureService`: `start`, `stop`, and `clear` take an owner `symbol`; `owner` and `onOwnerChange` expose the current holder. A non-owner's `stop` or `clear` is a no-op success, and a non-owner's `start` returns the new `busy` failure kind.
- `SttHandle` gains `press()`, `state`, and `onState`; `SttElements` loses `mic`, so dictation holds no DOM control. `SttMicState` is `"idle" | "recording" | "blocked"`.
- `setupStt` mints one `Symbol("stt-owner")` per instance, passes it to every capture call, and drops `onAudio` chunks while `capture.owner !== owner`.
- `publishState` derives the mic state with fixed precedence: `recording` wins over `blocked`, and `onState` fires only when the derived value changes. The initial state is `blocked` when another owner already holds the microphone at setup.
- `start` checks ownership before the host `blocked()` callback, so a held microphone reports `BUSY_LABEL` ("Dictation is active in another window") even when the host would also refuse.
- `busy` is returned only while the phase is `recording` under another owner; a foreign start during the owner's flush or while opening keeps `start-failed`, so a transient closing window is not reported as another window's take.
- `AgentSessionView` routes `this.mic` clicks to `this.stt.press()` and paints `ws-stt-mic--recording`, `aria-pressed`, and `title` from `onState`.
- `speech-capture.mjs` covers busy refusal, non-owner no-ops, owner release on stop and on dispose, and the flush window; `stt-stream.mjs` adds a two-instance case over one shared service asserting the refusal reason, no audio reaching the non-owner, and the return to idle when the owner's take ends.
- `this.mic` gains a click listener in the view with no matching removal; it lives on the view's own element and is not tracked by the disposable store.

Design: extends oversized-unit @ crates/workshop/ui/src/parts/stt/realtime-stt.ts::setupStt deps: RealtimeTranscriptionService,SpeechCaptureService,SttBlocker,SttElements,SttStatus
Design: extends pure-function @ crates/workshop/ui/src/parts/stt/realtime-stt.ts::captureFailureLabel deps: SpeechCaptureFailure
Design: new surface-growth @ crates/workshop/ui/src/services/speech-capture.ts::SpeechCaptureService boundary: pub
Design: new surface-growth @ crates/workshop/ui/src/services/speech-capture.ts::SpeechCaptureFailure boundary: pub
Design: new surface-growth @ crates/workshop/ui/src/parts/stt/stt.ts::SttHandle boundary: pub
Plan: vibe/2026-09-19-1-chatbox-extraction.md
The prompt input becomes a self-contained chat box that owns its bar, its mic and send buttons, and an empty attachments strip. The host sets state through defaulted props and receives intent through a single event sink, and the box no longer reaches into the service registry: the text-control registrar is injected. The session view shrinks to a composition layer that maps the pending wait, the model selection, and dictation state onto props and routes the box's events back to the service and to dictation. The bar's rules and dictation's mic styling move with the buttons into the box's stylesheet, and a second window's refused mic press now renders as a blocked mic, pinned by a two-view test over one shared capture service.

- `ChatBox` replaces `PromptInput` at the same locus. It takes `ChatBoxProps` and a `ChatBoxEventSink`, its root is the bar (`ws-agent-session__bar`), and it creates the mic and send buttons that `AgentSessionView` used to build; `setEditable` and `onSubmit` are gone in favor of `update({ editable })` and the `send` event.
- `textControls` is an injected registrar. The `getServiceOrNull(TEXT_CONTROL_SERVICE)` lookup leaves the box and lands in the `AgentSessionView` constructor, which passes `textControls.register.bind(textControls)`; a box built without the prop registers nothing.
- `controls`: with a host element the box appends it after the frame and its own two buttons to the element's end; a registered disposable removes only the box's buttons on dispose. Without one the buttons sit on the bar after the frame.
- `chat-box.css` absorbs the bar, send, and mic rules from `agent-session.css` and the `.ws-stt-mic` / `.ws-stt-mic--recording` rules from `stt.css`, which is now comment-only; `test/lazy-css-entry-bundle.mjs` swaps its `stt` marker for `chatbox: "ws-stt-mic--recording"`.
- `update()` compares each dynamic prop against the stored value and re-renders only what changed; `renderEditable` applies `editable && !takeReadOnly` to the editor and mirrors the effective value as `data-editable` on the frame, with `data-variant`, `data-action`, and `data-mic` mirrored on the root and buttons.
- `emitAction()` is shared by the send button and the submitting Enter: `idle` is silent, `stop` emits `stop`, and both `send` and `send-blocked` emit `send` carrying `getText()`, the pills present in document order, and a copy of `attachments`.
- `onChatBoxEvent` routes `send` to `submit()`, which re-reads `this.chatBox.getText()` and ignores the event's `text`, `mentions`, and `attachments`; `mic-press` calls `this.stt.press()`. `renderInputState` maps the pinned wait to `editable` and the wait plus model selection to `send`, `send-blocked`, or `idle`.
- `restore` returns without touching the box when `draft.v` is missing, a string, or any value other than the number `1`; `serialize()` emits `v: 1`, the editor JSON, and a copy of the strip's chips. `insertMention` inserts the node followed by exactly one space.
- `test/agent-stt.mjs` adds two views over one `SpeechCaptureService`: the second mic reads `data-mic="blocked"`, its press posts "Dictation is active in another window" without stealing or discarding the owner's take, it streams none of the owner's audio, and both mics return to `idle` when the owner stops.
- `test/chat-box.mjs` pins the contract: defaults, the three send states, the mic states, the controls slot, a `MutationObserver` proving an unchanged `update()` mutates nothing, mentions on `send`, and a byte-for-byte `serialize`/`restore` round-trip.
- `onChatBoxEvent` returns without action for `command`, `stop`, `cancel`, and `mic-release`; the box emits `stop` when `action` is `"stop"` and nothing consumes it.
- `attachments` on `ChatBox` is written only by `restore`; no path in the box adds a chip to the strip.

Design: service-locator -> constructor-injection @ crates/workshop/ui/src/parts/chatbox/chat-box.ts::ChatBox
Design: new service-locator @ crates/workshop/ui/src/parts/agent/agent-session-view.ts::AgentSessionView
Design: new dispatch-on-tag @ crates/workshop/ui/src/parts/agent/agent-session-view.ts::AgentSessionView.onChatBoxEvent
Design: new pure-function @ crates/workshop/ui/src/parts/chatbox/chat-box.ts::micTitle deps: ResolvedDynamicProps["mic"]
Design: extends oversized-unit @ crates/workshop/ui/src/parts/chatbox/chat-box.ts::ChatBox.constructor
Design: new surface-growth @ crates/workshop/ui/src/parts/chatbox/chat-box.ts::ChatBox.serialize boundary: persisted
Deferred: the view's onChatBoxEvent ignores the command, stop, cancel, and mic-release events the box can emit.
Plan: vibe/2026-09-19-1-chatbox-extraction.md
The chat box now owns the configuration of its mention typeahead: the item source arrives as a constructor prop, defaulting to the three-entry stub, and the box hands the suggestion plugin that source, the popup renderer, a sixty millisecond debounce, and a zero minimum query length, leaving debounce, abort, and stale-result handling to the plugin. The mention extension keeps only what the schema needs and no longer imports the popup, while the popup's rows gain an icon, a dimmed description, group headers that keyboard navigation skips, a loading row, and Tab as a second accept key. The chip-to-node attribute mapping lives in one place so typeahead-only fields never reach the persisted node. A source-text boundary test walks the component directory and fails on any import that reaches back into the host layers.

- `stubMentionSource` moves from typeahead-popup.ts to chat-box.ts as an exported `ChipSource` over `STUB_CHIPS`, whose entries now carry `kind: "file"` and `data: null`; the constructor resolves `props.mentionSource ?? stubMentionSource` once.
- `MentionChip.configure` runs per ChatBox instance with `items: ({ query, signal }) => mentionSource(query, signal)`, `render: renderMentionTypeahead`, `debounce: MENTION_DEBOUNCE_MS` (60), and `minQueryLength: 0`; the extension's base configure in mention-chip.ts keeps only `char`, `pluginKey`, `allowSpaces: false`, and `deleteTriggerWithBackspace: false`.
- `mention-chip.ts` drops its import of `./typeahead-popup`; `typeahead-popup.ts` now imports `attrsFromChip` and `ChipNodeAttrs` from `./mention-chip`, so the dependency runs popup to extension instead of extension to popup.
- `attrsFromChip` is the one mapping from `ChipRef` to `ChipNodeAttrs`; `insertMention` spreads it with `mentionSuggestionChar: "@"` in place of its inline attrs literal, and the popup's `command` wrapper calls it at the plugin edge. `description` and `group` are not in the mapping.
- `TypeaheadProps` and `TypeaheadRenderer` derive from `SuggestionProps<ChipRef, ChipNodeAttrs>` and `SuggestionOptions` imported from `@tiptap/suggestion` directly; the `MentionItem` interface and the `MentionOptions`-derived types are removed, and no package manifest changes in this diff.
- `layoutRows` orders ungrouped items first in source order, then each group in first-appearance order behind a header row; `index` counts items only and is the selection index.
- `renderChipIcon` is extracted from `renderChip` so a popup row draws the same glyph as the pill.
- `suggestionActive` replaces the inline plugin-state read in `handleKeyDown`, and the yield now covers Tab as well as Enter and runs before the Shift and Enter checks.
- `handleKeyDown` on the popup accepts Tab like Enter; `applySelection` iterates only `.ws-typeahead-popup__item` nodes, so a header never receives `aria-selected` or the highlight.
- `renderRows` hides the element only when there are no rows and `loading` is false; a pending empty result renders one `role="presentation"` row reading "Searching..." so the popup does not blink shut between keystrokes.
- `renderItem` sets `data-kind` on the option when the chip has a kind and appends a `.ws-typeahead-popup__description` span only when `description` is defined.
- `test/chatbox-boundary.mjs` walks `src/parts/chatbox/` recursively with node:test and no jsdom, matching `"../agent`, `"../stt`, `"../chrome`, `"../../services`, and the joined-fragment string with `line.includes`, so a comment or an `import type` line trips it too.
- `test/typeahead-popup.mjs` drives a `ChatBox` through `createBox` instead of a bare Editor with `MentionChip`, adding Tab, space-close, Backspace-restores-@, description, group header, and loading cases; `test/chat-box.mjs` adds the stub shape, injected source, AbortSignal, stale-result, and `/` cases and sets `globalThis.DOMRect` for the managed mount.
- `settle()` in both test files waits 160 ms of real time per typed step, replacing the 0 ms `flush()`.
- `commandSource` is stored on `ChatBox` and never read; a typed `/` produces no popup.
- `deferredSource`, `settle`, `typeText`, `popup`, and `popupLabels` are near-identical copies between `test/chat-box.mjs` and `test/typeahead-popup.mjs`.

Design: replaces pure-function @ crates/workshop/ui/src/parts/chatbox/chat-box.ts::stubMentionSource deps: query boundary: pub was: crates/workshop/ui/src/parts/chatbox/typeahead-popup.ts::mentionTypeaheadItems
Design: new pure-function @ crates/workshop/ui/src/parts/chatbox/chat-box.ts::suggestionActive deps: EditorState
Design: extends constructor-injection @ crates/workshop/ui/src/parts/chatbox/chat-box.ts::ChatBox
Design: new pure-function @ crates/workshop/ui/src/parts/chatbox/mention-chip.ts::attrsFromChip deps: ChipRef boundary: persisted
Design: new pure-function @ crates/workshop/ui/src/parts/chatbox/typeahead-popup.ts::layoutRows deps: ChipRef[]
Design: removes parallel-abstraction @ crates/workshop/ui/src/parts/chatbox/typeahead-popup.ts::MentionItem
Design: new clone-block @ crates/workshop/ui/test/chat-box.mjs::deferredSource
Deferred: commandSource is stored on ChatBox and wired to no suggestion plugin, so a typed / stays text.
Deferred: the default mention source is a three-entry stub standing in for a workspace file index.
Plan: vibe/2026-09-19-1-chatbox-extraction.md
Plan: vibe/2026-09-19-1-chatbox-extraction.md
Restoring a draft painted every attachment pill in the live strip with a remove button that nothing listened to, while the read-only renderer built the same button and then deleted it from the DOM. The pill renderer now takes an option that omits the button, and both the strip and the static renderer ask for that, so the button appears only where the editor wires it. The two chat box props that no code reads are documented as reserved so the contract matches the implementation. A focused test asserts that a restored strip pill has no remove button.

- `RenderChipOptions` carries one optional `removable` flag that defaults to true, so the NodeView caller and the existing test call sites are unchanged. The remove button is created only when the flag is not false.
- `renderStaticChip` passes `{ removable: false }` instead of building the button and removing it with a selector afterwards.
- `ChatBox` restore paints the live strip with `renderChip(chip, { removable: false })`, so a restored pill has no `.ws-mention-chip__remove` element.
- `commandSource` and `onPasteFiles` docstrings now say they are declared but not read in this release; the type shape is unchanged.
- `ws-mention-chip__remove` stays an unwired button on the NodeView pill; nothing here wires attachment removal.

Design: new flag-parameter @ crates/workshop/ui/src/parts/chatbox/chip-view.ts::renderChip deps: ChipRef,RenderChipOptions
Repairs: pill remove button is wired or absent @ crates/workshop/ui/src/parts/chatbox/chat-box.ts::ChatBox - restored attachment pills carried an inert Remove button
Plan: vibe/2026-09-20-1-chatbox-debt-removal.md
Delete a lockfile cache path pattern that matched no file from every continuous-integration, nightly, release, and distribution workflow. The workshop UI package sits one directory level above where the pattern looked, and the sibling wildcard pattern already covers its lockfile, so the dependency cache is unaffected. Correct the mention chip test's header comment, which cited a module location that no longer exists after the chatbox relocation. Configuration and comment text only; no runtime code or test assertion changes.

- `crates/workshop/*/ui/package-lock.json` is removed from all eleven `cache-dependency-path` blocks across `.github/workflows/ci.yml`, `nightly.yml`, `release-workshop.yml`, and `dist-ci/build-setup.yml`. Each block keeps `crates/*/ui/package-lock.json` and `crates/gateway/*/ui/package-lock.json`, and the former matches the lock the adjacent `npm ci --prefix crates/workshop/ui` step installs from.
- `.github/workflows/dist-ci/build-setup.yml` held its copy of the line six columns deeper than its two siblings, so inside the literal block scalar the pattern carried leading whitespace as well as the wrong directory depth.
- `crates/workshop/ui/test/mention-chip.mjs` line 1 now cites `src/parts/chatbox/mention-chip.ts`; the test body and its assertions are untouched.

Plan: vibe/2026-09-20-1-chatbox-debt-removal.md
Plan: vibe/2026-09-20-1-chatbox-debt-removal.md
Introduce a fourth product family, harness, to the build-time dependency matrix so that crates carrying its name prefix are held to their own rules before any of them exist. Harness crates may reach the promptforge door, the gateway public pair, and shared crates, never workshop crates or private gateway crates; workshop crates may reach the harness only through its single public crate; promptforge, gateway, and shared crates may not reach it at all. The harness source container is private to the family with that public crate as its one named exception, mirroring the promptforge container. The root architecture document gains the same rules so it does not lag the checker, and fixture tests cover each accepting and rejecting edge plus the file ceiling over a marked harness crate.

- `HARNESS_DOOR` is one constant that serves both the workshop-to-harness family rule and the `crates/harness/` container exception, so the door cannot drift between the two checks.
- `boundary_breach` evaluates container privacy before the family table, so a workshop crate naming a crate under `crates/harness/` is reported with the privacy message that names `harness-api`, while a harness crate sitting at the crates root is reported with the family rule instead; both cases have fixtures. The function now runs 79 lines.
- `family` maps the `harness-` prefix to `Family::Harness`; the bare name `harness` stays `Family::Unaffiliated`, as the classification test asserts.
- `public_gateway` is computed once and gates both the workshop-to-gateway and the new harness-to-gateway arms, whose message names both public pair crates.
- `file_ceiling_violations` is untouched and `participating_crates` changes only in its doc comment; the two new tidy fixtures show the `## Invariants` marker already binds a crate one level under `crates/harness/` and that an unmarked one stays outside the ceiling, so no code change was needed to widen the scope.

Design: new oversized-unit @ crates/build-xtask/src/product.rs::boundary_breach deps: CrateInfo
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Add a fixture-tested check that an engine crate manifest declares no async runtime and no HTTP client outside its dev-dependencies. The engine is a deterministic state machine whose every wait is an effect the harness performs, so those crates must not name tokio, tokio-util, async-trait, or reqwest in their regular, build, or target-specific dependency tables. Dev-dependencies stay outside the check because the engine's own suites drive it from a tokio harness against a mock gateway. An optional entry that only the test-support feature enables is exempt for now, so the serial driver and the observer adapter can ship behind that feature while the suites migrate. The walk over a manifest's dependency tables, until now duplicated by the product matrix and the workshop tier check, becomes one shared routine that the new guard also uses.

- `crates/build-xtask/src/manifest.rs` holds `dependency_tables`, the one walk over a parsed manifest's dependency tables of the requested kinds, top-level and under target, each labeled the way its section header reads. `product.rs` and `tidy.rs` drop their private copies of that loop and call it.
- `Violation` has two variants: a forbidden entry carrying the manifest, the table, and the resolved package name, and an unreadable manifest carrying the error. It derives equality so tests compare findings directly, and its Display names the table, the crate, and the full forbidden list.
- `forbidden_engine_dependencies` takes one manifest path and returns findings. A read or parse failure is returned as an `Unreadable` finding rather than a panic or a silent empty result.
- `enabling_features` computes every feature that enables an optional dependency by cargo's rules: `dep:` syntax suppresses the implicit feature, a strong `key/feature` path enables while the weak `key?/feature` form does not, and enabling propagates through any feature that lists an enabler, so a default feature that lists `test-support` voids the exemption.
- `package` renames are resolved before matching, so a renamed `tokio-util` is still caught and reported under its package name.
- `engine_deps-tests.rs` writes one manifest per case into a temporary directory. Eleven fixtures cover the clean case, forbidden entries in regular, build, and target tables, dev-only entries passing, each exemption edge, and unreadable or unparseable input.
- `main.rs` declares `engine_deps` under a non-test `dead_code` allowance; nothing outside the tests calls the guard yet.

Design: new pure-function @ crates/build-xtask/src/manifest.rs::dependency_tables deps: [&str],toml::Value
Design: removes clone-block @ crates/build-xtask/src/product.rs::manifest_dependencies
Design: removes clone-block @ crates/build-xtask/src/tidy.rs::workshop_dependencies
Design: new value-object @ crates/build-xtask/src/engine_deps.rs::Violation
Design: new pure-function @ crates/build-xtask/src/engine_deps.rs::is_optional deps: toml::Value
Design: new pure-function @ crates/build-xtask/src/engine_deps.rs::feature_lists deps: toml::Value
Design: new pure-function @ crates/build-xtask/src/engine_deps.rs::is_exempt deps: [(&str,Vec<&str>)],str
Design: new pure-function @ crates/build-xtask/src/engine_deps.rs::enabling_features deps: [(&str,Vec<&str>)],str
Deferred: forbidden_engine_dependencies is not yet run over the engine crates, so engine_deps keeps a non-test dead_code allowance until tidy calls it
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Two structural guards join the build task. The first walks a source tree, masks comments and string literals, drops every test-only item, and reports whole-identifier matches against a list of retired names, so a symbol the engine has shed cannot quietly return. The second requires every harness crate, and the harness door crate, to ship a clippy configuration that forbids raw tokio spawns, so the harness spawns only through its one instrumented wrapper. The ban check runs with the repository lint pass now and is vacuously clean until harness crates exist; the scan is fixture-tested only and is not yet run over the engine.

- `harness_clippy_bans` is wired into `all_violations` with the `crates/harness` container and the `crates/harness-api` door as separate arguments, so the door is held to the same bans as the container without living inside it.
- `retired_symbols` masks rather than parses: stripped text becomes spaces with newlines kept, so byte offsets and line numbers survive and the `#[cfg(test)]` pass can count braces without being fooled by literals or comments.
- `Hit` is an ordered value carrying file, line, and symbol; results sort by file then line and `Display` renders `file:line: retired symbol X reappears in live source`.
- `collect_crates` treats a directory holding a `Cargo.toml` as a crate and does not descend into it; any other directory is a container and the walk continues, so a crate nested under a manifestless subdirectory is still checked.
- `disallowed_methods` accepts both entry forms clippy allows, a bare string or a table with a `path` key, and `check_crate` reports only the banned methods that are absent.
- `check_crate` turns a missing, unreadable, or unparseable `clippy.toml` into one violation string each rather than skipping the crate.
- `remove_cfg_test_items` blanks inline modules, `mod name;` declarations under any visibility qualifier with or without `#[path]`, and any other single item; the module files it names are excluded from the scan by normalized path.
- `collect_sources` skips `tests/` and `target/` directories and any path component containing `test_support` or `test-support`.
- `retired_symbols` skips a `.rs` file it cannot read and yields no hits for an absent root; the module doc argues a compiled module that rustc cannot read fails the build beside the guard, and an unreferenced file is not live code.
- `mod retired_symbols` is declared under `allow(dead_code)` outside tests; nothing in `tidy` calls it and the crate holds no production seed list, only the two-name fixture seed in its tests.

Design: new value-object @ crates/build-xtask/src/retired_symbols.rs::Hit
Design: new swallowed-exception @ crates/build-xtask/src/retired_symbols.rs::retired_symbols deps: Path,[&str]
Design: new swallowed-exception @ crates/build-xtask/src/harness_bans.rs::collect_crates deps: Path,Vec<PathBuf>
Design: new pure-function @ crates/build-xtask/src/harness_bans.rs::disallowed_methods deps: toml::Value
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::is_test_support deps: str
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::normalize deps: Path
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::identifiers deps: str
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::line_end deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::block_comment_end deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::string_end deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::char_literal_end deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::raw_string_start deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::raw_string_end deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::skip_whitespace deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::balanced_end deps: [char],char,usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::path_attribute deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::skip_visibility deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::external_module deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::item_end deps: [char],usize
Design: new pure-function @ crates/build-xtask/src/retired_symbols.rs::module_files deps: Option<&str>,Path,str
Deferred: retired_symbols stays fixture-tested only under allow(dead_code), with no production seed list and no call from tidy
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Introduce the public crate through which clients configure the harness, push the gateway binding, and drive agent sessions. The harness never resolves a gateway itself: a client pushes the base URL, bearer key, and a monotonic generation at startup and on every replacement, and the latest push wins. The bearer key is redacted from debug output. The session vocabulary a client renders is defined now as serializable data, while the live session handle exposes only its id until the runner and sessions crates supply its behavior. The crate's lint configuration bans direct task spawning so the harness spawns only through one instrumented wrapper.

- `crates/harness-api/src/lib.rs` re-exports every public type from two private modules. The crate depends only on `serde`, `serde_json`, and `workspace-hack`, so it sits below every other harness crate.
- `Harness` keeps its binding in an `RwLock<Option<GatewayBinding>>` mutated through `&self`, so one shared harness can be rebound from any task. A poisoned lock is recovered with `PoisonError::into_inner` because a binding is written whole by a single store.
- `SessionId` wraps a `String` as a `#[serde(transparent)]` newtype deriving `Hash`, so ids serialize as bare strings and can key maps.
- `clippy.toml` restates `allow-unwrap-in-tests` and `allow-expect-in-tests` because a per-crate file replaces the root one. Its `tokio::spawn` and `tokio::task::spawn_blocking` bans carry `allow-invalid = true` since the crate has no tokio dependency for the paths to resolve against.
- `GatewayBinding` implements `Debug` by hand and prints `<redacted>` for `key`. `a_gateway_binding_never_prints_its_key` asserts the key text is absent and the generation is present.
- `Harness::set_gateway` replaces the binding unconditionally. `set_gateway_called_twice_leaves_the_latest_generation` checks the second call wins and `a_fresh_harness_has_no_gateway` checks `None` before the first.
- `SessionEvent` carries `reply: Option<u64>` skipped when `None`, and `LaunchRequest` defaults `args` to empty on deserialize.
- `Session` has a private `id` field and no constructor, so nothing in this crate can create one. Only `id()` is exposed.
- `Harness` does not act on a change of `generation`. It stores the binding; nothing here rebuilds a registry or client.
- `Cargo.toml` registers `harness-api` as a workspace dependency. No dependent is added in this change.

Design: new facade @ crates/harness-api/src/lib.rs boundary: pub
Design: new newtype @ crates/harness-api/src/session.rs::SessionId boundary: wire
Deferred: Session's launch, input, cancel, close, and subscribe methods await the harness runner and sessions crates
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Create the five private crates of the harness family as manifest-only skeletons, each carrying its crate documentation, its dependency invariants, and a clippy ban on raw tokio spawning. The runner crate is the exception: it holds the two instrumented spawn wrappers that every other harness crate is required to go through, so a run's tasks trace as one group. The workspace registers the new container the same way as the existing ones, and the ban check in the build tooling gains a test that pins the exact set of crates it covers.

- `crates/harness/runner/src/spawn.rs`: `spawn_tagged` and `spawn_blocking_tagged` are the only callers of `tokio::spawn` and `tokio::task::spawn_blocking`, each under `#[allow(clippy::disallowed_methods)]`. The tag is any `Display` value and lands in the `tag` field of a span named `spawn` or `spawn_blocking`.
- `Cargo.toml`: `crates/harness` joins the exclude list and its five crates are enumerated as members, because Cargo prunes excluded subtrees from member globs. Five `harness-*` path dependencies join the workspace table.
- `harness_crates`: the crate enumeration is split out of `harness_clippy_bans` so the covered set can be asserted directly; `harness_clippy_bans` now maps that list through `check_crate`.
- `crates/harness/log/clippy.toml`: capabilities, log, models, and sessions share one byte-identical `clippy.toml` whose two bans set `allow-invalid = true` because those crates do not depend on tokio yet; the runner's copy omits it. Each file restates `allow-unwrap-in-tests` and `allow-expect-in-tests` since a per-crate file replaces the root's rather than merging.
- `spawn_blocking_tagged`: enters the span inside the closure so it covers the whole of the blocking work. Both wrappers panic outside a tokio runtime exactly as the raw calls do.
- `the_ban_check_covers_the_five_container_crates_and_the_door`: asserts `harness_crates` returns exactly six directories, the five container crates plus `crates/harness-api`.
- `crates/harness/runner/tests/it/spawn.rs`: three tokio tests check both wrappers run their work to completion and that an owned `String` tag is accepted.
- `crates/harness/capabilities/src/lib.rs`: capabilities, log, models, and sessions contain only crate docs and invariants; no items yet.
- `crates/harness/runner/tests/it/spawn.rs`: no test observes the span or its `tag` field, so the tracing side of the wrappers is unverified.

Design: new facade @ crates/harness/runner/src/spawn.rs::spawn_tagged deps: F,T
Design: new facade @ crates/harness/runner/src/spawn.rs::spawn_blocking_tagged deps: F,T
Design: new clone-block @ crates/harness/log/clippy.toml
Design: new clone-block @ crates/harness/models/clippy.toml
Design: new clone-block @ crates/harness/sessions/clippy.toml
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Give the harness a durable record of every run. A Turso database holds one row per run and, under it, every effect, answer, and event in the order the effect loop produced them, so a transcript can be rebuilt or sliced by task without inspecting payloads. The write side opens a log, begins a run, appends records at a log-assigned position, and closes the run exactly once; the read side returns a run's row and its records filtered by kind, by task, or to a trailing count. Unsigned engine identifiers are stored as their signed two's-complement equivalent so the round trip through the database is lossless.

- `RunLog` holds the single connection and is the only type that issues SQL. Every writing method takes `&mut self`, so `append` can read the next `seq` and insert under it without a transaction; a host that shares the log across tasks adds its own serialization.
- `crates/harness/log/src/schema.rs` keeps the DDL and every statement as `pub(crate)` constants. The DDL is `IF NOT EXISTS` so an existing file opens unchanged, and `records_by_task` indexes `(run_id, task_id, task_seq)` so a run slices by task and orders within one.
- `RunId` and `Seq` wrap the row id and the loop position as distinct types; a `RunId` is meaningful only against the log that issued it.
- `RunMeta` is six public fields with no constructor; the log stores it as given and validates nothing.
- `signed` and `unsigned` reinterpret `u64` as `i64` bitwise for `seed`, `task_id`, `effect_id`, and `seq`, since SQLite integers are signed.
- `append` refuses an unknown or ended run before assigning `seq`; the position is one past the run's maximum and `0` for the first, counted per run.
- `end_run` updates only where `ended_at IS NULL` and turns a zero change count into `LogError::RunEnded`, so a run closes exactly once even against a shared file.
- `now_ms` stamps `at` and `ended_at` from the wall clock inside the log; it is not injectable and yields `0` on a clock set before the epoch. `started_at` is the caller's value.
- `records` reads newest first under `LIMIT` and reverses, so `last` keeps the final entries; a task slice orders by `task_seq`, which can differ from `seq` when tasks interleave.
- `parse_outcome` and `to_u32` reject rows whose columns disagree with the enum shape or type width as `LogError::Corrupt` rather than coercing.
- `crates/harness/log/tests/it/append.rs` covers the three-record round trip, per-run `seq`, kind and task and last filters, each outcome, exactly-once close, unknown runs, and on-disk reopen; nothing exercises concurrent writers, which the type does not admit.

Design: new store-boundary @ crates/harness/log/src/append.rs::RunLog boundary: persisted
Design: new surface-growth @ crates/harness/log/src/lib.rs boundary: pub
Design: new newtype @ crates/harness/log/src/record.rs::RunId
Design: new newtype @ crates/harness/log/src/record.rs::Seq
Design: new value-object @ crates/harness/log/src/record.rs::RecordKind
Design: new parameter-object @ crates/harness/log/src/record.rs::RecordFilter
Design: new bag-of-state @ crates/harness/log/src/record.rs::RunMeta
Design: new pure-function @ crates/harness/log/src/append.rs::signed deps: u64
Design: new pure-function @ crates/harness/log/src/append.rs::unsigned deps: i64
Design: new hidden-dependency @ crates/harness/log/src/append.rs::now_ms
Design: new swallowed-exception @ crates/harness/log/src/append.rs::now_ms
Design: new pure-function @ crates/harness/log/src/read.rs::to_u32 deps: &str,i64
Design: new pure-function @ crates/harness/log/src/read.rs::parse_outcome deps: &str,Option<String>
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The run log gains two event-only readers over its filtered record query. One returns the event payloads of a single task in that task's own sequence order, optionally trimmed to the final few, which is what a task-events request hands back to the engine. The other returns every event of a run in loop order for session views and reconnecting clients. Effect and answer rows are excluded from both, and a run that was never begun is refused.

- `events_for_task` and `transcript` both delegate to `records` with a fixed `RecordKind::Event` filter instead of issuing their own queries, so row ordering, the last-n window, and the unknown-run check live in one place.
- `events_for_task` returns bare `serde_json::Value` payloads and drops the stored envelope; `transcript` keeps `StoredRecord` so a view can read `seq`, `task_id`, and `task_seq` beside each event.
- `last` of `Some(0)` yields an empty slice, a count above what exists returns everything, and a task that never logged reads as empty rather than an error.
- `LogError::UnknownRun` comes back from both readers when the run was never begun, carrying the offending id.
- `crates/harness/log/tests/it/read.rs` appends two interleaved tasks out of `task_seq` order with an effect and answer mixed in, then checks per-task order, the last-n window, the event-only transcript in `seq` order, the empty cases, and the unknown-run refusal.

Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Break the two largest engine source files into module directories before later work edits them, moving code without changing what it does. The scheduler file keeps its core state and driver loop and hands the chain lifecycle, stepping, section walk, request dispatch, the Rust-backed model loop, and fanout bookkeeping to sibling modules. The Lua protocol file becomes a thin re-export over request types, answer types, parsing, rendering, and tests. The round-overhead benchmark that an earlier removal deleted along with the tool picker comes back without the picker so the loop's timing gate exists again, and its baseline numbers are recorded.

- `crates/promptforge-api-runtime/src/execute/scheduler.rs` keeps `RequestId`, `ChainId`, `Chain`, `Scheduler`, and the `drive` loop; `chain`, `step`, `walk`, `dispatch`, `models_loop`, and `tasks` take the rest as a directory in standard module layout rather than sibling files.
- `crates/promptforge/lua/src/protocol.rs` declares `request`, `answer`, `parse`, `render`, and `tests`, and re-exports `Answer`, `Request`, `StoreOp`, `YieldParse`, `append_message_record`, and the record types under their prior names, so callers outside the module are untouched.
- `pub(super)` replaces private visibility on the moved `Scheduler` methods and the `JoinState`, `ArmState`, and `ChainTarget` fields the siblings read; nothing widens past the scheduler directory or the protocol directory.
- `#[path = "parse-chat.rs"]` binds the kebab-named chat and loop request parsers as `chat` under `parse`, since the two share the message-record validation.
- `benches/models_loop.rs` returns with `Environment::new()` and no picker call; `criterion` is a dev-dependency only and the `[[bench]]` block sets `harness = false`.
- `struct` and `fn` declarations removed from each original reappear name for name across the residual file and its directory; the only textual changes are visibility, import paths, and signature line wrapping.
- `#[cfg(test)]` gates the protocol test tree, whose `mod.rs` holds the shared helpers and whose `parse`, `parse_chat`, `parse_loop`, `answer`, and `render` files hold the moved cases.
- `compactors_fail` repeats the runtime build, gateway start, prompt parse, and run-context construction of `models_loop`, differing only in the context window size and the asserted outcome.
- `models_loop` and `compactors_fail` carry the only assertions added; the split itself adds no tests.

Design: new facade @ crates/promptforge/lua/src/protocol.rs
Design: new clone-block @ crates/promptforge-api-runtime/benches/models_loop.rs::compactors_fail deps: Criterion
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Every failure that reaches author code is now one table carrying a kind, a message, and the kind's own fields, and converting it to a string yields exactly the message an author saw before. A caller that prints an error sees no change; a caller that branches reads the kind instead of matching message text. The shape holds for a shim's own argument error, a host answer rendered through the envelope, and a host callback that fails directly from Rust, and it survives the trip back into Rust so a Lua-side raise classifies as the typed error it stands in for. Every block coroutine now runs under a guard that records the raised value and the raise-point traceback before the coroutine dies, so a structured raise is not flattened to its message and an authoring error keeps its prompt-line mapping.

- `ErrorKind` is the closed vocabulary of twelve kinds with `tag` and `from_tag`; `ErrorValue` is what an error type implements to render into the table, and `Answer::into_envelope` now requires it where a `Display` bound stood. Both crates' `Error` types and the mlua classifier implement it.
- `Raised` is the table read back into Rust, recognized by the shared metatable and not by shape, so an author's own `error({ kind = ... })` is never mistaken for a shim raise. The runtime's `Error::from_raised` maps a kind back onto its substrate.
- `Classified` mirrors `Raised` field for field (kind, message, fields) with no converter between them; `Raised` already implements `Display` and could implement `ErrorValue` itself.
- `__impl_coro.lua` installs `protected_call` and `protected_xcall` over the global `pcall` and `xpcall`, rewriting only a Rust callback's raised failure; strings, an author's table, and an already built error table pass through. Its `guard` runs the block under the raw `xpcall`, stashes a failure from the message handler, and re-raises the same value.
- `stash_failure` writes the raised value and a traceback into `FAILURE_REGISTRY` and `FAILURE_TRACEBACK_REGISTRY`; `take_failure` clears both on every read so a later failure never sees a stale value. `block_guard` fails when the prelude never ran on the VM.
- `restore_traceback` on `StashedFailure` replaces the tail mlua appended (the guard's own frame) with the raise-point traceback, so `map_runtime_error` maps the author's frames; a Rust callback's wrapped failure is left untouched.
- `into_envelope` renders `(false, table)` in place of `(false, string)`; the shim's `fail` normalizes a bare-string envelope to a `lua`-kind table so the shape holds without exception.
- `block_failure` on `SectionVm` takes the stash on every failure, maps the error, lets `Interrupted` and `LuaQuota` win, keeps a stashed non-`lua` table as `Error::Raised`, and keeps the mapped runtime error for a `lua`-kind table because it already carries the message with its source and prompt line.
- `EmptyModelReply` carries `detail` as `Cow<'static, str>` so a Lua-side `empty_model_reply` raise re-renders with the text the author saw; `finish_reason` rides across as a field.
- `tostring(err)` is the comparison that holds for authors now; equality against the message string does not, and `..` concatenation still renders the message through `__concat`. The touched tests moved to `tostring(err)`.
- `Error::from_raised` keeps `out_of_scope_tool`, `unbound_tool`, the task kinds, and `internal` as `Error::Lua` because the table does not carry the structure those variants need; nothing in this change raises those kinds from Lua.

Design: new value-object @ crates/promptforge/lua/src/error-value.rs::ErrorKind boundary: pub
Design: new strategy @ crates/promptforge/lua/src/error-value.rs::ErrorValue
Design: new stringly-typed @ crates/promptforge/lua/src/error-value.rs::Raised
Design: new parallel-abstraction @ crates/promptforge/lua/src/error-value.rs::Classified
Design: new pure-function @ crates/promptforge/lua/src/error-value.rs::root_cause deps: Error
Design: new surface-growth @ crates/promptforge/lua/src/protocol/render.rs::Answer::into_envelope boundary: pub
Design: new surface-growth @ crates/promptforge/lua/src/coro.rs::install_shim_prelude boundary: pub
Design: new temporal-coupling @ crates/promptforge/lua/src/coro.rs::block_guard deps: Lua
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Extend the yield protocol between Rust and author Lua with the fields a Lua-side model loop needs. A chat result now reports whether the request was refused as too large before any round ran, and an empty reply resumes as an absent field rather than an empty string, so Lua branches on presence alone. A tool-call request may carry the model's call id, which distinguishes a model-issued call from a script call. A chat request now distinguishes no tool list from an explicit empty list, so a section can leave the advertised set to its current scope while an agent keeps an exact list.

- `ChatResult` gains `overflow: bool`. When it is set no round ran and every other field is absent or empty; otherwise at most one of `reply` and `tool_calls` is present.
- `Request::Chat` now carries `tools: Option<Vec<String>>` in place of `Vec<String>`. `parse_chat_opts` returns `None` when `opts` or `opts.tools` is absent and `Some` for any present list, empty included.
- `Request::ToolCall` gains `call_id: Option<String>`. `Some` marks a model-issued call and `None` a script call, so the two paths stay distinguishable at dispatch.
- `shim_optional_string` reads a shim-produced optional string field and maps a present non-string to `FieldFailure::Malformed`, unlike author-argument failures, which frame as catchable call errors.
- `chat_result_table` always sets `overflow` as a boolean and drops an empty `reply` string, so an empty reply resumes as nil whether the producer left the field absent or handed over `Some("")`.
- `parse_tool_call` treats a non-string `call_id` as a malformed yield; `a_non_string_call_id_is_a_malformed_yield` asserts the direct-yield failure rather than a call error.
- `chat_with_an_empty_tools_list_parses_an_explicit_empty_set` fixes `{ tools = {} }` as `Some(Vec::new())`, distinct from the `None` that an absent list or absent `opts` produces.
- `Request::ToolCall` is matched with `call_id: _` in `crates/promptforge-api-runtime/src/execute/scheduler/dispatch.rs`; nothing reads the id yet, and the `Request::Chat { .. }` arm there remains an internal-error guard that reads neither `model` nor `tools`.

Design: new surface-growth @ crates/promptforge/lua/src/protocol/render.rs::chat_result_table boundary: pub
Design: new pure-function @ crates/promptforge/lua/src/protocol/parse.rs::shim_optional_string deps: &mlua::Table,&str
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
A section VM can now yield one stateless tool-capable model round and get the raw result back: the reply or the unexecuted tool-call batch, the finish reason, the serving model, the metrics, and an overflow flag when the request was refused as too large before or by the provider. The scheduler owns every event of the round, firing the same observation sequence the Rust loop fires for one round, so the loop can later move into Lua without changing what a host sees. The round's tool scope is read at call time and recorded on the chain, and a tool name the model requests outside that scope fails the call as out of scope. An absent tool list means the section's current scope plus its local Lua tools; an explicit list names exactly its members and an alias bound nowhere fails before any request leaves. The leaf work still runs through the existing spawned path; only the arm and its events are new.

- `Arrival` widens the answer channel so a `chat` round posts its raw completion instead of a finished answer; the driver classifies it in `accept_chat` on its own thread, where the parked chain's reporting handles and advertised scope are reachable.
- `advertised` on `Chain` records the round's alias-to-target map at dispatch, so the scope gate in `tool_calls` checks the model's requested names against what this round offered rather than the section's scope at arrival time.
- `scope_halves` resolves an explicit `tools` list member by member: a local tool, an effective binding, or a bound catalog slot outside the section's scope; an unknown alias returns the same `UnboundToolCall` error the script `tool_call` arm produces, now built by the shared `unbound_tool_call`.
- `prepare_chat` runs the whole fallible preparation in one body: client resolution, binding, scope, projection, precheck, and the spawned round. It is over the unit size ceiling and shares its four-parameter signature with `dispatch_chat`.
- `failed` on `Round` maps a provider context rejection to the overflow answer under a failed turn, and an empty reply to a completed round with the reply absent, so the caller applies its own exit rules against `finish_reason`; every other failure is a failed turn and the call's error.
- `precheck` refusal before dispatch answers the round with `overflow_result` and never leaves the process; the tests confirm zero gateway calls on that path.
- `chat_shim` on `RunState` and `SectionVmSetup` is test-only: it installs `models.chat` in a section VM so fixtures can reach the arm directly. Production section VMs never install it; only the loop shim yields `chat`.
- `call_metrics` and the three loop-test fixtures widen to `pub(crate)` and `pub(super)` for reuse; no external surface changes.

Design: new oversized-unit @ crates/promptforge-api-runtime/src/execute/scheduler/chat.rs::prepare_chat
Design: new shared-parameter-cluster @ crates/promptforge-api-runtime/src/execute/scheduler/chat.rs::prepare_chat
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/scheduler/chat.rs::scope_halves deps: Option<&[String]>,ToolSet,Vec<ToolBinding>,Vec<ToolSchema>
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/scheduler/chat.rs::overflow_result
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/scheduler/dispatch.rs::unbound_tool_call deps: ToolSet,str
Design: new parameter-object @ crates/promptforge-api-runtime/src/execute/scheduler/chat.rs::Round
Design: new parameter-object @ crates/promptforge-api-runtime/src/execute/scheduler/chat.rs::Served
Design: extends message-passing @ crates/promptforge-api-runtime/src/execute/scheduler.rs::Arrival
Design: new flag-parameter @ crates/promptforge-api-runtime/src/execute/section_vm.rs::SectionVmSetup::chat_shim
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
A tool call issued by the model now always resumes with content: a tool's own failure comes back as nonce-wrapped untrusted text and the result report fires under the model's call id, so the model reads the failure and the round continues. A call from the author's own script keeps raising at the call site. A call to a local Lua tool is answered on the parked chain's own VM with no leaf work spawned, and the five model built-in task names are refused before alias lookup so no bound or local tool can shadow them. The tool-call arm moves out of the general dispatch module into its own file, and the shared dispatch tests move into a sibling file beside the code they cover.

- `dispatch_model_tool` wraps the shared `dispatch_tool` body and converts only `Error::Tool` into untrusted content before firing `on_tool_result` under the `call_id` carried by `ModelReport`; cancellation and the counts' own error still propagate.
- `RESERVED_TOOL_NAMES` is checked in `prepare_tool_call` before the local-tool and bound-alias lookups, so a local tool registered under `task_status` is unreachable and each name answers `unbound_tool`.
- `ToolCallDispatch` splits the arm's outcome into `Spawned` (parked on the pending table) and `Answered` (resumed on the spot), which is how the local-tool path issues no leaf request.
- `install_model_tool_call_shim` installs `tools.call_as_model` on test hosts only, reading the `MODEL_TOOL_CALL_REGISTRY` stash the same way `install_agent_chat_shim` reads its own.
- `answer_local_tool` treats a local handler's failure as the call's error for both the script and the model-issued form, so a model-issued call to a local tool does not receive the untrusted-failure-text conversion a bound tool does.
- `answer_local_tool` seeds and increments the alias count before the handler runs, and reports the result as trusted under the model's call id or an empty id for a script call.
- `leaf_requests_issued` exposes `next_request` under test so the local-tool tests can assert that zero leaf requests were issued.
- `tools_call_as_model` has no production caller: the loop shim in the same chunk does not call it, and only the test-only install reaches its registry stash.
- `prepare_tool_call` grows to about 120 lines, holding the reserved check, the inline local answer, and both spawned dispatch bodies in one function.

Design: new shared-parameter-cluster @ crates/promptforge/lua/src/dispatch.rs::dispatch_model_tool deps: GuardNonce,ModelReport,Observer,ToolBinding,ToolCallCounts,Value,str
Design: new shared-parameter-cluster @ crates/promptforge-api-runtime/src/execute/scheduler/tool_call.rs::answer_local_tool deps: Observer,ScriptReport,SectionVm,ToolCallCounts,Value,str
Design: new oversized-unit @ crates/promptforge-api-runtime/src/execute/scheduler/tool_call.rs::prepare_tool_call
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The model-tool loop now runs as Lua inside the section shim, yielding one chat round per iteration and one tool call per requested call, so every network wait inside it is an ordinary coroutine suspension and the scheduler's step body never awaits. The Rust loop, its dispatch arm, the loop request and its answer, the registry-key plumbing that let Rust append to the author's message list, and the compactor invocation helper are deleted. The chat round now reports which gate refused an overflowed request and the client's phrase for an empty reply, so the shim's exit rules raise the same errors the deleted loop did. The loop tests are rewritten at prompt level through the scheduler, and a new test proves the author's list never shows a half-answered tool batch.

- `models_loop` in the shim chunk drives the loop over `chat` and `tool_call` yields: per round it yields one chat, on tool calls yields one tool_call per call under its call id, buffers every result, then appends the assistant tool-call record and one tool record per result. The shim emits no events; the scheduler reports each round as it applies the answer.
- `Request::Chat` gains `binding`, the loop shim's leading handle as its frozen binding, which wins over `model` in `prepare_chat`. The new `call_handle` in parse.rs reads the handle for both `models.infer` and `models.loop` so each error names its call; `parse_loop` and `Request::Loop` are gone.
- `ChatResult` gains `overflow_reason` and `empty_detail`; `chat_result_table` renders both so the shim hands the reason tag to the compactor and raises the client's phrase as the `empty_model_reply` message.
- `DispatchTarget::Bound` loses its `ToolBinding` payload. The advertised map now carries names only; the `tool_call` arm resolves each name against the catalog or the section VM itself.
- `install_coro_shims` takes `max_tool_iterations`, threaded from `SectionVmSetup` through `install_shim_prelude` into the chunk as a capture beside the `compactors` table, so the shim reads neither a global nor a host call for its cap.
- `dispatch`, `step_inner`, `resume_block`, `start_lua`, and `handle_coro_result` become synchronous; `cancel::maybe_scope` stays only so the `tool_call` arm's spawned task captures the arm's cancel handle.
- `call_metrics` moves from the deleted `tool_loop.rs` into `chat.rs` with its body unchanged.
- `compact` re-raises an author compactor's own failure through `normalize_failure`; a compactor that returns instead of raising is refused as a `lua`-kind error naming the deferred replacement framework. A non-function compactor is refused before any request leaves, with `host_type` naming integers as "integer" to match the parse's wording.
- `empty_stop_turn_without_tool_calls_fails` now asserts one completed turn and `MODEL_TURN_COMPLETED` where the deleted loop reported zero turns and `MODEL_TURN_FAILED`: the round itself completes and the shim's exit rule raises afterward.
- `the_author_list_never_shows_a_half_answered_tool_batch` asserts each local handler in a two-call batch sees only the user message and that the batch record and both results land together ahead of the terminal text.
- `never_converging_script` gives every round a distinct call id because each round re-validates the author's whole list, whose ids must be unique.
- `drain_task_notices` is an empty function called ahead of every round; nothing drains.
- `invoke_selected`, `append_message_record`, `run_models_loop`, `run_prose_inference`, `LocalDispatch`, `RunState::tools`, and `Answer::Loop` are deleted along with `models_loop.rs`, `tool_loop.rs`, and the `parse_loop` and `render` protocol tests.

Design: replaces pure-function @ crates/promptforge-api-runtime/src/execute/scheduler/chat.rs::call_metrics deps: Completion was: crates/promptforge-api-runtime/src/execute/tool_loop.rs::call_metrics
Design: new pure-function @ crates/promptforge/lua/src/protocol/parse.rs::call_handle deps: Table,str
Design: extends bag-of-state @ crates/promptforge/lua/src/protocol/answer.rs::ChatResult boundary: pub
Design: extends parameter-object @ crates/promptforge-api-runtime/src/execute/section_vm.rs::SectionVmSetup
Design: extends constructor-injection @ crates/promptforge/lua/src/__impl_coro.lua
Design: new shotgun-surgery @ crates/promptforge/lua/src/vm.rs::install_coro_shims
Deferred: drain_task_notices in crates/promptforge/lua/src/__impl_coro.lua is a no-op stub with no task notices yet to drain
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The 864-line coroutine test sibling becomes a five-file test directory split by concern: shared fixtures, the yield shims, the failure contract at the coroutine boundary, the coroutine mechanics, and a new instruction-cost measurement. The compactor tests leave the loop suite for a sibling of their own. The new test counts the Lua instructions the loop shim spends per model-tool round and bounds them between a floor of 20 and a ceiling of 300, so the loop's move from Rust into Lua cannot silently tax the author's instruction budget. The test-only install of the model-form tool call shim is compiled only under a dev feature, so a production build of the Lua crate no longer exports it. The loop bench was re-run against its pre-split baseline: round overhead is within noise, while the zero-round overflow path costs about 270 microseconds more per failed run through the Lua compactor raise; that cost is recorded, not fixed.

- `crates/promptforge-api-runtime/src/lua/tests/mod.rs` holds the fixtures moved unchanged from `lua-coro-tests.rs` (`test_models`, `StubTool`, `test_tools`, `scheduler_vm`, `scheduler_vm_with_tools`, `start`, `yielded_request`, `compile_block`); `shims.rs` (10 tests), `errors.rs` (12), and `coroutine.rs` (5) carry the 27 existing tests verbatim, and `quota.rs` holds the one new test. `lua.rs` replaces the `#[path = "lua-coro-tests.rs"]` sibling with `mod tests;`.
- `models_loop_compactors.rs` takes the six compactor tests out of `models_loop.rs` unchanged, importing `loop_context` and `loop_prompt` from it; `models_loop.rs` keeps the shared loop fixtures for every loop-driven sibling and drops its `OverflowReason` import.
- `install_model_tool_call_shim` is compiled and re-exported only under `promptforge-lua`'s new `test-support` feature, so the function is absent from every production VM rather than exported and unused; `promptforge-api-runtime` enables the feature as a dev-dependency because its `#[cfg(test)]` `raw_shims` setup path in `section_vm.rs` is the one caller.
- `a_models_loop_round_costs_a_few_hundred_lua_instructions` installs a per-instruction counting hook on the loop thread with `set_hook` and `every_nth_instruction(1)`, answers four rounds with one `echo` tool call each, and asserts every round after the first spends between `ROUND_INSTRUCTION_FLOOR` (20) and `ROUND_INSTRUCTION_CEILING` (300) instructions and that the author's list ends at 10 records: user, four assistant-plus-tool pairs, terminal reply.
- `ROUND_INSTRUCTION_FLOOR` makes the test fail when the counted span is not the loop's work (the hook not firing on the thread, or the loop back in Rust) instead of passing while showing nothing about the quota.
- `benches/models_loop.rs` now describes the Lua-shim loop; the re-run measured `models_loop` at about 2.08 ms mean (+3% against the 2.02 ms baseline, inside the 10% noise band) and `compactors_fail` at about 1.04 ms (+36% against 768 us). That bench runs zero rounds, so the cost sits on the overflow path where `compact` runs a `raw_pcall` of `compactors.fail` and the raise unwinds through the block guard to the scheduler.
- `parse_request` in `quota.rs` repeats the parse tail of `yielded_request` from the fixtures module instead of sharing it.
- `compactors_fail` bench regression is measured and recorded; no product code changes here beyond the feature gate.

Design: removes surface-growth @ crates/promptforge/lua/src/coro.rs::install_model_tool_call_shim deps: Lua boundary: pub
Design: new pure-function @ crates/promptforge-api-runtime/src/lua/tests/quota.rs::parse_request deps: MultiValue,SectionVm
Design: new pure-function @ crates/promptforge-api-runtime/src/lua/tests/quota.rs::tool_call_round
Design: new pure-function @ crates/promptforge-api-runtime/src/lua/tests/quota.rs::reply_round deps: str
Deferred: the compactors_fail bench's overflow failure path costs about 270 us more per failed run through the Lua compact raise of compactors.fail, measured and recorded but not fixed
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Every leaf request a section yields - a model round, a bound tool call, an operator input wait, a store operation, a timer - is now built as a plain effect value and handed through one scheduler path to an internal performer table, which spawns the same leaf work as before and posts a raw answer keyed by the effect's id. Every answer comes back through one application function on the driver thread, where the round's events are emitted and the answer becomes the parked chain's protocol answer. Each effect projects onto a serializable record that drops its live store handle, so a run log can store what the engine asked for without the engine ever reading one back. The public API is unchanged; the existing executor suites pass and a new suite proves one effect per leaf kind with a serde round trip.

- `Effect` has five variants (Chat, ToolCall, UserInput, Store, Timer). Every leaf arm builds one and calls `issue`, the single path that allocates the `EffectId` from `next_effect`, hands the effect to the performer table, stores the join handle, and parks `Pending { chain, resume }`; no arm spawns or parks on its own.
- `Performers` is the engine's stand-in host: it owns the answer channel's sender, the `GatewaySource`, and the lazily resolved cached client that previously lived on `Scheduler` and on each chain's `client` slot. `perform` matches the effect and spawns one task per kind (blocking pool for `Store`); it touches no scheduler state and emits no event.
- `apply_answer` pairs the effect's `Continuation` with the raw `EffectAnswer` on the driver thread and replaces the inline match that `drive` used to hold. A kind mismatch is `Error::Internal`; `EffectAnswer::Dropped` resumes the chain with `Error::Interrupted` through `dropped_answer` and aborts the performer via `discard_performer`; a dropped timer moves its slot to `Cancelled` without waking the owner.
- `EffectRecord` mirrors `Effect` variant for variant and is the only serde form: `Effect` itself derives no `Serialize` because `Store` holds an `Arc<Access>`. The Chat record flattens the binding to model name, alias, temperature, max_tokens, and thinking, and carries messages as wire-form JSON values. `StoreOp` in promptforge-lua gains `Clone, PartialEq, Eq, Serialize, Deserialize` so the record can carry it.
- `EffectId` replaces `RequestId` everywhere: `pending`, `io_tasks`, `aborted_effects`, and `TaskBacking::Effect` are keyed by it.
- `ToolCallContinuation` and `Continuation::Store(Option<(Observation, Observation)>)` hold what the spawned closures used to capture (binding, report, call id; the succeeded/failed observation pair), so the answer can be applied without the arm's stack.
- `prepare_model_dispatch` is a new sync body in promptforge-lua mirroring `prepare_dispatch` under the model-issued rule; `perform_call` is the shared async half (count, cancel race, tool call). `dispatch_tool` and `dispatch_model_tool` are now wrappers over `perform_call` plus the matching sync body, and the scheduler calls only the sync bodies.
- `accept_infer` replaces the async `infer_round`: it takes the completion result rather than performing the round, and the infer path is a `Chat` effect over one user message with no tools.
- `counts.increment(binding.alias())?` now runs at dispatch inside `prepare_tool_call`, before the performer spawns; `accept_tool_call` passes `None` for the counts so nothing is counted twice.
- `stream` on `Effect::Chat` is `true` for a section's `chat` round and `false` for a nested `models.infer`; the performer forwards deltas to the host hook only when it is `true`. Two tests pin both branches.
- `cancel::maybe_scope` no longer wraps a bound tool call: the performer awaits `tool.call(args)` directly and cancellation is the driver aborting the join handle, so an aborted call emits no `TOOL_CALL_FAILED` observation from inside the task; the `cancel::current` re-export is removed.
- `accept_store` and `accept_user_input` report the store observation and the delivered input text on the driver thread when the answer is applied; `USER_INPUT_WAIT_STARTED` is still reported at dispatch.
- `dispatch_store` and `dispatch_user_input` now turn an `issue` failure into the call's error answer instead of never failing at dispatch.
- `aborted_effects` records a dropped effect's id so its performer's late answer is discarded rather than failing the run; a test stages the race from inside a broker and checks the performer future was dropped.
- `Effect::record` and `EffectAnswer::Dropped` carry `expect(dead_code)` outside test builds: this diff adds no production caller for either. `record` is called only from the test tap in `Performers` and the record tests; `Dropped` is constructed only by tests.
- `GatewaySource` loses its `ready()` accessor along with the H1 and walk client seeding; no chain carries a `client` slot any more.

Design: replaces newtype @ crates/promptforge-api-runtime/src/execute/run.rs::EffectId was: crates/promptforge-api-runtime/src/execute/scheduler.rs::RequestId
Design: new parallel-abstraction @ crates/promptforge-api-runtime/src/execute/run.rs::EffectRecord
Design: new flag-parameter @ crates/promptforge-api-runtime/src/execute/run.rs::Effect::Chat
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/run.rs::wire_value deps: T
Design: new facade @ crates/promptforge-api-runtime/src/execute/scheduler/performers.rs::Performers
Design: new dispatch-on-tag @ crates/promptforge-api-runtime/src/execute/scheduler/performers.rs::Performers::perform
Design: new dispatch-on-tag @ crates/promptforge-api-runtime/src/execute/scheduler/apply.rs::Scheduler::apply_answer
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/scheduler/apply.rs::dropped_answer deps: Continuation
Design: removes clone-block @ crates/promptforge-api-runtime/src/execute/scheduler/dispatch.rs
Design: extends shared-parameter-cluster @ crates/promptforge/lua/src/dispatch.rs::prepare_model_dispatch deps: GuardNonce,ModelReport,Observer,Option<&ToolCallCounts>,Result<ToolOutput,ToolError>,ToolBinding,str
Design: extends shared-parameter-cluster @ crates/promptforge/lua/src/dispatch.rs::perform_call deps: Observer,Option<&ToolCallCounts>,ToolBinding,Value,str
Design: new surface-growth @ crates/promptforge/lua/src/protocol/request.rs::StoreOp boundary: pub
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The engine's scheduler no longer awaits, spawns, or sleeps. A run is now a state machine the host steps: each step drains every chain that can make progress and returns the leaf effects those chains issued beside the events reported, and the host hands each answer back one at a time. The run's end is withheld until every issued effect has exactly one answer, a drop counting as one, so a host that answered everything it was handed knows nothing is still out. Cancellation becomes a synchronous flag the Lua instruction hook and the scheduler poll, installed on every section VM and handed to the activated capabilities, replacing the task-local token the engine used to await. A tokio loop inside the runtime crate performs the effects with the existing client, tools, and broker so the current suites keep passing, and the sessions crate bridges its awaitable token to that flag.

- `Run` owns the scheduler outright: `Scheduler` drops its lifetime and holds its `RunState` by value, and a chain names its walk position by `SlicePath` instead of borrowing the prompt tree, so the run is `Send` and moves between threads between calls.
- `TokioDriver` replaces the scheduler's internal `Performers` table: the channel, the join handles, and the abort bookkeeping move out of the engine into a host loop that spawns one performer per effect, joins them once the run is decided, and answers each `Dropped`.
- `Phase` tracks the run through fresh, running, ending, and done; `teardown` drops every chain's live state and orphans every pending effect, and the result is held until the orphans are answered.
- `CancelHandle::cancelled` gives the synchronous flag an awaitable future, woken by the cancel through a per-node waker list, so the tokio loop selects over it instead of polling on a timer.
- `InstructionBudget` holds the cancel flag in a `OnceLock` installed through `set_cancel`; the hook and the chunk failure mapper read it instead of the task-local scope.
- `stamp_effect` draws an issued effect's provenance from the same counter as the task's events, so a task's event sequence is strictly increasing but no longer dense; the provenance suite is relaxed to match.
- `RunContext::cancel` now takes the synchronous handle and the field is never absent: a fresh flag is minted at construction and `Environment::prepare` hands that flag to the capabilities, so a host handle must be set before prepare. `RunServices` changes its cancel type to match.
- `orphaned` records every effect whose chain stopped waiting; an answer for one is discarded, an answer for an unknown or repeated id ends the run with an internal error, and every answer after `Done` is ignored.
- `perform` answers inline when an effect cannot be performed (no client for a chat, an unbound tool, no broker) and clamps an unrepresentable timer duration to zero rather than failing the run.
- `dispatch_tool` and `dispatch_model_tool` are removed with `perform_call`; the Lua crate no longer performs a call or races it against cancellation, and the model-issued rule tests move to the sync `prepare_model_dispatch`.
- `bridge_cancel` spawns a task that sets the engine's flag when the session's token fires and is aborted once the run returns.
- `Run::cancel` has no production caller; the in-crate host cancels through the context's handle, and the method carries a dead-code expectation naming the harness as its first host.
- `dispatch-tests-race.rs` is deleted; its cancellation tests (a cancelled dispatch still counts the attempt, cancellation propagates through the model-issued path) have no counterpart in this diff.

Design: new facade @ crates/promptforge-api-runtime/src/execute/run.rs::Run
Design: new newtype @ crates/promptforge-api-runtime/src/execute/scheduler.rs::SlicePath
Design: extends ambient-context @ crates/promptforge-api-runtime/src/execute/context.rs::RunState
Design: removes message-passing @ crates/promptforge-api-runtime/src/execute/scheduler.rs::Scheduler
Design: replaces message-passing @ crates/promptforge-api-runtime/src/execute/tokio_driver.rs::TokioDriver was: crates/promptforge-api-runtime/src/execute/scheduler/performers.rs::Performers
Design: replaces oversized-unit @ crates/promptforge-api-runtime/src/execute/tokio_driver.rs::TokioDriver::perform was: crates/promptforge-api-runtime/src/execute/scheduler/performers.rs::Performers::perform
Design: new swallowed-exception @ crates/promptforge-api-runtime/src/execute/tokio_driver.rs::TokioDriver::perform
Design: new surface-growth @ crates/promptforge-api-types/src/cancel-sync.rs::CancelHandle::cancelled boundary: pub
Design: hidden-dependency -> temporal-coupling @ crates/promptforge/lua/src/hardening.rs::InstructionBudget
Design: new temporal-coupling @ crates/promptforge-api-runtime/src/execute/config.rs::RunContext::cancel
Design: new parallel-abstraction @ crates/workshop/sessions/src/agents/supervisor/effects.rs::bridge_cancel deps: CancelHandle
Design: new shotgun-surgery @ crates/promptforge-api-runtime/src/execute/tests
Deferred: `Run::cancel` has no production caller because the in-crate tokio host cancels through the context's handle
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Make the run driver surface public and add a serial test driver that performs a run's effects on the calling thread with no runtime and no network, so the engine's determinism and answer-ordering properties are checked by plain unit tests. Add a task history read: an author reads the events a task it owns, or the task it runs inside, has reported so far, and the model reads the same history through a fifth built-in whose answer resumes untrusted because it carries model, tool, and user text. The engine keeps no history of its own; every host answers the read from the events it has already been handed, which is why a host must commit a step's events before it performs that step's effects.

- `Run`, `Step`, `Effect`, `EffectAnswer`, `EffectId`, and `EffectRecord` become `pub` and are re-exported from `lib.rs`; `test_support` becomes a `pub` module under the new `test-support` cargo feature beside `cfg(test)`, so a companion crate can drive a run without tokio.
- `task_history` in `execute.rs` is the one implementation of a history read's answer; `test_support::drive` and `TokioDriver::perform` both call it over their own event buffers.
- `Continuation`, `ToolCallContinuation`, and `Pending` move from `scheduler.rs` into `scheduler/pending.rs`. `pending.rs` imports `TaskEventsReader` from `task_events.rs`, which imports `Continuation` back through the parent, so the two sibling modules depend on each other.
- `advertise_task_builtins` and `builtin_schema` move to `builtins-schemas.rs`, a `#[path]` sibling of `builtins.rs` that keeps the arms; the moved function gains the fifth schema and spans 105 lines.
- `TaskEventsReader` (`Shim` or `Builtin { call_id }`) rides inside `Continuation::TaskEvents`, so `dispatch_task_events` and `builtin_task_events` issue the same `Effect::TaskEvents` and `accept_task_events` renders the answer per reader.
- `BuiltinAnswer` gains a `trusted` field and `served_untrusted`; `report_builtin_answer` passes `answer.trusted` to the `ToolResult` where it passed a literal `true`.
- `Completion::from_result` and `ToolCall::from_parts` in `wire-canned.rs` are the first `pub` constructors for the `#[non_exhaustive]` wire types; in this diff only the test helpers `text_reply` and `tool_call_reply` call them.
- `readable_task` admits a task the caller owns (an internal timer slot excluded through `is_internal`) or the caller's own task; an unknown id and an unowned id both answer `TaskNotOwned`, so a caller learns nothing about tasks it never started.
- `builtin_task_events` refuses through `model_task` and `last_argument` with trusted text observed as a failed call; an empty history answers the trusted sentence `no new events`; a non-empty one is `nonce.wrap` over one JSON event per line, reported untrusted under the model's call id.
- `TokioDriver` gains a `history` field; `forward` takes `&mut self` and appends each step's events to it, and `perform` answers `Effect::TaskEvents` synchronously from it and returns `false`, so the read never enters `outstanding`.
- `drive` answers every effect in the step it is issued, answers `Dropped` once `run.decided()`, and reports a `Pending` step with no effects as an internal failure instead of hanging.
- `parse_task_events` accepts an integer or an integral float for `last` and rejects negative, fractional, above-`u32`, and non-numeric values as the call's own error; `event_sequence` renders events with `serialize_none_to_null(false)` so an absent optional field is `nil` in author code.
- `Batching::Reversed` is the only permutation the batching-pairing test runs; no shuffled order within a batch is exercised.
- `render_events` replaces a failed event serialization with a fixed `unrenderable` line rather than surfacing the error; its comment calls the branch unreachable and no test reaches it.
- `perform_locally` panics on `Effect::TaskEvents`; the test performers rely on the drivers answering that effect before the closure runs.
- `Effect::TaskEvents`, `drive`, and `TokioDriver` state in doc comments that a host appends a step's events before performing its effects; nothing in the types enforces it.

Design: new surface-growth @ crates/promptforge-api-runtime/src/lib.rs boundary: pub
Design: new surface-growth @ crates/promptforge-api-runtime/src/execute/run-effect.rs::EffectRecord::TaskEvents boundary: persisted
Design: new temporal-coupling @ crates/promptforge-api-runtime/src/execute/run-effect.rs::Effect::TaskEvents
Design: new cyclic-dependency @ crates/promptforge-api-runtime/src/execute/scheduler/pending.rs
Design: extends dispatch-on-tag @ crates/promptforge-api-runtime/src/execute/scheduler/dispatch.rs::Scheduler::dispatch
Design: extends dispatch-on-tag @ crates/promptforge-api-runtime/src/execute/scheduler/apply.rs::Scheduler::apply_answer
Design: extends dispatch-on-tag @ crates/promptforge-api-runtime/src/execute/scheduler/builtins.rs::Scheduler::answer_task_builtin
Design: extends dispatch-on-tag @ crates/promptforge-api-runtime/src/execute/tokio_driver.rs::TokioDriver::perform
Design: extends dispatch-on-tag @ crates/promptforge/lua/src/protocol/parse.rs::Request::from_yield
Design: extends stringly-typed @ crates/promptforge-api-runtime/src/execute/scheduler/builtins.rs::TASK_BUILTINS
Design: replaces oversized-unit @ crates/promptforge-api-runtime/src/execute/scheduler/builtins-schemas.rs::advertise_task_builtins deps: BTreeMap<String, DispatchTarget>,TaskAllowlist,Vec<ToolSchema> was: crates/promptforge-api-runtime/src/execute/scheduler/builtins.rs::advertise_task_builtins
Design: new pure-function @ crates/promptforge-api-runtime/src/execute.rs::task_history deps: Event,Option<u32>,TaskId
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/scheduler/task_events.rs::last_argument deps: Value
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/scheduler/task_events.rs::render_events deps: Event
Design: new swallowed-exception @ crates/promptforge-api-runtime/src/execute/scheduler/task_events.rs::render_events
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The run context now carries a host-drawn seed, a host-stamped start instant, an empty behavior flag set, and a host-state snapshot taken at launch. The untrusted-envelope nonce derives from the seed and every section's start timestamp renders the stamped instant, so two runs given the same inputs and answers produce the same nonces and the same timestamp text. The engine no longer reads the system clock or the OS random source; the Workshop session host draws both at launch. The per-read timestamp global and the fallible timestamp formatter are gone, and the runtime crate drops its random and time dependencies.

- `RunContext::new` takes `seed: u64` and `started_at: Timestamp` beside the name; `start_time: SystemTime` is removed and `flags: Flags` starts as `Flags::EMPTY` with a `flags` builder and `seed`, `run_flags`, and `started_at` readers. This is a breaking change to the public constructor.
- `GuardNonce::from_seed` expands the 64-bit seed to 128 bits with two SplitMix64 rounds; the nonce's unpredictability is now the seed's 64 bits rather than the 128 CSPRNG bits `GuardNonce::fresh` still draws for callers that keep it.
- `RunLimits`, its `nz!` constructors, and its unit test move verbatim from `config.rs` into `config-limits.rs`; `config.rs` re-exports it.
- `ui` on the context becomes `Option<serde_json::Value>`, shared into every section VM as one `Arc`; `install_ui` converts the snapshot into a fresh table per call, so author mutation of one result never reaches the next.
- `MarkdownRunParts` in the Workshop session gains `ui`, `seed`, and `started_at`; `launch_markdown` fills them from the provider, `rand::random`, and `now_timestamp`, which saturates a pre-epoch or overflowing clock to the epoch instead of refusing the launch.
- `sys_json` loses its `now` argument and `with_walk_state` loses `when`: the H1 pass and every walked section read the same `started_at` rendering, where H1 previously stamped its own time and the walk stamped another.
- `test_context` in the runtime's test module fixes seed 1 and a millisecond instant for every fixture; `untrusted_nonce_differs_across_runs_under_different_seeds` now varies the seed explicitly instead of relying on a fresh draw.
- `run_inputs.rs` pins the determinism contract: same seed and start give identical text, a different seed changes the nonce but not the timestamp, H1 and the walk agree, a whole-second instant renders without a fraction, `sys.now` raises the unknown-field error, and `ui()` reads the snapshot.
- `Error::TimestampFormat`, `now_rfc3339_checked`, and the `sys.now` field are removed along with the guide sentences describing `now`; `rand` and `time` leave the runtime crate's dependencies.
- `untrusted-tests.rs` receives the guard's test module verbatim plus `a_seeded_nonce_is_a_function_of_its_seed_alone`.

Design: hidden-dependency -> constructor-injection @ crates/promptforge-api-runtime/src/execute/config.rs::RunContext::new boundary: pub
Design: hidden-dependency -> constructor-injection @ crates/promptforge-api-runtime/src/execute/context.rs::RunState::new
Design: removes hidden-dependency @ crates/promptforge-api-runtime/src/execute/support.rs::now_rfc3339_checked
Design: new surface-growth @ crates/promptforge-api-runtime/src/execute/config.rs::RunContext boundary: pub
Design: new pure-function @ crates/promptforge-api-types/src/untrusted.rs::GuardNonce::from_seed boundary: pub
Design: new hidden-dependency @ crates/workshop/sessions/src/agents/supervisor/effects.rs::now_timestamp
Design: new swallowed-exception @ crates/workshop/sessions/src/agents/supervisor/effects.rs::now_timestamp
Design: new shotgun-surgery @ crates/promptforge-api-runtime/src/execute/config.rs::RunContext::new
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The engine now binds and advertises tools from data alone. A descriptor carries a tool's identity, wire name, description, schema, output kind, and conflicts; the catalog holds descriptors, and the implementations live in a host-side table keyed by identity that the tokio loop resolves each tool call against. Capability resolution, conflict checking, and activation leave the prepare pass for a host-side function that the run entry point calls once when the host supplies a registry. The run context sheds its observer, capture, client, broker, and delta hook; those ride a new host bundle that the loop's performers draw on, so the context is the engine's input and nothing else. The deployment environment keeps only its host roots, nesting cap, and the catalog the host hands it.

- `ToolDescriptor` is the tool as data: six public fields, a `new` that fills them, `structured` and `with_conflicts` builders, and `describe` / `describe_all` to derive one from a `Tool`. `ToolCatalog` stores `Arc<[ToolDescriptor]>`; `new` takes descriptors and `get` returns `&ToolDescriptor`.
- `ToolTable` maps `ToolId` to `Arc<dyn Tool>` on the host side; a repeated identity keeps the first implementation. `TokioDriver::perform` resolves a `ToolCall` there instead of scanning the run's tool-set snapshot, and an id with no entry resumes as a `ToolError` reading "no implementation in the host's table".
- `activate` takes an optional `CapabilityRegistry`, the prompt, and `RunServices`, and returns an `Activation` of catalog, table, and unsatisfied requirements. Its body is the former prepare body moved: declaration-order resolution, symmetric conflict marking, per-capability `create`, then `assemble` with containment, uniqueness, and wire-name checks. The final catalog build still logs and falls back to `ToolCatalog::default()` on error.
- `RunHost` bundles observer, debug capture, client, registry, tool table, input broker, delta hook, and activation requirements behind builders. `run`, `Environment::run`, and `TokioDriver::over` take it; the driver reads its fields directly. `RunContext` loses those five seams and gains `report_debug`, `vfs_explicit`, and `cancel_handle()`.
- `Environment` drops `client` and `registry`, gains `tools: ToolCatalog` with a `tools()` builder, `run_vfs()`, and `Clone`. `prepare` keeps a host-set handle when `vfs_explicit` is true rather than replacing it unconditionally; `Environment::run` builds the router first, activates over it, installs the catalog on a clone, and merges the activation report into prepare's.
- `ToolBinding` replaces `tool: Arc<dyn Tool>` with `schema: Json` and `conflicts: Vec<CapabilityId>`, derives `Debug`, `PartialEq`, `Eq`, and gains `from_descriptor`; `for_test` now takes a descriptor. `prepare_scoped_tools` reads `binding.description()` and `binding.schema()`.
- `Requirements::merge` folds another report in, skipping a `missing_required` id already present; `notice` widens from `pub(crate)` to `pub`.
- `fill_tool_bindings` decides whether a slot's capability is present by any catalog entry under the capability's prefix, replacing the removed activated-id parameter.
- `dispatch_user_input` no longer answers a broker-less run inline: every `user_input` issues a `UserInput` effect and reports the wait, and the driver answers `InputOutcome::Unavailable` when `host.input` is `None`. The corresponding test now asserts `USER_INPUT_WAIT_STARTED` fires.
- `report_debug` makes `Request` and `Response` events an explicit opt-in defaulting to false; callers that set a capture also set the flag.
- `Environment::run` refuses a capability both activation and prepare report missing with one line; `env_run_activates_over_the_store_the_run_reads` shows the capability's marker written at activation is readable through the run's store, with `create` called once.
- `conflicts` on `ToolDescriptor` and `ToolBinding` is set at assembly and copied at fill but read by no production code in the touched files.
- `test_host` on `RunContext` and `Arc<Mutex<RunHost>>` on `RunState`, the five `cfg(test)` builders on `RunContext`, `env_run`, and `FixtureTools` exist only under `cfg(test)` to keep the in-crate suites compiling against the old context-builder shape.
- `crates/promptforge-api-runtime/src/execute/activation.rs` and `host.rs` sit inside the engine crate and name `Tool`, `InputBroker`, and `CapabilityRegistry`; the engine's own execution path (`context.rs`, `dispatch.rs`, `bindings.rs`, `fill.rs`) no longer imports those traits.
- `session_registry` replaces `session_environment` in workshop-sessions, returning a `CapabilityRegistry` the supervisor caches per gateway generation and hands to `RunHost::registry`; the module's tests move verbatim to `environment-tests.rs`.

Design: replaces registry @ crates/promptforge-api-runtime/src/execute/activation.rs::ToolTable boundary: pub was: crates/promptforge-api-types/src/tools/registry.rs::ToolCatalog
Design: replaces oversized-unit @ crates/promptforge-api-runtime/src/execute/activation.rs::activate deps: CapabilityRegistry,Prompt,RunServices boundary: pub was: crates/promptforge-api-runtime/src/execute/environment.rs::Environment::prepare
Design: replaces swallowed-exception @ crates/promptforge-api-runtime/src/execute/activation.rs::assemble deps: CapabilityId,Contribution was: crates/promptforge-api-runtime/src/execute/fill.rs::assemble_catalog
Design: new ambient-context @ crates/promptforge-api-runtime/src/execute/host.rs::RunHost boundary: pub
Design: new bag-of-state @ crates/promptforge-api-types/src/tools/descriptor.rs::ToolDescriptor boundary: pub
Design: extends bag-of-state @ crates/promptforge/lua/src/handles.rs::ToolBinding
Design: new pure-function @ crates/promptforge-api-types/src/tools/registry.rs::describe_all deps: Tool boundary: pub
Design: new shim @ crates/promptforge-api-runtime/src/execute/config.rs::RunContext
Design: extends shared-mutable-state @ crates/promptforge-api-runtime/src/execute/context.rs::RunState
Design: new surface-growth @ crates/promptforge-api-runtime/src/lib.rs boundary: pub
Design: new surface-growth @ crates/promptforge-api-types/src/tools.rs boundary: pub
Deferred: the `observer`, `debug`, `client`, `input_broker`, and `on_delta` builders remain on `RunContext` under `cfg(test)`, routing into `test_host` instead of being removed
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The Workshop session crate now names the engine's model client, capability registry, and capability activation through the harness API crate instead of the engine runtime crate. The harness API gains a bridge module that only re-exports those items and defines nothing of its own. This lets the harness's own implementations replace the engine's in place as they land, without touching the session crate's imports again. The re-exports are temporary and go away once the session machinery lives in the harness and the engine no longer owns a client or a registry.

- `crates/harness-api/src/bridge.rs` re-exports `GatewayClient`, `GatewayEndpoint`, `SecretString`, `CompletionError`, `CompletionErrorKind`, `fetch_model_catalog`, `CapabilityRegistry`, `RegistryError`, `RegistryErrorKind`, `Web`, `Activation`, `ToolTable`, and `activate` from `promptforge_api_runtime`. Pure indirection; the module defines no item of its own.
- `crates/harness-api/Cargo.toml` adds `promptforge-api-runtime` as a dependency so the bridge can forward to it.
- `crates/workshop/sessions/Cargo.toml` adds `harness-api`; `agents.rs`, `agents/environment.rs`, and `agents/supervisor/effects.rs` now import the client, registry, `Web`, and `fetch_model_catalog` from `harness_api::bridge`.
- `crates/workshop/sessions/src/lib.rs` extends the crate's dependency invariant to name the harness door `harness-api` and its `bridge`.
- `promptforge-api-runtime` stays a direct dependency of `workshop-sessions`: `effects.rs` still imports `Environment`, `Prompt`, `RunContext`, `RunHost`, and `RunResult` from it.
- `Activation`, `ToolTable`, and `activate` are re-exported but no file in this change imports them through the bridge.

Design: new facade @ crates/harness-api/src/bridge.rs boundary: pub
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The engine no longer carries a run loop of its own. The tokio loop that performed a run's effects moves into the test-support surface as a driver that takes host-supplied performers, an event sink, and a cancel flag, and the environment-built gateway client leaves with it, so a host without a client gets a deterministic disabled-gateway failure instead of a process-environment lookup. Workshop's agent sessions become that driver's one production caller, an interim host until the harness owns the loop: they activate, prepare, refuse, and drive the run themselves, and every observer callback in Workshop gives way to the engine's event values. The session transcript becomes a memory-only log with the JSONL recorder, its versioned file format, and the restart gate removed, and the agent socket frames a wire projection of those events that drops the chain and depth fields.

- `Performers` is a struct of three boxed async closures (`chat`, `tool_call`, `user_input`), not a set of traits; `Performers::refusing()` answers each kind with its refusal and a host overrides the slots it supplies. The driver performs `Store`, `Timer`, and `TaskEvents` itself.
- `drive_tokio` takes the run, the performers, an event sink, and a `CancelHandle`; when the caller's flag fires the loop calls `run.cancel()`, so a handle distinct from the run's own still tears the run down.
- `RunHost` moves to `test_support::host` with its fields unchanged and gains `performers(limits)` and `sink()`; `run_with_host` and `run_host` replace `Environment::run` and the free `run` for every suite and the bench.
- `requirements.refusal()` returns the typed `RequirementsUnmet` error a host fails with, so the activate-prepare-refuse sequence is spelled out at each host and the engine holds no loop path.
- `test-support = ["dep:tokio"]` makes tokio optional in the runtime; `workshop-sessions` enables the feature as a production dependent, and the `suite` test and `models_loop` bench require it.
- `WorkshopObserver` becomes an `RwLock<Vec<Event>>` plus a broadcast with `append`, `len`, `is_empty`, `get`, and `subscribe`; the `Observer` and `EventLog` impls, `Persist`, `load_from`, `replay`, and the versioned header line are removed.
- `SessionSink` replaces `SessionObserver`: one `observe(Event)` matches the variant, runs the side effects (round count, backoff reset, idle push, failure frame) and appends content kinds alone, so lifecycle events never enter the log.
- `AgentEvent` and `AgentEventKind` in `workshop-protocol` are the wire projection of the engine's content events; `AgentEventFrame::new` returns `None` for a variant without a wire label and `drain_events` skips it with the cursor advanced.
- `run_markdown_agent` and `RunParts` move out of `supervisor/effects.rs` into `agents/run.rs`; the session builds its performers over the bridge client, the activated `ToolTable`, and its `SessionInputBroker`, and bridges its awaitable token to the engine's flag.
- `GatewaySource` and `env_client_with_limits` are deleted: a `Chat` with no client fails with `GatewayDisabled` rather than reading `PROMPTFORGE_GATEWAY_*` from the process environment, and the fanout test asserts the disabled-gateway message however the environment is configured.
- `AgentSessions::new` no longer takes a sessions directory and `launch` creates no files; `close` drops the transcript with the session.
- `deliver_input_response` and `deliver_input_response_before_completion` are removed from `workshop-sessions` and the `workshop-server` re-export: the engine records the operator's text as a `UserInput` event when the suspended call resumes, so a producer-side record would double it.
- `agent-frames.json` loses `chain_id` and `depth`; the SPA's `AgentEvent` type in `protocol.ts` and its `.mjs` tests drop the same fields, with no dual read on either side.
- `run-tests.rs` drives the session's run path directly: an undersized model is refused with `RequirementsUnmet` and an empty log, an unavailable broker ends the chat cleanly, a failing broker yields `RunErrorKind::Input`, and a fired token yields `AgentRunError::Interrupted`.
- `gate_restart_reloads_the_jsonl_and_resumes_waiting_for_input` and the `version1.jsonl` canary are deleted with the format; no test now spans a server restart.
- `performers` in `agents/run.rs` applies `RunLimits::new()` to the client rather than the limits on the run's `RunContext`, so a session run ignores any limits set there.
- `AgentEvent` carries no provenance; its doc says the wire does not yet expose the task and sequence an `Event` locates itself by.

Design: new strategy @ crates/promptforge-api-runtime/src/test_support/tokio_driver-performers.rs::Performers instead-of: speculative-abstraction: one trait per effect kind that every caller must implement
Design: new constructor-injection @ crates/promptforge-api-runtime/src/test_support/tokio_driver.rs::TokioDriver::over
Design: removes hidden-dependency @ crates/promptforge-api-runtime/src/execute/gateway.rs::env_client_with_limits
Design: replaces shared-parameter-cluster @ crates/promptforge-api-runtime/src/test_support.rs::run_with_host deps: Environment,Prompt,RunContext,RunHost,str was: crates/promptforge-api-runtime/src/execute/environment.rs::Environment::run
Design: replaces parameter-object @ crates/promptforge-api-runtime/src/test_support/host.rs::RunHost was: crates/promptforge-api-runtime/src/execute/host.rs::RunHost
Design: extends feature-flag @ crates/promptforge-api-runtime/Cargo.toml::test-support
Design: new surface-growth @ crates/promptforge-api-runtime/src/test_support.rs boundary: pub
Design: new schema-change @ crates/workshop/protocol/src/agent.rs::AgentEvent boundary: wire
Design: event-hook -> dispatch-on-tag @ crates/workshop/sessions/src/agents/session.rs::SessionSink::observe
Design: removes event-hook @ crates/workshop/gateway/src/observer.rs::WorkshopObserver
Design: removes store-boundary @ crates/workshop/gateway/src/observer.rs::Persist boundary: persisted
Design: replaces parameter-object @ crates/workshop/sessions/src/agents/run.rs::RunParts was: crates/workshop/sessions/src/agents/supervisor/effects.rs::MarkdownRunParts
Deferred: the runtime's client module stays as the interim door to the model client until the bridge repoints at harness-models
Deferred: session transcripts live in memory only until the run log takes over durable storage
Deferred: the agent_event wire shape does not expose an event's provenance
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The gateway HTTP client and the model catalog fetch leave the engine's vocabulary crate for the harness, the one production host that performs a model round. The vocabulary crate keeps what every transport shares and drops its HTTP dependencies: the request body builder, the SSE reassembly with its finishing rules, and a read loop over a caller-supplied chunk source that applies the byte cap, the sentinel rule, and the timing arithmetic against a clock the transport hands it. A timeout is now marked by a wrapper the transport applies rather than by downcasting to the HTTP client's error, so the classification survives without the vocabulary naming that client. The engine's interim client door is deleted and its model module becomes the public door for the chat vocabulary; the test host performs a round through a chat client trait, and the suites and the bench drive real rounds through a dev-only mock-gateway client that speaks the same protocol.

- `GatewayClient` now lives in the harness and performs a round by sending the shared request body, wrapping the response as a `ChunkSource`, and handing it to `read_completion_stream` with `Instant::now` as the clock. The transport owns only sending, the request timeout, the chunk source, and the clock.
- `ChunkSource` is the seam between a transport and the reassembly, with three implementations in this change: the harness response wrapper, the mock-gateway client's wrapper, and a canned source in the reassembly's own tests, which time a stream on an injected clock without an executor.
- `StreamAccumulator::finish` takes over the truncation rule, the strict normalizer, and the metadata parse from the old transport loop, so streamed and buffered turns are judged in one place. `into_body` becomes private and the `has_tool_calls` and `finish_reason` accessors go.
- `Timeout` is a doc-hidden newtype the transport wraps its timeout error in; `CompletionError::is_timeout` downcasts to it instead of to `reqwest::Error`, and `transport_source` applies it on both the `Http` and `BackendBodyRead` paths.
- `ChatClient` is the test host's trait for performing a `Chat` round under the run's limits. `RunHost` holds it as `Arc<dyn ChatClient>`, and both `MockGatewayClient` and the bench's `BenchClient` implement it.
- `MockGatewayClient` is a dev-only client for the engine's own suites and bench, included by `#[path]` so the bench target shares one copy. Its status check, capped error-body read, and stream read near-copy the harness client's tail because the engine crate may not name a harness crate.
- `pub mod model` widens the runtime door: the chat vocabulary is public, and the protocol seams (`build_request_body`, `read_body_capped`, `read_completion_stream`, `SseScanner`, `StreamAccumulator`, `Applied`, `escape_controls`, `ClientError`, `ClientTimeout`) ride through it under `#[doc(hidden)]`.
- `bridge.rs` re-exports the model client from `harness_models`; only the registry and activation re-exports remain interim.
- `spawn_tagged` replaces `tokio::spawn` in every relocated transport test and mock gateway, so the harness crate's spawn ban holds in its tests.
- `a_body_read_timeout_keeps_its_marker_under_backend_body_read` pins that a stalled error-body read boxed through `transport_source` still reports `is_timeout` alongside its status.
- `config_errors_preserve_their_causes_across_the_substrate_bridge` builds a `ClientError::Config` directly and checks it maps onto the runtime's `Error::Config`, since the runtime no longer reaches `GatewayEndpoint` or `SecretString`.
- `promptforge-model-client` drops `reqwest`, `url`, `axum`, and `tokio` from its manifest; `promptforge-api-runtime` adds `reqwest` and `bytes` under dev-dependencies only.
- `client.rs` in the runtime is deleted; every engine import of `crate::client` moves to `crate::model`, and the tokio driver, run context, and suites take a `MockGatewayClient` where they took a `GatewayClient`.

Design: new facade @ crates/harness/models/src/lib.rs boundary: pub
Design: replaces facade @ crates/promptforge-api-runtime/src/model.rs boundary: pub was: crates/promptforge-api-runtime/src/client.rs
Design: new surface-growth @ crates/promptforge-api-runtime/src/lib.rs::model boundary: pub
Design: new strategy @ crates/promptforge/model-client/src/client/read.rs::ChunkSource
Design: new strategy @ crates/promptforge-api-runtime/src/test_support/host.rs::ChatClient
Design: replaces pure-function @ crates/promptforge/model-client/src/client/request.rs::build_request_body deps: &CompletionOptions,&[Message],Option<&[ToolSchema]> was: crates/promptforge/model-client/src/client/transport.rs::build_request_body
Design: new newtype @ crates/promptforge/model-client/src/error.rs::Timeout
Design: new clone-block @ crates/promptforge-api-runtime/src/test_support/mock-gateway-client.rs::MockGatewayClient
Design: new shotgun-surgery @ crates/promptforge-api-runtime/src/client.rs
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The capability registry, per-run activation, and the tool and input-broker implementation traits leave the engine and its types crate for a harness crate that depends on no provider. The three web crates move into the harness container under new names and depend on that crate for the trait they implement, so the dependency runs from provider to contract and the engine names no provider at all. The engine keeps only the identity and descriptor vocabulary, and its own suites drive tool calls and input waits through stand-in traits that mirror the harness ones. Workshop's session launch reaches the relocated pieces through the harness door, which also builds the first-party registry for it in the interim.

- `harness-capabilities` now owns `Capability`, `Tool`, `InputBroker`, `CapabilityRegistry`, `activate`, `Activation`, and `ToolTable`, and its manifest names `promptforge-api-runtime` and `promptforge-api-types` but no provider crate. `harness-webfetch` and `harness-web-search` implement `Tool` from it, and `harness-web` packs both behind the `promptforge/web` capability.
- `harness-api` drops `promptforge-api-runtime` and gains `harness-capabilities` and `harness-web`; `bridge.rs` re-exports the registry, activation, `Tool`, `InputBroker`, and `Web`, and adds `first_party_registry`, which builds a registry holding `promptforge/web` from a gateway root and bearer token. Its `register` result is dropped on the grounds that a single registration cannot collide.
- `TestTool`, `TestBroker`, and `TestToolTable` in `test_support/tools.rs` copy the harness traits method for method so the engine's suites keep performing `ToolCall` and `UserInput` effects without an engine crate depending on a harness crate; `async-trait` becomes an optional dependency behind `test-support`.
- `ToolDescriptor::describe` and `describe_all` are gone; deriving a descriptor is now the `descriptor` default method on the `Tool` trait, not an inherent method on the data type.
- `spawn_tagged` from `harness-runner` replaces every `tokio::spawn` in the moved crates' mock servers, and each moved crate gains a `clippy.toml` banning `tokio::spawn` and `tokio::task::spawn_blocking`; `harness-web` declares the ban with `allow-invalid` because it has no tokio dependency. `the_ban_check_covers_the_eight_container_crates_and_the_door` pins the three new members.
- `DEFAULT_USER_AGENT` becomes `harness-webfetch/0.0`, so every fetch identifies itself under the new crate name.
- `prepare_activated` and `run_activated`, with the activation, conflict, and assembly suites, move from the engine's `prepare.rs` into `tests/it/` in the capabilities crate; the engine's prepare suite keeps slot filling against a caller-built `ToolCatalog` and `web_descriptor` stands in for the registry.
- `session_registry` in Workshop delegates to `first_party_registry` and returns `None` only when the web capability itself cannot be built.
- `CapabilityRegistry` no longer re-exports `Web`; `promptforge-api-runtime` has no dependency on `promptforge-web` or `promptforge-web-search`, and `promptforge-api-types` and `promptforge-lua` drop `async-trait`.
- `RunHost` loses `registry`, `requirements`, and `activated`, and `run_with_host` prepares and runs without activating anything.

Design: replaces strategy @ crates/harness/capabilities/src/tool.rs::Tool boundary: pub was: crates/promptforge-api-types/src/tools/registry.rs::Tool
Design: replaces registry @ crates/harness/capabilities/src/registry.rs::CapabilityRegistry boundary: pub was: crates/promptforge-api-runtime/src/capabilities.rs::CapabilityRegistry
Design: replaces registry @ crates/harness/capabilities/src/activation.rs::ToolTable boundary: pub was: crates/promptforge-api-runtime/src/execute/activation.rs::ToolTable
Design: extends facade @ crates/harness-api/src/bridge.rs boundary: pub
Design: new swallowed-exception @ crates/harness-api/src/bridge.rs::first_party_registry deps: &str,&str boundary: pub
Design: new parallel-abstraction @ crates/promptforge-api-runtime/src/test_support/tools.rs::TestTool instead-of: layer-violation: an engine crate naming harness_capabilities::Tool
Design: new parallel-abstraction @ crates/promptforge-api-runtime/src/test_support/tools.rs::TestBroker instead-of: layer-violation: an engine crate naming harness_capabilities::InputBroker
Deferred: the `## Invariants` marker on harness-web, harness-webfetch, and harness-web-search waits until the four moved files over the 500-line ceiling are split
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The engine crates no longer declare an async runtime, a tracing facade, or a random number generator. The one reporting handle that stamps each event with its task provenance moves down to the shared types crate so the parser and Lua layers report through it directly, and the callback trait they used to take is gone from production code. Parsing a prompt now returns its lifecycle and compilation events beside the outcome instead of streaming them to a caller-supplied sink. The awaitable cancellation token built on the async runtime moves to the harness door, leaving the engine with the polled flag tree alone. The recording observer the suites assert against survives as a test fixture fed from returned events, so the existing assertions hold without a callback in the engine.

- `Emitter` and `EventSink` become public in `promptforge-api-types`, with `Emitter::root` for reporting under task 0 before a run exists and `Emitter::emit` as the general form. `Emitter::report` takes a `Lifecycle` constructor constant from `event::lifecycle`, so the observation-tag match that built events is gone and each constant is the variant it names.
- `Prompt::parse` returns `(std::result::Result<Prompt, ParseError>, Vec<Event>)` and no longer takes an observer; every caller in the engine suites, `run_markdown_agent`, and the `contract` handler reads the pair.
- `harness_api::cancel::CancelHandle` is the tokio token with its `CURRENT` task-local scope, moved out of the types crate; `promptforge_api_types::cancel::CancelHandle` is now the sync parent-child tree that lived at `cancel::sync`, and `harness-api` gains `tokio` and `tokio-util` as dependencies.
- `metrics` is a new module in `promptforge-api-types` holding `CallMetrics`, `ToolCallEvent`, `Usage`, `LlamaTimings`, `VllmMetrics`, and `ClientTiming`, moved from the deleted `events.rs` along with `EventLog`, `RuntimeEvent`, and `RuntimeEventKind`, which have no replacement.
- `TestTool::call` and `TestBroker::user_input` are declared in the boxed-future form `FixtureFuture` with `'life0` and `'async_trait` lifetimes so the crate drops `async-trait` from its manifest; fixture impls written under the dev-only macro compile only while that expansion shape holds.
- `test_support::recording` holds `Observer`, `RecordingObserver`, `NullObserver`, `DebugCapture`, `DebugEvent`, and a payload-free `Observation` enum, with `forward` replaying returned events onto them; `promptforge-lua` and `promptforge-parser` each carry their own test-only recorder folding events to kind strings.
- `GuardNonce::fresh` is deleted; every nonce derives from a seed through `from_seed`. The module-level legacy helper `run_chunk` in `vm.rs` mints its nonce from `GuardNonce::from_seed(0)`, a fixed seed.
- `ScriptReport` loses `chain_id` and `depth`, and `prepare_dispatch` and `prepare_model_dispatch` take an `Emitter` in place of the observer and execution pair; a `ToolResult` event carries provenance instead, so a consumer that grouped by chain id reads `provenance.task`.
- `Event::Other` is removed from the serialized `Event` enum; `TaskResumed` and `TaskNote` fold to an `Other` observation only inside the test adapter.
- `fill_tool_bindings` no longer logs an exact slot whose capability contributed no such tool; the alias stays unbound with no record, and the prepare suite's assertion on that log line is dropped.
- `response_metadata` returns `diagnostics` lines for malformed metadata sections instead of tracing warnings, surfaced on `Completion` as `metadata_diagnostics`.
- `run_markdown_agent` pushes the parse-time events into the session sink before the run starts, whether or not the parse succeeds; the `contract` route discards them.
- `metadata_diagnostics` on `Completion` has no reader in the files this change touches.
- `store_observations_happen_before_later_lua_side_effects` now asserts report order against a `log` checkpoint between two writes; the prior recorder globbed the store at each report to prove the file existed when the report fired, and that proof is gone.

Design: new surface-growth @ crates/promptforge-api-types/src/emitter.rs::Emitter boundary: pub instead-of: speculative-abstraction: a renamed Observer trait with the emitter as its one impl
Design: replaces shared-mutable-state @ crates/promptforge-api-types/src/emitter.rs::EventSink boundary: pub was: crates/promptforge-api-runtime/src/execute/event_buffer.rs::EventSink
Design: dispatch-on-tag -> pure-function @ crates/promptforge-api-types/src/event-lifecycle.rs::Lifecycle
Design: new pure-function @ crates/promptforge/parser/src/lib.rs::Prompt::parse deps: str,str boundary: pub instead-of: event-hook: an observer callback streamed during the parse
Design: replaces shared-mutable-state @ crates/promptforge-api-types/src/cancel.rs::CancelHandle boundary: pub was: crates/promptforge-api-types/src/cancel-sync.rs::CancelHandle
Design: new surface-growth @ crates/harness-api/src/cancel.rs::CancelHandle boundary: pub
Design: replaces hidden-dependency @ crates/harness-api/src/cancel.rs::CURRENT was: crates/promptforge-api-types/src/cancel.rs::CURRENT
Design: replaces bag-of-state @ crates/promptforge-api-types/src/metrics.rs::LlamaTimings boundary: wire was: crates/promptforge-api-types/src/events.rs::LlamaTimings
Design: replaces parallel-abstraction @ crates/promptforge-api-runtime/src/test_support/recording-observation.rs::Observation was: crates/promptforge-api-types/src/observe.rs::Observation
Design: new parallel-abstraction @ crates/promptforge/lua/src/tests-recording.rs::Observation
Design: new surface-growth @ crates/promptforge/model-client/src/client/wire.rs::Completion boundary: pub
Deferred: Completion::metadata_diagnostics has no reader, so the model client's degraded-metadata diagnostics are collected and dropped
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The manifest guard and the retired-symbol scan now run over the engine crates on every test run of the build tooling crate and from the tidy check, instead of only against fixtures. The engine crate set is the two named root crates plus every crate discovered under the private container, so a missing root manifest is reported and a new container crate is covered the moment it lands. The one live engine function whose name was on the retired list is renamed and confined to test support so the scan passes on the tree.

- `engine_crates` names `promptforge-api-runtime` and `promptforge-api-types` explicitly, whether or not they exist, then walks `crates/promptforge/` through `collect_crates`, which treats a directory holding a `Cargo.toml` as a crate and descends into any other directory.
- `RETIRED_SEEDS` fixes the eight retired identifiers as a `pub(crate)` constant handed to `retired_symbols` with each whole engine crate directory, so `build.rs`, `benches/`, and `examples/` are scanned while `tests/` and test support are not.
- `engine_guard_violations` concatenates the manifest and retired-symbol findings into one list, and `all_violations` in tidy extends with it, so `cargo xtask tidy` and the tidy tests fail on either.
- `install_model_chat_shim` replaces `install_agent_chat_shim` in `coro.rs`, gated behind the `test-support` feature; `lib.rs` moves the re-export onto the gated line and `section_vm.rs` calls the new name under `#[cfg(test)]`.
- `engine_deps` and `retired_symbols` lose their `#[cfg_attr(not(test), allow(dead_code))]` allowances in `main.rs` now that tidy references them.
- `crates/build-xtask/src/engine_guards-tests.rs` runs both guards over the real workspace and asserts empty results, then covers with temp-dir fixtures: a reintroduced seed is reported with file and line, every seed is caught while seeds in a comment or a `cfg(test)` module are not, a `tokio` dependency in a container crate is reported against that crate, and a missing root crate produces one `unreadable manifest` finding rather than being skipped.
- `collect_crates` returns silently when the directory it is given cannot be read, so an absent or unreadable `crates/promptforge/` yields no container findings and no report.

Design: new facade @ crates/build-xtask/src/engine_guards.rs::engine_guard_violations deps: Path
Design: new swallowed-exception @ crates/build-xtask/src/engine_guards.rs::collect_crates deps: Path,Vec<PathBuf>
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Adds the equivalence test for the third checkpoint: for every offline fixture the observation suite pins, driving a prompt serially with no runtime, no observer, and no forwarding adapter must return an event stream whose section and kind sequence matches what the recording observer saw through the tokio driver, and both drivers must decide the run alike. The comparison reads each event's serialized kind tag directly, so it never passes through the adapter it is checking. The bench re-run for this checkpoint puts round overhead about fifteen percent above the Lua-loop landing; that growth is recorded as a known cost of the completed Run API rather than a loop regression.

- `STREAM_FIXTURES` names the four offline fixtures so a diverging comparison reports which one failed.
- `drive_serially` builds the run through `env.prepare` and drives it with `perform_locally`, panicking on any model round because the fixtures issue none.
- `event_trace` reads the `kind` tag off the serialized event rather than through the recording adapter, so the comparison does not depend on the seam under test.
- `observer_kind` lowercases and underscores the observer's detail text into the serialized `kind` spelling.
- `the_returned_event_stream_matches_the_former_observer_sequence` asserts the stream opens with `run_started`, that the two traces are equal, and that success text or failure text agrees across both drivers.
- `crates/promptforge-api-runtime/src/execute/tests/observations.rs` is the only source file touched; no product code changes.

Design: new pure-function @ crates/promptforge-api-runtime/src/execute/tests/observations.rs::event_trace deps: Event
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/tests/observations.rs::observer_kind deps: str
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Give the harness its production host for an engine run: a loop that steps the run, records each step's events before issuing its effects, starts one tagged task per effect through a performer trait for that effect's kind, and resumes the run with each answer once the answer is recorded. Cancellation aborts and joins every outstanding performer, waits for a blocking store operation to finish so the claims it holds release, and answers each abandoned effect as dropped, so every effect record in the log has exactly one answer record. A guard on every performer task posts the drop itself when the task ends without answering, so a panicking performer cannot leave the run waiting forever. Answers gain a log projection that keeps a model round's reply, finish reason, and requested tool names but not its request and response bodies, and renders every failure to its display text. The run log's task column becomes text, because task identifiers are now hierarchical dot-separated paths rather than integers.

- `Performers`: six `Arc<dyn ...>` fields, one per effect kind, all public with no constructor; the loop moves a clone of the one it needs into each spawned task. Tests build it by assigning fields over an `Unused` bundle whose every method is `unreachable!`.
- `SharedLog`: `Arc<tokio::sync::Mutex<RunLog>>`, the loop as writer and a task-events reader sharing one log; every append is awaited under the lock.
- `drive_run`: takes a `RunId` the caller has already begun and closes that row itself with `end_run`; returns `Result<RunOutcome, DriveError>`, where `DriveError::Log` ends the drive with the row left open and `DriveError::Stalled` reports a pending run with nothing out.
- `Answering`: a drop guard over the answer channel; `post` disarms it, and any other end of the task sends `EffectAnswer::Dropped`. `deliver` discards a post for an id no longer in `outstanding`, so the run never sees two answers for one effect.
- `perform_store`: takes the `Arc<Access>` by value and drops it before returning, so the claims release before the answer posts, on a panic too.
- `AnswerRecord`: the log form of `EffectAnswer`, one variant per kind with `Result<_, String>` for the fallible ones; `ChatAnswerRecord` keeps `model`, `finish_reason`, `reply`, and the `tool_calls` names only. `EffectAnswer::record` is the projection, and the tests assert the serialized text contains no body fields.
- `Tag`: `spawn_tagged` and `spawn_blocking_tagged` take `(EffectId, Provenance)` in place of any `Display` value; the span carries `effect`, `task`, and `seq` fields.
- `execute.rs`: re-exports `StoreOp` and `StoreOutcome` from `promptforge_lua` and `StoreError` from `promptforge_store`, so a store performer is written against the runtime door; `EffectId` gains `get` and `Display`.
- `Driver`: in `drive`, on `Step::Pending`, a cancel flag already set is handed to `run.cancel()` before the events are committed; when the run is decided or cancelled, each newly issued effect is recorded and answered `Dropped` at once and every outstanding performer is dropped, so the next step reaches `Done`.
- `await_answer`: `select!` with `biased` prefers the answer channel over the cancel flag, and every answer already queued is applied before the loop steps again.
- `drop_outstanding`: aborts and joins performers in ascending `EffectId` order; a join error other than cancellation is logged with `tracing::error!` and the effect is dropped regardless; the channel is drained afterwards so stale posts never reach the run.
- `Drop for Driver`: aborts every outstanding performer when the driver is dropped mid-run, instead of detaching the tasks.
- `schema.rs`: `records.task_id` becomes `TEXT NOT NULL`; `Record::task_id` is a `String`, `RecordFilter::task` an `Option<String>`, `events_for_task` takes `&str`, and `RecordFilter` loses `Copy`. The harness writes `provenance.task.to_string()`.
- `tests/it/effect_loop.rs`: record order is events, then each effect followed by its answer, then the closing event; a cancel writes one `Dropped` per outstanding effect and the parked timer's future is torn down before the run ends; a panicking input performer ends the run `Cancelled` with one `Dropped` answer inside a five-second timeout; a refused log write returns `LogError::RunEnded` unchanged and aborts the parked timer; a closed run refuses its first write before any performer starts; a 300ms blocking store finishes before `Done` and its late outcome is discarded in favor of the drop.
- `schema.rs`: the DDL is `CREATE TABLE IF NOT EXISTS`, so an existing log file keeps its `INTEGER` column; no migration or dual read is written.
- `ChatPerformer`, `ToolPerformer`, `TaskEventsPerformer`: no implementation exists outside the test `Unused` fixture.
- `ChatAnswerRecord`: a `CompletionResult` variant this crate does not know records as neither reply nor tool calls, with no mark that it was unrecognized.

Design: new strategy @ crates/harness/runner/src/performers.rs boundary: pub
Design: new speculative-abstraction @ crates/harness/runner/src/performers.rs::ChatPerformer boundary: pub
Design: new speculative-abstraction @ crates/harness/runner/src/performers.rs::ToolPerformer boundary: pub
Design: new speculative-abstraction @ crates/harness/runner/src/performers.rs::TaskEventsPerformer boundary: pub
Design: new bag-of-state @ crates/harness/runner/src/performers.rs::Performers boundary: pub
Design: new shared-mutable-state @ crates/harness/runner/src/effect_loop.rs::SharedLog boundary: pub
Design: new temporal-coupling @ crates/harness/runner/src/effect_loop.rs::drive_run deps: CancelHandle,FnMut(Event),Performers,Run,RunId,SharedLog boundary: pub
Design: removes stringly-typed @ crates/harness/runner/src/spawn.rs
Design: new schema-change @ crates/harness/log/src/schema.rs boundary: persisted
Design: new surface-growth @ crates/promptforge-api-runtime/src/execute.rs boundary: pub
Design: new oversized-unit @ crates/harness/runner/tests/it/effect_loop.rs::records_are_events_then_effects_then_answers_per_step
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The harness can now answer four of the engine's effect kinds. The model crate gains a chat performer that runs one round through the gateway client and streams the round's live deltas on a channel separate from the run's event sink, so a session can render a reply forming without the run log seeing a fragment. The runner supplies the timer, store, and task-events performers itself, since each is machinery it already holds: tokio's timer wheel, the engine's store operation over the effect's own access, and the run log the loop writes. The engine exposes its store operation as one function so a host answers a store effect the same way the engine's own drivers do. Test mock servers across the harness crates now borrow a real effect tag from a throwaway run instead of passing string names the spawn wrapper no longer accepts.

- `GatewayChatPerformer` holds a `GatewayClient` and a `DeltaSink`, both supplied at construction; the client arrives with the run's request limits already applied because a chat effect carries none of its own.
- `DeltaSink` is an unbounded sender so a slow consumer never stalls a model round; a closed receiver drops deltas and the round still completes.
- `LogTaskEvents` takes a `SharedLog` and a `RunId` at construction and clones the shared log handle for each read, adding a second holder beside the effect loop.
- `perform_store_op` is a new public function on the engine that runs `run_store_op` over a `Store` built from the given access; the `test-support` gates on the `run_store_op` and `Store` re-exports are removed so production builds compile them.
- `harness-runner` becomes a regular dependency of the models crate for the performer trait, alongside tokio's `sync` feature for the sender; the runner does not depend on the models crate.
- `test_support::mock_tag` lives behind a new `test-support` cargo feature on the runner; three crates enable it in their dev-dependencies.
- `chat` sends a delta only when `stream` is true; a nested infer's fragments drop at the performer. The `_binding` parameter is unused, the round runs under the effect's frozen options.
- `TokioTimer` converts seconds with `Duration::try_from_secs_f64` and falls back to `Duration::ZERO`, so an out-of-range value fires at once rather than never.
- `LogTaskEvents` reads the whole task from `events_for_task` and narrows by provenance sequence itself, because the log's own `last` keeps the final n records rather than those after a sequence number.
- `LogTaskEvents` logs a refused read or an unparseable stored payload through `tracing::error!` and answers with what it recovered; the answer shape has no error to carry.
- `mock_tag` replaces eleven string-literal tags across six test files with a `Tag` borrowed from a fresh run parked on an input wait.
- `performers.rs` integration tests measure the timer from effect row to answer row in the log, count two `Dropped` answers after a cancel, exercise write, append, read, exists, and a missing-path failure through `VfsStore`, and check that `last` drops exactly the events already seen.
- `performer-tests.rs` asserts wire-order delivery of three fragments, no deltas when `stream` is false, and completion when the receiver is dropped.

Design: new constructor-injection @ crates/harness/models/src/performer.rs::GatewayChatPerformer boundary: pub
Design: new flag-parameter @ crates/harness/models/src/performer.rs::GatewayChatPerformer::chat
Design: new constructor-injection @ crates/harness/runner/src/performers-host.rs::LogTaskEvents
Design: extends shared-mutable-state @ crates/harness/runner/src/performers-host.rs::LogTaskEvents
Design: new swallowed-exception @ crates/harness/runner/src/performers-host.rs::LogTaskEvents::events
Design: new swallowed-exception @ crates/harness/runner/src/performers-host.rs::TokioTimer::sleep
Design: new facade @ crates/promptforge-api-runtime/src/execute.rs::perform_store_op deps: Access,StoreOp boundary: pub
Design: new surface-growth @ crates/promptforge-api-runtime/src/execute.rs::perform_store_op boundary: pub
Design: new shotgun-surgery @ crates/harness/runner/src/test_support.rs::mock_tag
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The runner now takes a prompt file on disk to a run the effect loop can drive. It draws the seed from the OS random source and the start from the wall clock, opens the run's row in the log with both before anything else happens, parses the prompt and records the parse events, activates the declared capabilities against the caller's registry, and refuses a prompt the environment cannot satisfy with the engine's own notice. A refusal or a parse failure closes the row as failed, so the log explains a run the loop never saw. The tool performer resolves each tool call in the table that activation assembled.

- `prepare_run` draws host inputs before parsing and writes them to the row first, so the record holds the seed and start however the run ends. The seed comes from `rand::random` and the start from `SystemTime::now`, saturating to the epoch on an unrepresentable clock.
- `Services` bundles the caller's registry, VFS, cancel flag, log, chat and input performers, session identity, model, and UI snapshot as ten public fields with no constructor; `prepare_run` destructures it at entry.
- `Prepared` returns the run, its open `RunId`, the seed and start as written, the six performers, and the parse events already recorded in the log.
- `failed_outcome` is the one derivation of a failed log outcome from a `RunError`, shared by the loop's `outcome_of` and preparation's refusal path so both write the same `error_kind` and `error_message`.
- `ActivatedTools` implements `ToolPerformer` over a `ToolTable`; an id outside the table is logged at error level and answered as the call's own `ToolErrorKind::Other` failure rather than stalling the run.
- `tokio` gains the `fs` feature for the prompt read; `harness-capabilities`, `rand`, and `sha2` become dependencies; `async-trait` and `tempfile` are dev-dependencies for the fixture tool and prompt files.
- `PrepareError::Read` writes no row, since there is no prompt to record; `Parse` and `Refused` carry the closed row's `RunId`; `Log` propagates a log write failure and leaves the run unprepared.
- `prompt_hash` records the prompt text as `sha256:` plus lowercase hex in `runs.prompt_hash`, so two preparations of the same text hash the same.
- `crates/harness/runner/tests/it/prepare.rs` covers the refusal notice and closed row, the parse failure under the `Parse` kind, distinct seeds and rows across two preparations with equal prompt hashes, a prepared run driven to completion, the echo tool reached through a `ToolCall` effect, and the performer's refusal of an unknown id.
- `prepare_run` has no production caller in this change; only the tests invoke it.

Design: new oversized-unit @ crates/harness/runner/src/prepare.rs::prepare_run deps: Path,Services,str
Design: new bag-of-state @ crates/harness/runner/src/prepare.rs::Services
Design: new bag-of-state @ crates/harness/runner/src/prepare.rs::Prepared
Design: new pure-function @ crates/harness/runner/src/effect_loop.rs::failed_outcome deps: RunError
Design: new pure-function @ crates/harness/runner/src/prepare.rs::event_record deps: Event
Design: new pure-function @ crates/harness/runner/src/prepare.rs::prompt_hash deps: str
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Drive a fixture prompt through run preparation and the effect loop with the real chat performer against the mock gateway and an in-memory log, and assert the whole record stream: the row's outcome, one answer per effect, the effect and answer payloads, the task columns, and that every logged event reached the sink. Writing that suite exposed a collision: the parse stamps its events under the main task from zero, and the run's main-task counter also started at zero, so a task and sequence pair was not unique across a run's records. The run context now carries where the root task's sequence starts, and preparation passes the parse-event count so run records continue the sequence rather than restarting it.

- `EventSink::seeded` builds a buffer whose root task counts from a given start; every other task still counts from zero.
- `provenance_start` is a builder setting on the public `RunContext`, defaulting to 0, and the field is rendered in the context's `Debug` output.
- `RunState` constructs the sink with `EventSink::seeded(ctx.provenance_start)` in place of `EventSink::default()`.
- `prepare_run` seeds the context with `parse_events.len()`, saturating at `u32::MAX` if the count does not fit.
- `end_to_end.rs` asserts strict per-task ordering and uniqueness of `(task_id, task_seq)` over the whole stream, parse events included, and that the run's first main-task record has sequence equal to the parse-event count.
- `a_seeded_sink_continues_the_root_sequence_and_leaves_other_tasks_at_zero` and `the_root_task_sequence_starts_where_the_context_says` pin the seed at the sink and at the run state.
- `NoInput` is the test's input performer; the fixture issues no input wait, so reaching it is `unreachable!`.
- `harness-log`, `shared-vfs`, and `tempfile` enter `harness-models` as dev-dependencies only.

Design: new surface-growth @ crates/promptforge-api-types/src/emitter.rs::EventSink::seeded deps: u32 boundary: pub
Design: new surface-growth @ crates/promptforge-api-runtime/src/execute/config.rs::RunContext::provenance_start boundary: pub
Design: extends ambient-context @ crates/promptforge-api-runtime/src/execute/config.rs::RunContext
Design: new oversized-unit @ crates/harness/models/tests/it/end_to_end.rs::a_prepared_run_drives_end_to_end_and_records_the_whole_stream
Repairs: unique (task_id, task_seq) across a run's records @ crates/promptforge-api-runtime/src/execute/context.rs::RunState::new - the root task's counter restarted at zero and collided with the parse events' sequence
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The supervisor's pure state reducer, the synchronous run lifecycle, and agent discovery with the embedded built-in chat leave Workshop for the harness's sessions crate, and Workshop reaches them through the interim bridge on the public harness crate. The awaitable cancel token moves from that public crate into the runner, where the layout already places cancellation, because the sessions crate arms a token per run and could not depend on the public crate without a dependency cycle; the public crate re-exports the token so clients see no change. The moved code carries its tests with it and changes no behavior.

- `harness_runner::cancel` now defines `CancelHandle`, the task-local `CURRENT`, and the `scope`, `maybe_scope`, `current`, `wait_cancelled`, and `is_cancelled` free functions; `harness_api::cancel` is a bare re-export of the same six public items, so no client path changes.
- `harness_sessions::{discovery, lifecycle, transition}` are re-exported by `harness_api::bridge` beside the model client and capability traits; `workshop-sessions` imports them from the bridge in `agents.rs`, `agents/session.rs`, `agents/supervisor.rs`, and `agents/run-tests.rs`.
- `crates/harness/sessions/Cargo.toml` depends on `harness-runner` and on `tokio` with only the `sync` feature, plus `tempfile` for tests; `crates/harness-api/Cargo.toml` drops `tokio` and `tokio-util` entirely, and `crates/harness/runner/Cargo.toml` picks up the `sync` and `rt` features and `tokio-util`.
- `pub(in crate::agents)` items in `transition.rs` and `pub(super)` items in `lifecycle.rs` widen to `pub`, with `#[must_use]` added on `SupervisorState::new`, `transition`, `RunLifecycle::new`, and `RunLifecycle::arm`, and `Debug` derived on `RunLifecycle` and `RunState`.
- `AgentSource` moves from `agents/session.rs` into `discovery.rs` beside `discover_agents`, `agent_source`, and `BUILTIN_CHAT_SOURCE`; `agents.rs` re-exports it `pub(crate)` from the bridge so the rest of Workshop keeps the name.
- `discovery-tests.rs` carries the three discovery tests out of `agents/tests.rs` unchanged, seeding `tempfile` directories inside `harness-sessions`; `cancel-tests.rs` moves with its module and keeps its spawn allowance.
- `clippy.toml` in `crates/harness/sessions` drops `allow-invalid` from the two spawn bans now that the crate depends on `tokio`, so the banned paths resolve and the check is enforced rather than merely declared.
- `workshop_registry` is imported by none of the moved files; `agents/session.rs`, which still imports `Push` and `Registry`, stays in Workshop.
- `crates/workspace-hack/Cargo.toml` gains `tokio-util` rows only because the runner now depends on it; no harness source changes for it.

Design: replaces newtype @ crates/harness/runner/src/cancel.rs::CancelHandle boundary: pub instead-of: cyclic-dependency: harness-sessions takes the token from the runner rather than from harness-api was: crates/harness-api/src/cancel.rs::CancelHandle
Design: replaces hidden-dependency @ crates/harness/runner/src/cancel.rs::CURRENT was: crates/harness-api/src/cancel.rs::CURRENT
Design: new facade @ crates/harness-api/src/cancel.rs boundary: pub
Design: extends facade @ crates/harness-api/src/bridge.rs boundary: pub
Design: replaces value-object @ crates/harness/sessions/src/discovery.rs::AgentSource boundary: pub was: crates/workshop/sessions/src/agents/session.rs::AgentSource
Design: replaces constructor-injection @ crates/harness/sessions/src/lifecycle.rs::RunLifecycle boundary: pub was: crates/workshop/sessions/src/agents/lifecycle.rs::RunLifecycle
Design: replaces pure-function @ crates/harness/sessions/src/transition.rs::transition deps: SupervisorEvent,SupervisorState boundary: pub was: crates/workshop/sessions/src/agents/supervisor/transition.rs::transition
Design: replaces newtype @ crates/harness/sessions/src/transition.rs::RunId boundary: pub was: crates/workshop/sessions/src/agents/supervisor/transition.rs::RunId
Design: replaces value-object @ crates/harness/sessions/src/transition.rs::SupervisorState boundary: pub was: crates/workshop/sessions/src/agents/supervisor/transition.rs::SupervisorState
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
A harness run now has three lifecycle states: alive, closing once cancel or close is requested, and closed once the engine reports done. A closed run stays closed; a late request has nothing left to interrupt. When an interrupt and a genuine terminal outcome cross, one pure rule settles which arrived first: a completed or failed outcome seen before a late cancel or timeout stays the run's terminal and the interrupt renders nothing, otherwise the interrupt renders exactly one synthetic terminal frame. The frame's wording lives in a single place and the frame type can only be built by that rule, so holding a frame means the race was already decided.

- `SessionState` has three variants and two pure transitions, `interrupted` and `done`. Both matches are wildcard-free, so a new variant is a compile error.
- `SyntheticTerminal` wraps one private `Interrupt` behind a private `new`; `effective_interrupt` is structurally the only production path to a frame, and `message` is the only place an interrupt's terminal text lives.
- `effective_interrupt` takes the interrupt and whether a genuine terminal was already observed and returns `Superseded` or `Terminal`. It performs no I/O and reads no state.
- `is_genuine` on `RunCompletion` treats `Completed` and `Failed` as genuine and `Interrupted` as not, so the engine's echo of a session-requested interrupt never suppresses a later interrupt's frame.
- `frames` folds an arrival order through the rule in the tests; table tests cover terminal-first and interrupt-first orderings, and `EVERY_INTERRUPT` with `fixture_index` fails to compile when a variant is missing from the fixture.
- `transition.rs` gains only the module declaration and a `pub use` of the new items; within the touched files nothing outside the tests calls `effective_interrupt` or holds a `SessionState`.

Design: new value-object @ crates/harness/sessions/src/transition-interrupt.rs::SessionState
Design: new value-object @ crates/harness/sessions/src/transition-interrupt.rs::Interrupt
Design: new newtype @ crates/harness/sessions/src/transition-interrupt.rs::SyntheticTerminal
Design: new value-object @ crates/harness/sessions/src/transition-interrupt.rs::EffectiveInterrupt
Design: new pure-function @ crates/harness/sessions/src/transition-interrupt.rs::effective_interrupt deps: Interrupt,bool
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The user-input wait registry and the session's input broker move from Workshop into the harness session crate, and the broker now implements the harness runner's input performer trait directly, so the async broker trait in the capability layer is deleted. Wait announcements become harness-owned data rather than Workshop protocol frames; the socket that owns a client renders them into the wire shape at one point. Workshop names all of this through the harness door and keeps a re-export so its server's import path is unchanged. A new test drives a real run to its input effect and confirms the operator's text becomes the section's return value.

- `InputPerformer` replaces `InputBroker`: `SessionInputBroker` implements `wait(&self, _execution: String, _section: String) -> BoxFuture<Result<InputOutcome, InputError>>` from `harness_runner::performers`, and the `async_trait` trait `InputBroker` in `harness-capabilities` is deleted with its `mod input;` and re-export. `workshop-sessions` drops its `async-trait` dependency and its three test brokers now return `Box::pin` futures.
- `WaitFrame` is a new harness-side enum with `Required { token }` and `Cancelled { token }`, deriving `Debug, Clone, PartialEq, Eq`. It replaces `workshop_protocol::InputFrame` in `WaitRegistry::resend_unresolved`, `WaitGuard`, and `SessionInputBroker`; the `use workshop_protocol::{InputFrame, InputResponse};` import is gone from the moved module.
- `input_frame` in `crates/workshop/sessions/src/agents/socket.rs` is the one match that turns a `WaitFrame` into an `InputFrame` before `send_frame`; `input_rx` now receives `WaitFrame`.
- `harness_api::bridge` re-exports `harness_sessions::{discovery, input, lifecycle, transition}` and `harness_runner::performers::{BoxFuture, InputPerformer}`, and no longer re-exports `InputBroker`. `crates/workshop/sessions/src/lib.rs` re-exports `SessionInputBroker, WaitError, WaitRegistry` from the bridge in place of its deleted `pub mod input;`, with no deprecation mark.
- `WaitError` loses `#[non_exhaustive]`; the doc comment states a new variant is meant to be a compile error at every external match.
- `complete_input_response` is now `pub` and takes `token: &str, text: String` instead of an `InputResponse`, so the harness function names no protocol type; `AgentSession::accept_input` passes `&response.token, response.text`.
- `crates/harness/sessions/AGENTS.md` is new and carries the rule that the input broker backs only the script-side `user_input()` and no `user_input` tool is advertised unless a prompt adds it; that line leaves `crates/promptforge-api-runtime/AGENTS.md`.
- `SessionInputBroker` clones `registry` and `frames` into a `'static` boxed future in `wait`; `WaitGuard` still removes the wait and pushes `WaitFrame::Cancelled` on drop unless disarmed by a delivered value, and a wait cancelled out of the registry resolves as `InputError::message("the user-input wait was cancelled")`.
- `a_user_input_effect_is_answered_when_the_registry_receives_the_text` parses a `return user_input()` prompt, steps `Run` to `Effect::UserInput`, spawns `broker.wait(execution, section)`, completes the registry with the token, resumes with `EffectAnswer::UserInput(answer)`, and asserts `RunResult::Ok("typed by the operator")` and an empty `registry.unresolved()`. `complete_input_response_runs_the_seam_before_the_wait_resumes` asserts the seam runs before the receiver sees the text and that a consumed token yields `WaitError::UnknownToken`.
- `spawn_tagged` with `mock_tag()` replaces `tokio::spawn` in the relocated broker tests; `harness-runner` with `test-support` is a dev-dependency for it.
- `_execution` and `_section` are accepted by `SessionInputBroker::wait` and never read.
- `WaitGuard` and the broker's `Required` push both discard the send error with `let _ =` when no receiver is attached; the comments state the reconnect resend repairs the client.

Design: removes parallel-abstraction @ crates/harness/capabilities/src/input.rs::InputBroker
Design: new parallel-abstraction @ crates/harness/sessions/src/input.rs::WaitFrame instead-of: layer-violation: harness-sessions naming workshop_protocol::InputFrame
Design: new value-object @ crates/harness/sessions/src/input.rs::WaitFrame
Design: new surface-growth @ crates/harness/sessions/src/input.rs::WaitError boundary: pub
Design: extends facade @ crates/harness-api/src/bridge.rs boundary: pub
Design: new pure-function @ crates/workshop/sessions/src/agents/socket.rs::input_frame deps: WaitFrame
Design: new shim @ crates/workshop/sessions/src/lib.rs
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
The harness handle and the live session it serves now live in the sessions crate, and the public door is reduced to re-exports of them. A client pushes the gateway, its chat catalog, and a host snapshot as data; the capability registry and model client are rebuilt only when the gateway generation changes, and every session watches for that. A launch resolves the discovered agent, prepares and drives the program on the effect loop, and records it in the run log, which is also the transcript a reconnecting client reads, index for index with the live broadcast. A turn-cancel or a catalog whose models changed relaunches the program as a new run, and a close drains the run so its outstanding effects are answered as dropped before the session reports closed.

- `crates/harness-api/src/harness.rs` and `crates/harness-api/src/session.rs` are now `pub use` lines over `harness_sessions::environment`, `runtime`, `protocol`, `session`, `input`, and `transition`; `bridge::first_party_registry` is a re-export too, and the crate drops `harness-web`, `promptforge-api-types`, `serde`, and `serde_json`.
- `Bindings` holds the gateway resources, catalog, and host snapshot behind `RwLock`s with a `watch` per generation, shared as `Arc<Bindings>` between `Harness` and every `Supervisor`. `set_gateway` builds under the write lock and sends the watch there too, so the stored resources and the watched generation come from one caller; a repeated generation is a no-op and returns `false`.
- `SessionCore` is the state that outlives a connection: id, retained source, lifecycle, wait registry, four broadcasts, round counter, next index, and run ids. `Session` handles clone an `Arc` to it, and `SessionTable` maps id to core so a finished supervisor removes itself with `forget`.
- `Supervisor` is one task per session, spawned through the new `spawn_session` whose span carries the session id and no effect tag. It feeds `transition`, executes the selected effect, and drains raw deltas ahead of the run future in a biased `select!` so a round's chunks are broadcast before the event that supersedes them.
- `prepare_source` is split out of `prepare_run` so a session's retained source runs without a file read; `run_once` calls it and, when preparation fails after opening a row, replays that row's events from the log into the sink through `replay_recorded`.
- `commit_effect`, `commit_answer`, and `append` on `Driver` take `&mut self` so the boxed drive future is `Send` and a supervisor can hold it in its own task.
- `lifecycle.rs` arms `promptforge_api_types::cancel::CancelHandle`, the engine's own flag; `bridge_cancel` and its bridging task leave `run.rs` in `workshop-sessions`, whose sites take the engine flag directly.
- `launch` on `Harness` refuses a name absent from `discover()` before touching the filesystem, refuses with `LaunchError::GatewayUnusable` when no gateway resources hold a client, subscribes to the gateway watch before reading the snapshot, and opens the run log on first use as `runs.db` under `state_dir`.
- `reply_stamp` is the one rule stamping model-round events with the settled round count, applied by `observe` on `SessionCore` live and by `Session::transcript` on read; `transcript` walks every run id in launch order, so indices continue across a relaunch.
- `gateway_client` and `GatewayResources::build` log an unusable key, URL, or web root and leave that resource `None`; the launch then reports `GatewayUnusable` and a relaunch reports a fixed message through `failed_relaunch`, not the underlying error.
- `current_model` returns `Ok(None)` after a warning for a selected id that is not representable, fetches the catalog only when a selection or a catalog entry exists, and reports a fetch failure or an absent selection as `CurrentModelError`.
- `close` on `Harness` removes the session from the table at once and requests close; the supervisor's `drain` awaits the cancelled run, so the state reaches `Closed` only after its outstanding effects were answered `Dropped`. `closing_answers_outstanding_effects_dropped_before_closed` checks the log holds one effect and one `Dropped` answer after it.
- `tests/it/session.rs` drives a real agent on an in-process harness with an unreachable gateway: the transcript equals the live stream and the log records, a turn-cancel makes a second run with continuing indices, a catalog with different models retires the run, and a parked wait resumes on `send_input`.
- `run_once` builds each run's `Services` with an empty `VfsRef` and passes `workspace_roots` only through `ui()`; no root reaches the store.
- `CatalogBinding` keeps `models` as `Vec<serde_json::Value>`; `current_model` reads the first entry's `"id"` by key and `classify` compares the lists for equality.

Design: new facade @ crates/harness-api/src/harness.rs
Design: new facade @ crates/harness-api/src/session.rs
Design: extends facade @ crates/harness-api/src/bridge.rs
Design: new surface-growth @ crates/harness-api/src/lib.rs boundary: pub
Design: replaces facade @ crates/harness/sessions/src/runtime.rs::Harness was: crates/harness-api/src/harness.rs::Harness
Design: replaces facade @ crates/harness/sessions/src/session.rs::Session was: crates/harness-api/src/session.rs::Session
Design: replaces newtype @ crates/harness/sessions/src/protocol.rs::SessionId boundary: wire was: crates/harness-api/src/session.rs::SessionId
Design: new shared-mutable-state @ crates/harness/sessions/src/environment.rs::Bindings
Design: new shared-mutable-state @ crates/harness/sessions/src/runtime.rs::SessionTable
Design: new shared-mutable-state @ crates/harness/sessions/src/session.rs::SessionCore
Design: new message-passing @ crates/harness/sessions/src/session/supervisor.rs::Supervisor
Design: new parameter-object @ crates/harness/sessions/src/session.rs::SessionSeed
Design: new parameter-object @ crates/harness/sessions/src/session/run.rs::RunInputs
Design: new parameter-object @ crates/harness/sessions/src/session/supervisor.rs::SupervisorParts
Design: new pure-function @ crates/harness/sessions/src/session/run.rs::opened_run deps: PrepareError
Design: new pure-function @ crates/harness/sessions/src/session/supervisor.rs::classify deps: Option<&CatalogBinding>,Option<&[serde_json::Value]>,u64
Design: new swallowed-exception @ crates/harness/sessions/src/session/run.rs::replay_recorded deps: LogRunId,SessionCore
Design: new swallowed-exception @ crates/harness/sessions/src/environment.rs::current_model deps: GatewayBinding,HostSnapshot,Option<&CatalogBinding>
Design: new stringly-typed @ crates/harness/sessions/src/environment.rs::CatalogBinding
Design: new shared-parameter-cluster @ crates/harness/runner/src/prepare.rs::prepare_source deps: Path,Services,str
Design: removes shim @ crates/workshop/sessions/src/agents/run.rs::bridge_cancel
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Workshop's agent sessions now run in the harness. The server constructs the harness at boot, registers it beside every other subsystem, and pushes the gateway binding, the chat catalog, and the host snapshot into it as plain data, both at startup and again on every replacement. The socket, workbench, and catalog-relay code that stayed on the Workshop side moves into the server crate, the interim bridge module and the standalone sessions crate are deleted, and the shell derives its own status-bar reporting from the session's events, deltas, and error reports rather than handing the harness a channel. A workspace-wide guard now fails the build when any non-dev dependency table enables an engine crate's test-only feature, and a session whose catalog holds no chat-capable model waits instead of launching.

- `AgentSessions` holds only the subsystem registry and the reconnect backoff; the `Harness`, `MenuHandles`, `GatewayHandles`, and `WorkspaceRoots` are fetched from the registry at the point of use instead of arriving through the constructor.
- `harness_for` builds the `Harness` with `state_dir/harness` as its state directory and pushes the shell's current bindings before the composition root registers it; `register` now registers the `Harness` and the `AgentSessions` handle as two state registrations.
- `register_tasks` registers a background task that runs `bindings::forward`, a loop that re-pushes all three bindings whenever the gateway watch, the chat-generation watch, or the menu broadcast fires; tests that bind the router directly start it through `spawn_bindings_forwarder`.
- `status::spawn_relay` spawns one relay task per launched session over the session's broadcast receivers; `failure_label` classifies an error report by whether its text starts with `Model turn failed` or `Tool call failed`, else labels it `Agent failed`.
- `test_support_leak_violations` scans `[dependencies]`, `[build-dependencies]`, their `[target.<cfg>]` forms, the root `[workspace.dependencies]`, and `[features]` values of the form `<dep>/test-support` or `<dep>?/test-support`, resolving `package` renames; an engine crate's own `test-support` feature forwarding to a sibling is exempt. `engine_guard_violations` includes its findings, and `EXEMPTING_FEATURE` becomes the guarded feature name.
- `workshop-server` gains `harness-api` and `promptforge-api-types` as production dependencies and loses `workshop-sessions`; `promptforge-api-runtime` leaves its dev-dependencies.
- `handle_frame` calls `sync_bindings` before `Session::cancel` on a cancel frame so the relaunch reads the current host snapshot; `launch` pushes the bindings before calling `Harness::launch` for the same reason.
- `Attached` replaces `rounds_seen` with `framed`: the wire index of a durable frame is the count of transcript entries with a wire shape sent so far, while `cursor` tracks the transcript index. `on_event_wake` frames a live entry directly when its index is at or below the cursor and otherwise drains the transcript from the cursor.
- `drain_events` reads the transcript through `session.transcript(cursor).await`; a read error is logged at warn and the socket continues from the same cursor. `frame_entry` advances the cursor past an entry whose stored payload does not deserialize as an `Event`, without a frame or a log line.
- `classify` returns `CatalogDisposition::Unavailable` for a bound catalog whose `models` list is empty, so `set_catalog` with an empty list holds the session until a usable generation arrives; `an_empty_catalog_holds_the_session_until_a_chat_model_arrives` pins it and the shared `harness` fixture now binds an id-less chat entry.
- `catalog_binding` pushes the current chat generation with an empty `models` list when the menu bus has no chat-capable catalog.
- `LaunchRefusal` shrinks to `Unavailable` (no harness registered) and `Refused(LaunchError)`; the agent-name, gateway, and source-read refusals are the harness's own.
- `SURVIVED_TURN_LABELS` matches on report text; the error channel stays `broadcast::Receiver<String>`, so the shell and the harness agree on failure kinds only by prefix.
- `pub use harness_api::WaitError` keeps one import path; `WorkshopObserver`, `SessionInputBroker`, `WaitRegistry`, and `SessionHost` are no longer exported from `workshop-server`.
- `crates/harness-api/src/bridge.rs` is deleted along with the `harness-capabilities` and `harness-models` dependencies of `harness-api`; `crates/workshop/sessions/` is deleted in full, and `tidy.rs` drops `workshop-sessions` from the feature tier.

Design: constructor-injection -> service-locator @ crates/workshop/server/src/agents.rs::AgentSessions boundary: pub instead-of: constructor-injection: the shell forbids passing one subsystem's handles into another's constructor
Design: new service-locator @ crates/workshop/server/src/agents/bindings.rs::push_bindings deps: Harness,Registry
Design: new event-hook @ crates/workshop/server/src/agents/state.rs::register_tasks deps: Registry
Design: new temporal-coupling @ crates/workshop/server/src/agents/socket.rs::handle_frame deps: Option,SessionsState,Subscriptions,WebSocket,str
Design: new swallowed-exception @ crates/workshop/server/src/agents/socket.rs::drain_events deps: Attached,WebSocket
Design: new swallowed-exception @ crates/workshop/server/src/agents/socket.rs::frame_entry deps: Attached,SessionEvent,WebSocket
Design: new pure-function @ crates/workshop/server/src/agents/socket.rs::delta_frame deps: Delta
Design: new pure-function @ crates/workshop/server/src/agents/status.rs::failure_label deps: str
Design: new pure-function @ crates/workshop/server/src/agents/bindings.rs::gateway_binding deps: GatewaySnapshot
Design: new cyclic-dependency @ crates/build-xtask/src/test_support_leak.rs
Design: new shared-parameter-cluster @ crates/build-xtask/src/test_support_leak.rs::scan_table deps: Map,Path,Vec,[String],str
Design: new swallowed-exception @ crates/build-xtask/src/test_support_leak.rs::parse_manifest deps: Path
Design: new pure-function @ crates/build-xtask/src/test_support_leak.rs::guarded_dependency_reference deps: str
Design: new pure-function @ crates/build-xtask/src/test_support_leak.rs::resolve_package deps: [(String,Map)],str
Design: removes facade @ crates/harness-api/src/bridge.rs
Repairs: a catalog with no chat-capable model is unavailable @ crates/harness/sessions/src/session/supervisor.rs::classify - a session launched its run under an empty catalog instead of waiting for a usable generation
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Rewrite the user-facing guides and the architecture record around the sans-I/O engine and the harness that hosts it. The agent guide's event log chapter now teaches reading a task's own events incrementally through a sequence cursor, the language guide gains the task id field and a background-work section covering spawn, the waits, inspection, notes, and cancellation, and the architecture document lists the harness as a component while the executor and the Lua boundary lose their gateway dependency. A migration note for Papergate maps each engine call it makes today to its harness replacement and names the one gap, store access, that the harness surface does not cover. The assembled guides are regenerated in the same change so they agree with their sources.

- `vibe/archdoc.md` records the executor as a deterministic state machine whose host interface is `Run::new`, `step`, `resume`, and `cancel`, and moves the gateway dependency off the executor and the Lua VM boundary onto the new harness component, which reaches the gateway through its public protocol and discovery crates only. The workshop UI entry now depends on the harness rather than the executor.
- `vibe/papergate-harness-migration.md` recommends closing the store gap by changing the prompt (paper as the run argument, report read from the last `assistant_reply` event under section `Analyze`) over extending the door, and gives the second option's shape for the Papergate repository to weigh.
- `tasks.events` is documented as returning a plain sequence in the task's own order, readable only for the calling task or a task it owns, with `opts.last` set to the highest `provenance.seq` already seen so a per-turn loop reads each event once.
- `sys.taskid` is documented as the nearest enclosing task: the main walk's `0` for a walked section, the arm's task in a fanout, the spawned task in a `tasks.spawn` chain, and the caller's task inside a `call`.
- `tasks.spawn` is documented with the `tasks_live` rule: every spawned task must be delivered through `tasks.when_any` or `tasks.when_all` or cancelled before its owner returns.
- `runtime.events()` no longer appears in the agent guide; the read-only view, its index-safety rules, and the empty-table fallback for an unconfigured log are removed with it, and the `runtime` global is no longer named as the agent-environment marker.

Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Add tests asserting that a finished task's final text reaches the model only inside the run's untrusted envelope, byte-identical on the drained-notice path, the awaited-answer path, and in the notice the log keeps; that a result forging the envelope's close tag or a template control delimiter is neutralized inside the envelope; and that a history read wraps a forging history the same way under the reader's nonce and reports its answer as untrusted. The run-text and scripted-drive helpers already in a sibling suite are shared instead of copied, so the new suite and the existing input suite read the same definitions.

- `model_task_trust.rs` is a new test module registered in `mod.rs`; its four tests play the model through the serial driver with one canned answer per round.
- `text_of` in `task_events.rs` becomes `pub(super)` with a doc comment, and the identical local copy in `run_inputs.rs` is removed in favor of the import; `drive_scripted` is widened to `pub(super)` for the same reason.
- `run_nonce` derives the envelope nonce from `TEST_SEED` via `GuardNonce::from_seed`, which lets a child section forge the exact live close tag on purpose.
- `assert_enveloped` requires exactly one live open tag and one live close tag under that nonce, with the payload occurring once between them.
- `a_finished_tasks_result_reaches_the_model_only_inside_the_runs_envelope` and `await_tasks_hands_the_model_the_same_enveloped_result` compare the notice text and the `Event::TaskNotice` text against the same expected string, so the wrap happens once when the task ends rather than per delivery path.
- `a_task_result_forging_the_close_tag_is_neutralized_inside_the_envelope` and `the_task_events_read_wraps_a_forging_history_under_the_readers_nonce` check that the forged `<` is escaped to `&lt;/untrusted_input_`, `[INST]` is spaced to `[ INST]`, and the bare nonce appears three times only; the history read's `Event::ToolResult` reports `trusted` false.
- `model_task_trust.rs` and its siblings are the only Rust changed: no product source moves, and every Rust hunk is under `execute/tests/`.

Design: new pure-function @ crates/promptforge-api-runtime/src/execute/tests/model_task_trust.rs::run_nonce
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/tests/model_task_trust.rs::notice_text deps: &[Event]
Design: new pure-function @ crates/promptforge-api-runtime/src/execute/tests/model_task_trust.rs::assert_enveloped deps: &str,&str
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
Plan: vibe/2026-09-18-4-sans-io-engine-harness.md
When the host cancelled a run, or a fatal answer ended it, the scheduler tore every chain down without ending the tasks those chains still owned. A task that had started and was still live was recorded with a start and no terminal, and its slot read as running forever. The run's end now settles every live task exactly once, reporting each as abandoned because the run ended, before it tears the chains down and reports the run's own end boundary. A new abandonment reason names the run's end, so the log and the model notice say what happened instead of blaming an owner.

- `AbandonReason::RunTerminated` is an additive variant on the public serde enum; it renders as "the run ended" in the trace line, and no existing variant or spelling changes.
- `settle_all_tasks` collects the distinct owners of every live slot, sorts them ascending by chain index, and passes each to `abandon_owned_tasks`, discarding the leaked-author list since no one receives an outcome for a run that is ending. A nested task ended through `abort_subtree` carries `OwnerAborted` and is already terminal when its owner's turn comes, so `is_live()` skips it and no slot reports twice.
- `Scheduler::end` calls the settlement before `teardown`, so every `TaskAbandoned` observation precedes the run's end boundary; a run that ended well has no live slot left and emits nothing.
- `terminals_per_started_task` is widened to `pub(super)` so the new termination module reuses it.
- `cancelling_a_run_settles_every_live_task_with_one_terminal_before_the_run_ends` drives a spawner whose child parks on a model round on the serial driver, cancels at the first chat effect, and asserts one `abandoned` terminal per started task with reason `RunTerminated`, observed before `RunFailed`.
- `cancellation_while_suspended_in_a_fanout_arm_interrupts_the_run` now asserts two "Task abandoned: the run ended" terminals where it previously asserted the stranded arms reported no terminal of their own.

Design: extends surface-growth @ crates/promptforge-api-types/src/ids.rs::AbandonReason boundary: persisted
Repairs: exactly-one-terminal contract @ crates/promptforge-api-runtime/src/execute/scheduler/drive.rs::Scheduler::end - a task live when the host cancelled the run received no terminal and its slot read running forever
Plan: vibe/2026-09-20-1-harness-debt-removal.md
The harness session reported failures to its clients as bare sentences, and the Workshop shell recovered the failure kind by prefix-matching those sentences against its own copy of the words, falling back to the label that says the agent's run ended. A reword on the harness side would have relabelled every survived turn as a dead agent with nothing failing to compile. The failure report now carries a machine-readable kind beside its display message; the shell labels the status bar from the kind alone and passes the message through unchanged, and the error frame on the socket still carries only the message. A new session test pins that a requested close is reported once as an interrupt with the frame's wording.

- `FailureKind` is a four-variant `Copy` enum, deliberately not `#[non_exhaustive]`: a client that labels each kind matches on it exhaustively, so a new kind fails that client's build until it is labelled instead of falling into a wildcard.
- `SessionFailure` pairs the kind with the display message as a passive data bag with derived equality. The kind is the fact code acts on; code never derives meaning from the message.
- `harness_api` re-exports `FailureKind` and `SessionFailure` beside `Session`, so `status.rs` and `socket.rs` import them from that crate alone.
- `SessionCore` reports each failure with its kind. The model-turn and tool-call failure arm picks the kind from the matched event and keeps the sentence unchanged; the supervisor reports a failed run as `RunFailed` and a close's synthetic terminal as `Interrupted`.
- `failure_label` maps a `FailureKind` to the status label by exhaustive match: the two survived turns keep their boundary as the label, and `RunFailed` and `Interrupted` read `RUN_FAILED_LABEL`. `SURVIVED_TURN_LABELS` and the `starts_with` scan are gone.
- `every_error_report_pushes_a_terminal_failure_status` feeds all four kinds with messages deliberately unlike the labels and asserts the label comes from the kind and the message passes through as the description.
- `a_requested_close_reports_one_interrupted_failure_by_kind` asserts a parked run reports nothing, then that a close reports exactly one `Interrupted` failure carrying the interrupt frame's wording.
- `session-close.rs` holds the close-path tests as the `close` child module of the session integration suite; `closing_answers_outstanding_effects_dropped_before_closed` moves there unchanged.
- `ErrorFrame::new` still receives only `failure.message`. The socket's wire shape does not change and the kind never reaches the frame.

Design: stringly-typed -> value-object @ crates/harness/sessions/src/session.rs::SessionFailure boundary: pub
Design: removes stringly-typed @ crates/workshop/server/src/agents/status.rs::failure_label deps: FailureKind
Design: extends facade @ crates/harness-api/src/lib.rs boundary: pub
Plan: vibe/2026-09-20-1-harness-debt-removal.md
Plan: vibe/2026-09-20-1-harness-debt-removal.md
@vinniefalco
vinniefalco merged commit 9530340 into cppalliance:master Sep 20, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant