---
title: "RFC: Generation Events — generation_complete, re-scoped output_update, autosave cutover"
description: "Local-first visual environment for building and running AI workflows. Build agents visually, deploy anywhere, privacy by design."
canonical: https://docs.nodetool.ai/rfc-generation-events
markdown: https://docs.nodetool.ai/rfc-generation-events.md
product: NodeTool
source: https://github.com/nodetool-ai/nodetool/blob/main/docs/rfc-generation-events.md
---

# RFC: Generation Events — generation_complete, re-scoped output_update, autosave cutover

# RFC: Generation Events — `generation_complete`, re-scoped `output_update`, autosave cutover

**Status:** Draft for sign-off · **Owner:** runtime/web

> Every line/file reference below was re-verified against the working tree before this revision. Findings that were wrong-as-written (the double-save rationale, the actor `job_id` field, "`_messages` authoritative for autosave") are corrected in-body and catalogued in the **Addressed review notes** appendix. The scope was widened from "kernel→websocket→web-editor" to **all message consumers** (browser runner, mobile, CLI, mini-apps, timeline/sketch editors, chat) after a consumer census.

---

## 1. Summary

We split the one overloaded "a node produced something" signal into **three orthogonal channels**:

1. **`node_update`** — node **lifecycle** only (running / completed / error, timing, cost). One per node-run. Stops being the autosave/generation source.
2. **`generation_complete`** (NEW) — a generator committed **one complete artifact**: `{ node_id, node_name, node_type, index, outputs }` (plus `job_id`/`workflow_id` stamped downstream). Emitted once per `process()` result (once at stream-end for `genProcess`). Authoritative; drives `liveGenerations` + autosave; **never suppressed**.
3. **`output_update`** — demoted to **ephemeral display-only** live feed (text tokens, progressive preview, realtime audio). Carries an explicit `disposition: "append" | "replace"`. Never persisted, never creates a generation. Edge-suppression becomes harmless.

The kernel change is small and TS-only. The **breadth** is in the consumers: every surface that today reads `node_update.result` or `output_update.value` as an artifact must move to `generation_complete.outputs` or accept a documented, graceful degradation to one artifact.

---

## 2. Problem & evidence

**The kernel collapses multi-execution runs into one `node_update`.** `_emitNodeStatus("completed", …)` fires exactly once per node-run at `actor.ts:440-443`, carrying `this._latestResult ?? {}`. There are only 4 `_emitNodeStatus` call sites (`actor.ts:278` running, `:421` suspended, `:426` error, `:440` completed). `_latestResult` (declared `actor.ts:157`) is **overwritten** on every execution — never accumulated:

| `actor.ts` line | mode | write | object identity |
|---|---|---|---|
| 357 | streaming-input `run()` | `= nodeOutputs.collected()` | fresh from collector |
| 369 | streaming-input legacy `process()` | `= outputs` | **same ref** as executor return |
| 987 | `genProcess` per-yield | `= {...collected}` | **fresh spread** each yield |
| 994 | `genProcess` stream-end | `= {...collected}` | **fresh spread** |
| 1000 | correlated `process()` | `= outputs` | **same ref** as executor return |
| 1290 | controlled-loop `process()` | `= outputs` | **same ref** as executor return |

(The object-identity column is load-bearing for §7 — see the corrected double-save analysis.)

So a `ListGenerator.item → TextToImage.prompt` run that fires `TextToImage` 6× (the correlated `while (isReady(key))` drain at `actor.ts:716-720`, each call overwriting `_latestResult` at `:1000`) emits **one** `node_update{completed}` carrying run #6 only.

**Autosave inherits the collapse.** `autoSaveAssets` (`unified-websocket-runner.ts:428-571`) is called from the single site at `unified-websocket-runner.ts:1932-1953`, gated on `outbound.type === "node_update" && status === "completed" && result != null && meta?.auto_save_asset`. It walks `outbound.result` (= `_latestResult`, run #6) → persists **1 asset/run, 5 lost**. (Idempotency guard at `:468` `if (assetValue.asset_id) continue` skips already-`asset_id`'d values by mutating that same object in place at `:518-520`; `node_id`/`job_id` stamped from `opts` at `:506-507`. Crucially, `Asset` records carry `node_id`+`job_id` but **no `index`** — there is no persisted-side dedupe key beyond the in-place `asset_id` mutation. This matters for replay; see §15.)

**`output_update` is the accidental sole carrier of the lost variants — and it's overloaded three ways.** Emitted at `runner.ts:1286-1294`, once per emit (per yield / per key / per control-run — NOT collapsed). But suppressed at `runner.ts:1280-1284` for any handle with an outgoing **data** edge (`findEdges(sourceNodeId, handle).some(isDataEdge)` unless `always_emit_output_updates`). So an intermediate generator feeding a Preview emits nothing; only the terminal Preview re-emits and buffers all N. `output_update` simultaneously serves: (1) live incremental display (text tokens, progressive preview → `setOutputResult(append=true)`), (2) realtime audio transport (~50/s, coalesced to a worklet bus — the *reason* suppression exists, `runner.ts:1257-1264`), and (3) the accidental multi-execution variant carrier. It also can't distinguish a chunk (append) from a whole value (replace) — `workflowUpdates.ts:515` hardcodes `append=true` (latent progressive-preview bug).

**Net:** a 6-image run produces 6 artifacts; the generation timeline and persistence see 1.

---

## 3. Goals / Non-goals

**Goals**
- Every committed artifact of a multi-execution run is persisted (N assets/run) and appears as a navigable variant — **on both the server and in-browser execution paths**.
- Lifecycle, artifacts, and live display travel on three independent channels with clear contracts.
- `output_update` becomes correctness-irrelevant: edge-suppression can drop it freely.
- Zero `nodetool-core` / Python-worker change for the targeted multi-execution collapse (see §10).
- No double-save and no no-save during cutover.
- Every TS message consumer (web editor, browser runner, mobile, CLI, mini-apps, timeline/sketch, chat) is explicitly migrated or assigned a documented graceful-degradation decision.

**Non-goals**
- Changing the realtime-audio fast-path (`runner.ts:45-52` / bus). It stays byte-for-byte.
- Changing the TS↔Python bridge protocol.
- Reworking the salvaged UI navigator (`groupByRun`/`NodeHistoryViewer`). It is the correct consumer; it only needs a correct feed.
- A separate `generation_started` / progress channel (out of scope; `node_update{running}` + `node_progress` already cover it).
- Persisting N artifacts for **multi-artifact-per-output-slot streaming generators** (no such node exists today; see §10 — explicitly out of scope, with a guard test).

---

## 4. The three channels

| Channel | Carries | Rate | Persisted? | Creates Generation? | Suppressible? |
|---|---|---|---|---|---|
| `node_update` | lifecycle: running/completed/error, timing, cost, property echo | 1/run | no | **no (changed)** | no |
| `generation_complete` | one complete artifact + stable variant `index` | N/run (1 per `process()` result) | **yes (drives autosave)** | **yes** | **never** |
| `output_update` | live display feed: text tokens, progressive preview snapshot, audio chunks | high (≤~50/s audio) | no | no | yes (harmless) |

### 4.1 `node_update` (unchanged shape, narrowed role)

`messages.ts:166-178` stays as-is. After this change it no longer drives autosave or generations. Everything else (status/timing/cost/property-merge/error-notification/trace) is unchanged. The `result` field becomes **advisory/back-compat only** (still the last result); consumers must not read it for **multi-execution** artifacts. It remains the graceful-degradation path for consumers that cannot be fully migrated this cycle (mobile shows 1 artifact via `result`; see §8.6) and for skip-result nodes' values (see Decision 7).

### 4.2 `generation_complete` (NEW)

```ts
export interface GenerationComplete {
  type: "generation_complete";
  node_id: string;
  node_name: string;
  node_type: string;
  /** k-th generation of this node in this run (backend-assigned, monotonic, stable). */
  index: number;
  /** The complete result dict for this artifact (same shape as a process() return). */
  outputs: Record<string, unknown>;
  /** Stamped downstream by the runner relay, NOT by the actor. */
  job_id?: string | null;
  workflow_id?: string | null;
}
```

- `index` is a **per-actor monotonic counter** (`_generationIndex++`), NOT the correlation lineage index (those reset per `(root, parentKey)` and are not a stable global variant id — see §5). A fresh actor is created per `_processGraph` and a fresh runner per job, so `index` does not collide across runs.
- `job_id` / `workflow_id` are **optional in the actor emit** and stamped uniformly downstream — see §5 and the corrected note below.
- `outputs` is the **whole result dict**, passed through unflattened. List-valued handles (`num_images=N`) stay as arrays; consumers flatten via `runVariantValues` / recursive autosave `collect` (Decision 1).
- Emitted from inside the serial actor paths, so per-node `index` ordering is deterministic.

### 4.3 `output_update` (re-scoped, + `disposition`)

```ts
export interface OutputUpdate {
  type: "output_update";
  node_id: string;
  node_name: string;
  output_name: string;
  value: unknown;
  output_type: string;
  metadata: Record<string, unknown>;
  /** NEW. "append" = value is a chunk to concatenate; "replace" = value is a whole snapshot. */
  disposition?: "append" | "replace";
  /** NEW (optional). Marks the final chunk of an append stream. */
  done?: boolean;
  workflow_id?: string | null;
}
```

`disposition` is optional for back-compat: absent ⇒ treat as `"append"` (today's behavior). Streaming chunks (text tokens, audio, genProcess yields) → `"append"`; whole non-generation values (a string into an Output node, a progressive-preview full-frame) → `"replace"`.

---

## 5. `generation_complete` emission points

Add to the actor, alongside `_emitNodeStatus`. **The actor does NOT carry `job_id`** — verified: `packages/kernel/src/actor.ts` has zero `jobId`/`job_id`/`_jobId` references, and `_emitNodeStatus` (`actor.ts:1361-1380`) emits `node_update` **without** a `job_id` field. `job_id`/`workflow_id` are backfilled for **every** outbound message at `unified-websocket-runner.ts:1855-1861` (`msg.job_id ?? active.jobId`, `msg.workflow_id ?? active.workflowId`). A new `type` inherits this for free. (The browser path must apply the same backfill; see §8.5.) `node_name` mirrors `node_update`: `this.node.name ?? this.node.type` — **not** `this.node.data?.title`.

```ts
private _generationIndex = 0;  // new field near _latestResult (actor.ts:157)

private _emitGenerationComplete(outputs: Record<string, unknown>): void {
  this._emitMessage({
    type: "generation_complete",
    node_id: this.node.id,
    node_name: this.node.name ?? this.node.type,   // mirror _emitNodeStatus
    node_type: this.node.type,
    index: this._generationIndex++,
    outputs
    // NO job_id here — runner relay stamps it (unified-websocket-runner.ts:1855-1861)
  });
}
```

Wire it at **each completed `process()`-result boundary** — the same sites where `_latestResult` is assigned:

| # | `actor.ts` site | mode | rule |
|---|---|---|---|
| 1 | after `:1000` (correlated `process()`) | one per ready key → **N/run for a generator** | the primary 6×-collapse fix |
| 2 | after `:1290` (controlled-loop `process()`) | one per `"run"` control event | |
| 3 | after `:369` (streaming-input legacy `process()`) | once | also the path Python `is_streaming_input` nodes take (see §10) |
| 4 | after `:994` (`genProcess` **stream-end**) | **ONCE per stream**, using final `_streamingCollectedOutputs` — **NOT** per yield at `:987` (yields are chunks → stay `output_update`) | if also correlation-driven, reached once per key (correct). **Load-bearing invariant — see below.** |
| 5 | after `:357` (streaming-input `run()`) | once, `nodeOutputs.collected()` | **TS-only**; Python nodes never reach `run()` (see §10). Parity-complete spot for filters/transforms; low priority |

**Load-bearing invariant for site #4 (genProcess stream-end).** `_streamingCollectedOutputs` is an **overwrite-merge**, not a concatenation: `actor.ts:984` does `Object.assign(this._streamingCollectedOutputs, routed)`, and `:994` takes `{...this._streamingCollectedOutputs}`. Therefore the stream-end dict holds the **last value written to each output slot**, not all N values streamed through that slot. The "once at stream-end" rule is correct **iff the generator's final accumulated dict holds the complete artifact** — true for nodes that emit a final whole-value yield (Summarizer-style: a terminal `{text, output}` consolidating yield; audio-with-`done`). It is **wrong** for a hypothetical generator that streams N distinct savable artifacts on the **same output slot** with no consolidating final yield — that would commit only artifact N (the exact collapse bug this RFC kills, reappearing on the streaming path). No such node exists today (e.g. `ListGenerator` yields per-item `{item, index}`, but its items propagate downstream as iteration-correlation envelopes that fire consumers N times → the N `generation_complete` land on the **consumer** at site #1, not on the generator, whose own timeline is not a savable sink). We **scope multi-artifact-per-slot streaming generators OUT** (§3 non-goal, Decision 1 corollary) and add a guard test (§13) asserting the documented limitation so a future node author hits a failing test, not a silent data loss.

Constraints:
- **Never** routed through the `runner.ts:1280` edge-suppression — `generation_complete` goes straight out via `_emitMessage → _emit` (`runner.ts:999-1001, 1688-1708`).
- **Not audio-dropped** — do NOT route it through the `isAudioChunkOutputUpdate` drop (`runner.ts:1696-1703`). It is pushed into `_messages` (which is **truncated** past `MAX_RETAINED_MESSAGES = 10_000` via `slice` at `runner.ts:1697-1700` — see §15 on what that does and doesn't guarantee).
- No new backpressure: same fire-and-forget path as `node_update`, rate bounded by result count (low), does not touch inbox flow-control.
- Skip-result parity: for `nodetool.constant.*` / `nodetool.input.*` (the `skipResult` set at `actor.ts:437-439`), do **not** emit `generation_complete` — those aren't generators. How their values still reach display sinks is specified in Decision 7.

---

## 6. `output_update` re-scope

- **`disposition` drives append vs replace.** `runner.ts:1286-1294` sets `disposition: "append"` for streaming chunks (genProcess yields, audio), `"replace"` for whole-value emits. Every frontend consumer branches on it instead of hardcoding append (see §8.7 — there are **5** `setOutputResult` call sites, not 1), fixing the progressive-preview latent bug.
- **Ephemeral clearing lifecycle.** The `outputResults` buffer (`ResultsStore.ts:609-694`, keyed `${wf}:${job}:${node}`) is cleared (a) on run start (existing `clearResults`/`clearOutputResults` at `:424-443`) and (b) on `generation_complete` for that `(wf, job, node)` artifact — once the artifact is committed as a generation, its live scratch buffer is stale. This keeps the display buffer from leaking partial chunks into the settled view.
- **Edge-suppression is now harmless.** No correctness depends on `output_update` reaching anyone — artifacts travel on `generation_complete`. The `runner.ts:1280-1284` data-edge skip and the `always_emit_output_updates` opt-out remain as-is; they now only affect cosmetic live display, never persistence or variant counts.
- **Audio path unchanged.** `isAudioChunkOutputUpdate` (`runner.ts:45-52`), the live-stream-without-retention fast-path (`runner.ts:1704-1706`), `queueAudioAppend` / `publishRealtimeAudioChunk` (`workflowUpdates.ts:513-523`), `realtimeAudioChunkBus.ts`, `AudioOutBody.tsx`, `useRealtimeAudioPlayback.ts` — all untouched. Audio chunks are `disposition: "append"` and never become generations.
- **Do not narrow `value` to chunk-only.** Display sinks legitimately render whole non-generation values (a string into an Output node, chat string-streaming). Keep `value: unknown`.

---

## 7. Autosave cutover (server path)

**Move autosave from `node_update{completed}` to `generation_complete`** — a **hard switch, not dual-write**:

1. Add `generation_complete` to the processing gate at `unified-websocket-runner.ts:1889-1892` so it gets node-type resolution, constant/input skip, and normalization.
2. New branch (parallel to the deleted one), autosave **before** normalize (normalize strips inline bytes):
   ```ts
   if (outbound.type === "generation_complete") {
     const meta = this.getNodeMetadata?.(nodeType);
     if (meta?.auto_save_asset && outbound.outputs != null) {
       await autoSaveAssets(outbound.outputs as Record<string, unknown>, {
         userId: this.userId ?? "1",
         workflowId: active.workflowId,
         jobId: active.jobId,
         nodeId: String(outbound.node_id ?? ""),
         textOutputName: primaryTextOutputName(meta)
       });
     }
     // then: normalizeOutputValue(outbound.outputs) — mirror :1958 on .outputs
   }
   ```
3. **Delete** the `node_update` autosave at `unified-websocket-runner.ts:1932-1953` in the **same commit**.

**Why hard-switch and not dual-write (corrected rationale).** The original draft justified the hard switch by claiming the last-result object is a *different instance* on the two channels and would therefore double-save. **That is inverted.** Verified at `actor.ts:1000`/`:1290`: `this._latestResult = outputs` assigns the **same object reference** that `_emitGenerationComplete(outputs)` would carry — so on the `process()`/correlated path, `node_update.result` and the run-#N `generation_complete.outputs` are the **same instance**, and the reference-based idempotency guard (`autoSaveAssets:468` keys on the in-place `asset_id` mutation at `:518-520`) would make a dual-write a **no-op** for that shared object, not a double-save. The genuine fresh-object case is the **`genProcess` path** (`:994` does `{...spread}`), where the guard cannot see the prior save across channels.

The hard switch is still **required**, for two real reasons:
- **(a) Under-save, not double-save, is the dominant risk.** Runs `1..N-1` of a multi-execution node exist **only** on `generation_complete` — `node_update` collapses to the last. A dual-write therefore under-saves the early runs regardless of object identity; the new channel must own autosave outright.
- **(b) Cross-channel idempotency is reference-based and partial.** It protects only the **shared** last-result object on the `process()` path; it does **not** protect the `genProcess` fresh-spread path, where dual-write **would** double-save the final stream-end artifact.

Atomic swap is the only clean option. (If a dark-launch is mandated, gate both with one flag, never both ON.)

**Asset-volume / parity.** A 6-image run now persists 6 assets (was 1), each tagged `node_id`, `job_id`. The consumer de-dupes by **`jobId`**: `mergeGenerations` (`nodeGenerations.ts:74-82`) drops live gens whose `jobId` already persisted, and all N variants share one `jobId`, so live N + persisted N collapse to N (not 2N). `useNodeResultHistory.lastJobAssets` (`useNodeResultHistory.ts:75-81`) already returns N assets for the last job → N tiles. The `job_update{completed}` asset reload (`workflowUpdates.ts:712-725`) now surfaces all N correctly. **Note:** persisted assets carry no `index` (§2), so `mergeGenerations` groups purely by `jobId`; this is sufficient for the live/persisted collapse but means **replay idempotency must be addressed at the autosave layer** (§15 / Decision 8). **Storage volume note:** flagged in §15 — runs that previously discarded intermediates now persist them; confirm desired for high-N generators (e.g. a 100-frame batch).

---

## 8. Frontend rewiring (all consumers)

A census of every `node_update` / `output_update` consumer (`grep` across `packages/`, `web/`, `mobile/`) yields the surfaces below. The original draft covered only §8.1–§8.4; §8.5–§8.10 are the surfaces the consumer census surfaced.

### 8.1 `workflowUpdates.ts` reducer (web editor — primary)
- **`node_update` branch (`:866-1044`):** keep all lifecycle (error notification/state/ErrorStore/trace at `:881-920` except the generation write; status/timing/cost at `:931-974`; property-merge at `:1016-1043`). **DELETE the `completed → upsertLiveGeneration` write at `:986-998`.** Keep a lightweight `running → upsertLiveGeneration({status:"running"})` placeholder (`:982-985`) so a node shows a spinner before its first artifact (`generation_complete` only fires at commit). The `node_update{error}` → generation write (`:894-901`) **stays on `node_update`** (lifecycle owns errors; `generation_complete` represents committed artifacts only — see Decision 1). **Both the `running` placeholder and the `error` settle are index-less patches** — see §8.2 for how they route in the index-keyed store, and §9 for silent-scrub gating that must cover the `running` write too.
- **`output_update` branch (`:499-547`):** becomes display-only. Branch on `disposition` instead of hardcoded `append=true` at `:515`. Audio fast-path + logging + trace unchanged.
- **NEW `generation_complete` branch:** the sole generation driver. Appends/replaces by `index`, absorbs the silent-scrub gate (§9), clears the ephemeral `outputResults` buffer for the artifact.

### 8.2 `ResultsStore.upsertLiveGeneration` (`:496-558`) — **DELETE the variant-inference, define index-less routing**
- **DELETE** the Tier-2 "second completed appends a variant" hack: `startsNewVariant` (`:522-525`) + the synthesized-variant-id append branch (`:527-548`, the `${jobId}#${variantCount}` scheme at `:535`).
- **DELETE** the `isSilentJob`-in-upsert special case (`:519` + its comment `:514-518`). Silence moves to the message handler (§9).
- **REPLACE** the matcher with **append/replace-by-`index`** keyed `${workflowId}:${nodeId}`. `generation_complete{index:k}` → set/replace slot `k`, id `k===0 ? jobId : ${jobId}#${k}` (preserve the back-compat id scheme — `selected_generation` values and tests depend on it). `getLiveGenerations` (`:563-564`) read API unchanged.
- **Index-less-patch contract (NEW — addresses the under-specified seam).** The surviving `node_update{running}` placeholder and `node_update{error}` settle carry **no `index`**. Specify: *a patch without `index` targets the newest slot for that `jobId`* — i.e. retain the existing `findLastIndex(g => g.jobId === jobId)` fallback (`:507`) **only** for index-less patches, while any `generation_complete` patch supplies an explicit `index` and uses index-keyed set/replace. This guarantees a node that errors before committing any artifact (no `generation_complete`, correct per Decision 1) still has its `running` placeholder settled by the index-less `error` patch — otherwise the index-keyed store has no slot for it and leaves a stuck `running` generation.

### 8.3 Preview / Output / content-card (web editor)
- **`PreviewNode.tsx` (`:235-298`):** **DELETE** the "accumulate `output_update`s as a variant array" reliance (the contract documented at `:235-242`). Preview reads its **source** node's generations via the value edge — `useNodeResultHistory(incomingValueEdge.source)` (already wired at `:277-284`) / `useNodeGenerations` of the source — for discrete artifacts; `streamBuffer` (`output_update`) only for live text/audio. The `sourceFallbackValue` path becomes primary.
- **`OutputNode.tsx` (`:239-249`):** structurally unchanged — already layers `streamBuffer ?? settledValue`. Stream for live, generation (`useNodeGenerations().current`) for settled.
- **`ContentCardBody.tsx` (`:600-617`):** already generation-driven via `useNodeResultValue`/`useNodeResultHistory`. Its `resolvePreviewValue` array-flatten (`:281-301`) is now redundant for settled artifacts (`runVariantValues` covers N tiles); it still services the live in-flight buffer, so it stays.

### 8.4 SALVAGED UI — fed correctly, **zero code change**
`utils/nodeGenerations.ts` (`groupByRun`/`getCurrentRun`/`RunGroup`/`runVariantValues`/`mergeGenerations`/`assetToGeneration`), `useNodeGenerations` (`{runs, currentRun}`), `NodeHistoryViewer` (the runs/variants/single navigator, `defaultView` flips to grid at `variants.length > 1`, `:269-270`), `useNodeIO`, `nodeGenerationAccessor`, `useNodeExecState`. These already expect `index`-keyed variants under a stable `jobId`; they just receive a clean feed instead of the `node_update` collapse.

### 8.5 In-browser execution path — **normalize + autosave branches REQUIRED** (was omitted)
The dual-run-path architecture runs the **same `packages/kernel`** in a Web Worker, so `generation_complete` **is emitted in-browser too**. Two gaps:
- **Normalize.** `browserRunner.worker.ts:187-203` (`attachPreviewBitmaps` / `resolveImageRefForTransport`) and `browserWorkflowRunner.ts:435-441` (`materializeBrowserOutputs` / `resolve`) currently materialize **only** `node_update.result` and `output_update.value`. **Add a `generation_complete` branch in both** that applies the same resolve + materialize to `.outputs` (GPU read-back, raw-RGBA→PNG, inline-bytes→uri), or in-browser `generation_complete` carrying GPU refs / inline bytes renders broken and bitmaps are not transferred. The `:432` "render identically" guarantee depends on it.
- **Autosave.** `autoSaveAssets` exists **only** in `unified-websocket-runner.ts` — the entire `web/src/lib/workflow/` directory calls it **nowhere** (verified: zero matches). So in-browser generative runs do not persist via this code. **Decision required (Decision 9):** either (a) add a browser-side autosave hook keyed on `generation_complete` mirroring the server branch, or (b) confirm+document that browser-path persistence already round-trips to the server (the `job_update{completed} → invalidateQueries(['assets']) + loadWorkflowAssets` reload at `workflowUpdates.ts:712-725` assumes the server already saved). The "every committed artifact is persisted" goal is **unproven for the browser path** until this is resolved; the rollout's "each step shippable and green" claim is **false for browser-eligible workflows** until §8.5 lands. The silent-scrub case runs in-browser (§9), so this is on the critical path.
- **Backfill.** Confirm the browser relay applies the same `job_id`/`workflow_id` stamping the server does at `unified-websocket-runner.ts:1855-1861`; the bare actor emit (§5) relies on it.

### 8.6 Mobile (`mobile/src/stores/WorkflowRunner.ts`) — **add a case, decide degradation**
Independent reducer: reads `node_update.result` (`:354-359`) and `output_update.value` (`:384-396`) into one `nodeResults` map; reads `node_update` only for errors otherwise. After this change, mobile keeps working at **1-artifact-per-node** via `node_update.result` (the advisory last-result), which is acceptable graceful degradation. **Required:** (1) add a `generation_complete` case to the `WorkflowRunner.ts` switch — minimally store `.outputs` into `nodeResults` so multi-execution shows at least the latest, ideally a variant list if mobile surfaces variants; (2) regenerate `mobile/src/api.ts` message-type table (it currently has a generated `output_update`/`node_update` union with no `generation_complete`, `:5005+`); (3) state explicitly (Decision 10) that mobile's continued read of `node_update.result` is an **intended** degradation, not an accident. Mobile is intentionally NOT a root workspace; build `protocol` first before its typecheck.

### 8.7 `setOutputResult` append/replace plumbing — **5 call sites, not 1**
`disposition` must thread through `ResultsStore.setOutputResult`'s boolean `append` param. Verified call sites (excluding tests): `workflowUpdates.ts:515`, `core/chat/chatProtocol.ts:514`, `hooks/timeline/useGenerateClip.ts:186`, `hooks/sketch/useGenerateLayer.ts:227`. The original draft named only the first two. The timeline/sketch `forwardWorkflowMessage` paths call `setOutputResult(..., true)` unconditionally, so a `disposition:"replace"` value there would still append — re-introducing the exact progressive-preview bug elsewhere. **Fix:** change `setOutputResult` to take `disposition` (or derive `append` from it) and thread it through **all four** runtime call sites. (`utils/imageRef.ts:51` is only a doc comment; update it too.)

### 8.8 Timeline & Sketch generation (`useGenerateClip.ts`, `useGenerateLayer.ts`) — **migrate asset extraction**
Both share an identical pattern that makes `output_update` **load-bearing for production asset extraction**: on each message they `jobOutputs.set(jobId, normalizeOutputUpdateValue(...))` gated on `node_id === context.selectedOutputNodeId` (`useGenerateClip.ts:204-209`, `useGenerateLayer.ts:248-253`), then at `job_update{completed}` call `extractAssetId(jobOutputs.get(jobId))` (`useGenerateClip.ts:228-235`) and **mark the whole job FAILED** ("finished without producing an output asset") if absent. Output nodes are terminal (no outgoing data edge) so `output_update` survives suppression *today*, but the new contract makes this a freely-droppable display artifact. **Migrate** the `selectedOutputNodeId` extraction from `output_update` to `generation_complete.outputs` (authoritative, never suppressed); keep `output_update` only for live preview inside the editors. This is the production path for both the video timeline and the sketch editor — it must move, not just honor `disposition`.

### 8.9 Mini-apps (`web/src/components/miniapps/hooks/useMiniAppRunner.ts`) — **migrate result tiles**
Mini-app result tiles are built **entirely** from `output_update`: `:84-103` creates one `MiniAppResult` per `output_update` (id `node_id:output_name:timestamp`) → `upsertResult`; `node_update` is read only for errors (`:105-119`). Under the new contract `output_update` is no longer the artifact carrier, so mini-app lists become incomplete/empty for edge-connected generators. **Switch** `MiniAppResult` construction to `generation_complete.outputs` (flatten per output handle for committed artifacts); keep `output_update` only for live-streaming display within a result. Mini-apps are a first-class surface.

### 8.10 Chat / agent path (`web/src/core/chat/chatProtocol.ts`)
`chatProtocol.ts` independently consumes `node_update` (`:1263`) and `output_update` (`:1294` → `applyOutputUpdate` at `:492-521`); it has **no** `upsertLiveGeneration` / generation path (only `content_metadata.media_generation` chunk handling at `:430`). Two requirements: (1) `applyOutputUpdate` must honor `disposition` (same demotion as §8.1; chat string-streaming at `:523+` stays as legitimate whole-value display); (2) **explicitly decide** (Decision 11) whether the chat surface creates variants for a multi-execution generator invoked from a workflow tool, or **intentionally ignores `generation_complete`** (display-only). Recommend the latter for this cycle — document it — rather than leaving it implicit in a one-line note. If chat later needs variants it gets its own `generation_complete` branch.

### 8.11 CLI (`packages/cli/src/websocket-client.ts`)
Protocol consumer used by `nodetool workflows run --json` and piped chat: handles `node_update` (`:398-403`, surfacing `result`) and `output_update` (`:405-411`, surfacing `value`); no `generation_complete` case. After the change a 6-image CLI run reports 1 collapsed result via `node_update` and the variants exist only on a type the CLI ignores. **Decide (Decision 12):** surface `generation_complete` events in the JSON stream (one event per artifact) or aggregate them; add the message-type entry to the CLI union (`:36-38`). Lowest-priority consumer but must not silently drop variants from `--json` output.

---

## 9. Silent-scrub handling

`useLiveSliderWriter` reuses **one** `jobId` for an entire scrub, marks it silent (`markJobSilent`), and emits a `running→completed` arc **per frame** (`runBrowserGraphJob({ jobId: previewJobIdRef.current })`). Under the new model each frame emits `generation_complete` with incrementing `index` → without a gate, a 60-frame scrub = 60 variants. **This runs in-browser** (§8.5), so the browser-path normalize/autosave branches must exist for it.

**The gate relocates from `ResultsStore.upsertLiveGeneration:519` into the new `generation_complete` handler in `workflowUpdates.ts`** (frontend gate, Decision 2). **Both** writers that fire per scrub frame must honor it:

```ts
// in the generation_complete handler:
const slot = isSilentJob(jobId) ? 0 : index;   // pin scrub frames to slot 0
upsertLiveGeneration(wf, node, jobId, { index: slot, outputs, status: "completed" });
```

```ts
// in the node_update{running} placeholder write (§8.1) — also fires per scrub frame:
const slot = isSilentJob(jobId) ? 0 : undefined; // index-less for non-silent (newest-slot)
upsertLiveGeneration(wf, node, jobId,
  slot === 0 ? { index: 0, status: "running" } : { status: "running" });
```

`isSilentJob` is already imported in `workflowUpdates.ts:37`. `previewJobs.ts` and `useLiveSliderWriter` are unchanged. **Both halves matter:** §8.2 deletes the `isSilentJob` special-case from `upsertLiveGeneration`, so if the per-frame `running` placeholder does not also pin slot 0, it would append/churn variants via the index-less newest-slot fallback. The covering test `ResultsStore.variants.test.ts:154-183` (running→completed PER frame, 8 frames → 1 generation) relocates to a `generation_complete`-handler test that **drives running + generation_complete per frame for 8 frames** and asserts 1 live gen.

Keep the kernel dumb (always increment `index`); silence is a pure slider-preview UI concern, so the gate lives frontend-side. The browser runner already owns the jobId; we do not push a `silent` flag into the kernel.

---

## 10. Python parity — **VERDICT: TS-only for the targeted collapse. Zero `nodetool-core` change.**

Python nodes execute through the **same `NodeExecutor` interface** as TS nodes (`node-executor.ts:127-171`). `PythonNodeExecutor` implements **only** `process()` (`python-node-executor.ts:223-237`) and `genProcess()` (`:239-258`) — **never `run()`**. `process()` does the bridge RPC, calls `materializeOutputs()`, and **returns a fully-materialized plain JS dict** to the actor's `await`; `genProcess()` yields materialized partials. For Python the actor assigns these to `_latestResult` at sites `:369` (legacy single `process()`), `:994` (genProcess stream-end), `:1000` (correlated), `:1290` (controlled-loop) — **identically to TS**.

**Two corrections to the "identical sites" claim** (it was over-stated):
- **Site #5 (`run()` / `nodeOutputs.collected()`, `:357`) is TS-only.** `PythonNodeExecutor` has no `run()`; the actor's `run()` path requires `this._executor.run` (`actor.ts:286`). A Python node flagged `is_streaming_input` therefore falls into the **legacy single `process()`** branch (`actor.ts:358-371`, site #3), never site #5. So for Python, site #5 is dead and site #357 is never reached. The zero-Python-change conclusion is unaffected; the coverage claim is corrected.
- **Streaming-output multi-artifact nodes are out of scope (§5 site #4 invariant).** Python streaming-output nodes deliver every artifact as a per-yield `chunk` frame (worker `protocol.py:126-132`), and the terminal `result` is deliberately **empty** (`protocol.py:141-145`, `result_data={'outputs':{},'blobs':{}}` for `execute.stream`). So for a Python generator that yields N distinct artifacts on the **same slot**, the only place those artifacts exist distinctly is the per-yield boundary — which we route to display-only `output_update`. The genProcess stream-end `generation_complete` would carry only artifact N. **We scope this out** (§3, Decision 1 corollary) rather than fix it, because the faithful fix would require the wire to distinguish a "complete artifact" yield from a progressive chunk — a `final`/`complete` marker on `chunk` frames — which **would** require a `nodetool-core` (`worker/protocol.py`) change, contradicting the zero-Python-change goal. No such node exists today; the §13 guard test documents the limitation.

The bridge is **pure request/response RPC** keyed to `request_id` (`python-bridge-base.ts:172-267`) — it emits no `node_update`/`output_update`/generation/autosave concept. **Therefore `generation_complete` is emitted entirely in the TS `NodeActor`.** Python nodes get it for free for the targeted multi-execution collapse (the 6× `ListGenerator→TextToImage` case is a pure TS-actor correlated-drain phenomenon). No `packages/protocol` Python mirror, no `bridge-protocol.ts` change, no worker change. **Python-side scope: none.**

---

## 11. Protocol / serialization changes & full touch-point list

**msgpack is schemaless on both ends** — server encodes with `msgpackr` `pack` (`unified-websocket-runner.ts:7,1229`), client decodes with `@msgpack/msgpack` `decode` (`WebSocketManager.ts:242/249`). No `addExtension` / `Packr` registry / type table anywhere. **Any new `type` string round-trips for free; zero msgpack change.** There is no zod schema for the streaming `ProcessingMessage` union (the `api-schemas` zod is REST chat-thread only) — **zero zod change.**

Touch points (expanded to all consumers):
1. **`packages/protocol/src/messages.ts`:** add `interface GenerationComplete` (after `:178`); add to the `ProcessingMessage` union (`:633-653`) — auto-flows into `MessageType`/`MessageOfType`, re-exported via `index.ts:5`. Add `disposition?` + `done?` to `OutputUpdate` (`:201-210`).
2. **`packages/kernel/src/actor.ts`:** `_emitGenerationComplete` (bare, no `job_id`; `node_name = node.name ?? node.type`) + the 4–5 emit sites (§5); `_generationIndex` field.
3. **`packages/kernel/src/runner.ts`:** set `output_update.disposition` at `:1286-1294`; ensure `generation_complete` is retained (not audio-dropped) in `_emit` (`:1688-1708`).
4. **`packages/websocket/src/unified-websocket-runner.ts`:** add `generation_complete` to the processing gate `:1889-1892`; new autosave + normalize branch; delete `node_update` autosave `:1932-1953`. (Backfill at `:1855-1861` already stamps `job_id`/`workflow_id` — no change.)
5. **`web/src/lib/workflow/browserRunner.worker.ts` + `browserWorkflowRunner.ts`:** add `generation_complete` normalize/materialize branches (§8.5); confirm `job_id` backfill; add/confirm browser-path autosave (Decision 9).
6. **`web/src/stores/workflowUpdates.ts`:** new `generation_complete` branch + silent-scrub gate (both writers); demote `output_update` to disposition-aware; delete `completed→upsertLiveGeneration` from `node_update`.
7. **`web/src/stores/ResultsStore.ts`:** rewrite `upsertLiveGeneration` to append/replace-by-`index` with the index-less-patch contract; change `setOutputResult` to disposition-aware.
8. **`web/src/core/chat/chatProtocol.ts`:** `applyOutputUpdate` honors `disposition`; documented `generation_complete` decision (Decision 11).
9. **`web/src/hooks/timeline/useGenerateClip.ts` + `web/src/hooks/sketch/useGenerateLayer.ts`:** migrate asset extraction to `generation_complete.outputs`; thread `disposition` through their `setOutputResult` calls.
10. **`web/src/components/miniapps/hooks/useMiniAppRunner.ts`:** build result tiles from `generation_complete.outputs`.
11. **`mobile/src/stores/WorkflowRunner.ts` + `mobile/src/api.ts`:** add `generation_complete` case; regenerate message-type union; document `node_update.result` degradation (Decision 10).
12. **`packages/cli/src/websocket-client.ts`:** add `generation_complete` to the union and the JSON event stream (Decision 12).

---

## 12. Rollout plan

Ordered, each step shippable and green **on both run paths** (the original draft was green only for the server path):

1. **Protocol additive (no behavior change).** Add `GenerationComplete` to the union + `OutputUpdate.disposition?`/`done?`. Build `protocol`. Nothing emits or consumes it yet. *Back-compat: optional fields, additive union member — old clients ignore the new `type`.*
2. **Kernel emits *bare* `generation_complete`** at the 4–5 sites — **no `job_id` and no `index`** (per D8, both are stamped downstream: the server persist/relay seam derives `index` from DB ordering; the browser relay assigns an arrival-order `index`). Set `output_update.disposition`. Relay it through **both** the websocket (gate `:1889-1892`, backfill `job_id`+`index`, normalize `.outputs`) **and** the browser worker (assign arrival-order `index`, normalize `.outputs`, §8.5) **without** autosave yet. No consumer reacts → no behavior change. Add kernel emission tests (incl. the genProcess stream-end guard test).
3. **Frontend consumes `generation_complete`** (new reducer branch + `upsertLiveGeneration` rewrite incl. index-less-patch contract + silent-scrub gate on both writers). **Delete** the `node_update{completed}→upsertLiveGeneration` write and the ResultsStore Tier-2 hack **in the same commit**. Migrate `PreviewNode`, mini-apps, timeline/sketch asset extraction, and mobile in the same wave (each is independently testable but must not be left reading the demoted channel for artifacts). Now the timeline shows N variants live.
4. **Autosave hard-switch.** Add the `generation_complete` autosave branch and delete the `node_update` autosave **atomically** on the server (§7); land the **browser-path persistence decision** (Decision 9) in the same step. Now N assets persist on whichever paths are in scope.
5. **Demote `output_update` display** (disposition-aware append/replace across all 5 `setOutputResult` sites + `chatProtocol.ts`; ephemeral-clear on `generation_complete`). Cosmetic.
6. **Cleanup:** remove the now-dead `node_update.result`-as-artifact reads where a consumer fully migrated; tighten comments. (`node_update.result` survives as the documented mobile/skip-result degradation path.)

**Safe cutover sequence:** steps 1–2 are invisible (additive + emit-only on both paths). Step 3 swaps the generation driver in one commit. Step 4 swaps the autosave driver in one commit. Never run both drivers of either {generations, autosave} simultaneously.

**Client/server version skew (NEW).** Steps 1–2 (old-client/new-server) degrade safely — an old client ignores the new `type` and shows the old 1-variant behavior. The genuine risk is **new-client/old-server**: step 3 deletes the client's `node_update{completed}→upsertLiveGeneration` write, but an old server never emits `generation_complete` → **generations disappear entirely**. Electron bundles client+server in lockstep (verified: `web` and `websocket` both `0.7.0-rc.23`), so in-process is safe. **For any remote `nodetool serve` deployment where web and the websocket server version independently, gate the step-3 deletion behind a server-capability probe, or keep the `node_update` generation write until the server is known to emit `generation_complete`.** The optional `NODETOOL_GENERATION_EVENTS` flag stages a same-process cutover but does **not** cover client/server version skew. *(Open — see §15.)*

---

## 13. Test plan

**Kernel emission (`packages/kernel`):**
- Correlated 6× generator (`ListGenerator→TextToImage`) emits exactly 6 `generation_complete` with `index` 0–5, each distinct `outputs`, **one** `node_update{completed}`.
- Controlled-loop node emits one per `"run"` event.
- `genProcess` node with a final whole-value yield: N `output_update{disposition:"append"}` yields + **exactly one** `generation_complete` at stream-end carrying the **consolidated** value (assert NOT per-yield, assert value is complete).
- **Guard test (documented limitation):** a `genProcess` node whose last yield does **not** carry the full value (overwrite-merge on one slot) loses prior content in the single `generation_complete` — assert this so a future multi-artifact-per-slot streaming node author hits a failing test, not silent loss.
- `genProcess` under correlation (multiple keys): one `generation_complete` per key.
- `nodetool.constant.*` / `nodetool.input.*` emit **no** `generation_complete`.
- `generation_complete` is never edge-suppressed (intermediate generator feeding a Preview still emits it) and never audio-dropped.
- Actor emit carries no `job_id`; assert the runner relay stamps it (server **and** browser paths).

**Autosave (`packages/websocket`):**
- 6-gen run → 6 `Asset` rows, each with correct `node_id`/`job_id`.
- No double-save: assert `node_update{completed}` no longer triggers `autoSaveAssets`.
- **Replay idempotency:** re-emitting a **fresh** `generation_complete` object (asset_id unset, as a real reconnect would) is a no-op — requires the `(job_id, node_id, index)` guard from Decision 8, NOT just per-object `asset_id`. (Today's per-object guard would duplicate.)
- Text-output autosave still fires off `generation_complete.outputs`.

**Browser path (`web`):**
- In-browser `ListGenerator→TextToImage→Preview` end-to-end: 6 normalized variants render (assert `generation_complete.outputs` materialized — no broken images), and persistence matches Decision 9.

**Reducer / store (`web`):**
- `generation_complete{index:0,1,2}` → 3 variants (rewrite `ResultsStore.variants.test.ts:34-66`).
- Index-less patch routing: `node_update{running}` then `node_update{error}` with **no** intervening `generation_complete` settles to exactly **one errored** generation (no stuck `running`).
- `disposition:"append"` concatenates; `"replace"` overwrites the display buffer — assert across `workflowUpdates`, `chatProtocol`, `useGenerateClip`, `useGenerateLayer`.
- `node_update{completed}` no longer mutates `liveGenerations`.
- `mergeGenerations` collapses live N + persisted N (same jobId) → N.

**Silent-scrub:** 8 scrub frames, each driving `node_update{running}` **and** `generation_complete` under one silent jobId → **1** live generation (relocated from `ResultsStore.variants.test.ts:154-183`; covers both writers).

**Consumer surfaces:** mini-app result list shows N tiles for a multi-execution generator; timeline/sketch extract the asset from `generation_complete.outputs` (not `output_update`); mobile shows ≥1 artifact via the new case; CLI `--json` emits N `generation_complete` events.

---

## 14. DECISIONS TO LOCK

### Status — locked 2026-06-20
- **D1** ✅ A — per-`process()` boundary; multi-artifact-per-output-slot streaming generators out of scope.
- **D8** ✅ B (refined) — `index` from **DB ordering**, assigned at the persist/relay seam (not the actor).
- **D9** ✅ Provider-generations only, **always server-side**; **no** browser autosave; browser normalize mandatory.
- **D10** 🔶 Provisional — mobile gets the `generation_complete` case + data now; full variant UI fast-follow (awaiting confirm).
- **D11** ✅ B — chat shows variants.
- **D2, D3, D4, D5, D6, D7, D12** ✅ accepted as recommended.

**Decision 1 — Generation boundary & list handling.**
Options: **A)** one `generation_complete` per `process()` result; list-valued handles carried as arrays, consumers flatten (`runVariantValues` / recursive autosave collect). **B)** fan a list-valued result into one `generation_complete` per element in the actor.
**Recommend: A** — the actor already hands whole result dicts to `_sendOutputs`; passing the same dict avoids teaching the actor per-handle list semantics, and `runVariantValues` + `autoSaveAssets`'s recursive `collect` (`:448-464`) already flatten. **Corollary (errors):** keep node-level errors on `node_update{error}`; `generation_complete` represents committed artifacts only (no error variant in the shape). A run that errors before committing emits no `generation_complete` — correct. **Corollary (streaming):** the genProcess stream-end `generation_complete` carries the overwrite-merged `_streamingCollectedOutputs`, so multi-artifact-per-output-slot streaming generators are **out of scope** (§5/§10); guard-tested in §13.
**DECIDED: A** — confirmed, including the streaming scope-out (multi-artifact-per-output-slot streaming generators are not covered this cycle; documented + guard-tested).

**Decision 2 — Silent-scrub gate location & mechanism.**
Options: **A)** backend pins `index=0` for silent jobs (kernel must learn "silent"). **B)** frontend gate in the `generation_complete` handler **and** the `node_update{running}` placeholder: `isSilentJob(jobId)` → pin slot 0, else append by `index` (or newest-slot for index-less running/error).
**Recommend: B** — silence is a pure slider-preview UI concern; the kernel stays dumb. The gate must cover **both** per-frame writers (generation_complete and the running placeholder), since §8.2 removes the `isSilentJob` special-case from `upsertLiveGeneration`. `isSilentJob` already imported in `workflowUpdates.ts:37`.

**Decision 3 — Autosave cutover.**
Options: **A)** dual-write (both `node_update` and `generation_complete` autosave during migration). **B)** hard switch (delete old, add new in one commit).
**Recommend: B** — **corrected rationale:** runs `1..N-1` exist **only** on `generation_complete` (node_update collapses to the last), so dual-write under-saves the early runs regardless; and the cross-channel `asset_id` idempotency guard is **reference-based** — it protects the shared last-result object on the `process()` path (same instance at `actor.ts:1000/1290`, so dual-write would be a no-op there, NOT a double-save as the draft claimed) but does **not** protect the `genProcess` fresh-spread path (`:994`), where dual-write **would** double-save the final artifact. Atomic swap is the only clean option. (If a dark-launch is mandated, gate both with one flag, never both ON.)

**Decision 4 — `output_update.disposition` + ephemeral lifecycle.**
Options: **A)** add `disposition: "append" | "replace"` (+ optional `done`); clear the buffer on run-start and on `generation_complete` for that artifact; thread `disposition` through **all 5** `setOutputResult` call sites. **B)** keep hardcoded append; clear only on run-start.
**Recommend: A** — fixes the progressive-preview latent bug (`workflowUpdates.ts:515` always-append) and makes whole-value snapshots correct. Absent `disposition` defaults to `"append"` for back-compat. Note the call-site count: `workflowUpdates.ts:515`, `chatProtocol.ts:514`, `useGenerateClip.ts:186`, `useGenerateLayer.ts:227` — missing any re-introduces the bug in that consumer.

**Decision 5 — Keep the name `output_update` vs rename.**
Options: **A)** keep `output_update`. **B)** rename to `display_update` / `output_chunk`.
**Recommend: A** — renaming churns the protocol union, both web reducers, `chatProtocol.ts`, mobile, CLI, the relay, and every display-sink component for a cosmetic gain; the `disposition` field already encodes the honest semantics. Revisit only if a future cleanup pass touches all sites anyway.

**Decision 6 — Do connected content-card/text nodes still emit `output_update` for live streaming?**
Options: **A)** yes — keep emitting for live token/preview display (now purely cosmetic). **B)** stop emitting for edge-connected handles (rely only on `generation_complete`).
**Recommend: A** — live token streaming and progressive previews are real UX that `generation_complete` (fires only at commit) can't provide. Edge-suppression already trims the firehose where it matters; the surviving emits are harmless display. Keep them.

**Decision 7 — How do `constant`/`input`/`skipResult` node values reach display sinks?**
Context: §5 skips `generation_complete` for `nodetool.constant.*` / `nodetool.input.*`; `node_update.result` is demoted to advisory; `output_update` is suppressible on edge-connected handles. That risks a value on none of the three authoritative channels.
Options: **A)** these nodes' downstream **consumers** are what emit `generation_complete` (the constant/input value flows as an input, and the consuming node commits its own artifact) — the constant/input node itself has no savable artifact and needs none; client-authored constants are already in the graph state, and server-resolved inputs surface via their consumer. Keep `node_update.result` as the **explicit** display fallback for the rare standalone constant/input preview. **B)** emit `generation_complete` for these too.
**Recommend: A** — but **state it explicitly** rather than leaving the "client already holds the values" hand-wave: a constant/input feeding a connected node has its value carried into the consumer's `generation_complete`; a standalone constant/input previewed directly reads `node_update.result` (the one sanctioned `result` read, scoped to skip-result node types). This reconciles "don't read `result` for artifacts" (multi-execution generators) with "skip-result nodes use `result`" (they have no generation).

**Decision 8 — Replay/duplicate-asset idempotency.**
Options: **A)** per-object `asset_id` guard only (today). **B)** add a persisted-layer idempotency key on `(job_id, node_id, index)` — skip autosave for an already-persisted tuple.
**Recommend: B** — a real reconnect/replay re-emits a **fresh** `generation_complete` (asset_id unset on a new object), so the reference-based guard does not fire → duplicate `Asset` rows sharing `job_id` but new ids. Since persisted assets carry no `index` today, add `index` to the autosave dedupe (either a stored column or an in-run `(node_id, index)` set per job) so replay is a true no-op. Without B, the §13 "replay is a no-op" test fails for genuine reconnects.
**DECIDED: B, refined — `index` is assigned from DB ordering at the server persist/relay seam, not by the actor.** The `unified-websocket-runner` interception that backfills `job_id` (`:1855-1861`) persists the asset, derives `index` from the DB ordering of `(job_id, node_id)`'s assets, stamps it onto `generation_complete` **before relay**, and dedupes autosave on `(job_id, node_id, index)` → replay is a true no-op. Live ordering uses the stamped DB index (available because stamping happens in the same pre-relay pass). The browser path (no persist) falls back to an arrival-order index.

**Decision 9 — Browser-path persistence.**
Options: **A)** add a browser-side autosave hook keyed on `generation_complete` mirroring `unified-websocket-runner`'s branch. **B)** confirm + document that browser-path generative runs already round-trip persistence to the server (relying on the `job_update{completed}` asset reload), and that `generation_complete` in-browser is display-only.
**Recommend: decide before step 4 ships.** The browser path calls `autoSaveAssets` **nowhere** (verified). If browser jobs do not reach the server autosave gate, option A is required or the "every committed artifact is persisted" goal fails for browser-eligible (and silent-scrub) workflows. Regardless of A/B, the browser **normalize** branch for `generation_complete.outputs` is **mandatory** (§8.5) for correct rendering.
**DECIDED: B — persistence is provider-generations only, always server-side; no browser autosave hook.** Browser/non-provider generations are display-only and not persisted. Provider calls (fal/replicate/LLM/etc.) already execute server-side, so every persistable artifact hits the server autosave gate by construction. Option A (browser autosave) is rejected. The browser `generation_complete.outputs` **normalize** branch (§8.5) remains **mandatory** for rendering; only the persistence half is dropped from the browser path.

**Decision 10 — Mobile degradation.**
Options: **A)** mobile adds a `generation_complete` case but continues to show 1-artifact-per-node via `node_update.result` (graceful degradation, no variant UI). **B)** mobile renders full variant lists from `generation_complete`.
**Recommend: A for this cycle** — state it as an **intended** degradation, regenerate `mobile/src/api.ts` types, and add the case so multi-execution at least shows the latest. Variant UI on mobile is a follow-up.
**DECIDED (provisional): add the `generation_complete` case + regenerate types now; full variant UI as a fast-follow.** Mobile shows the latest immediately and does not block the cutover. *Awaiting confirmation on whether mobile variant UI is wanted in this cycle.*

**Decision 11 — Chat/agent path & `generation_complete`.**
Options: **A)** chat ignores `generation_complete` (display-only via `output_update`/`media_generation`); document why. **B)** chat gets its own `generation_complete` → variant handling.
**Recommend: A for this cycle** — chat surfaces live media via existing chunk handling; workflow-tool multi-execution variants are not a chat UX today. Document the decision; don't leave it implicit. `applyOutputUpdate` still honors `disposition` regardless.
**DECIDED: B — chat shows variants.** Chat gets full `generation_complete` → variant handling (overrides the degrade recommendation). `applyOutputUpdate` still honors `disposition`.

**Decision 12 — CLI semantics.**
Options: **A)** surface each `generation_complete` as its own event in the `--json` stream. **B)** aggregate into the final result.
**Recommend: A** — preserves the N-artifact information the kernel now exposes; add the type to the CLI union. Lowest priority but must not silently drop variants.

---

## 15. Risks & open questions

- **Storage volume.** N-per-run autosave means previously-discarded intermediates now persist (a 100-frame batch generator writes 100 assets). Confirm desired; consider a per-node `auto_save_asset` cap or an explicit "save intermediates" flag for very-high-N generators. *(Open — needs product call.)*
- **Browser-path persistence (Decision 9).** The single biggest open item: browser jobs call `autoSaveAssets` nowhere. Until Decision 9 lands, N-asset persistence is unproven for browser-eligible and silent-scrub runs. The browser normalize branch is mandatory independent of the persistence decision.
- **Replay duplicate assets (Decision 8).** Persisted assets carry no `index`; the only dedupe is the per-object `asset_id` mutation. A reconnect re-emits fresh `generation_complete` objects → duplicate rows. Needs `(job_id, node_id, index)` idempotency at the autosave layer.
- **`_messages` truncation vs reconnect replay.** `generation_complete` is pushed into `_messages`, but `_messages` is **truncated** to `MAX_RETAINED_MESSAGES/2` past 10,000 (`runner.ts:1697-1700`) and is **not** the autosave source (autosave consumes the live relay stream in `unified-websocket-runner`, not `_messages`). Drop the draft's "`_messages` authoritative for autosave" claim. For a very-high-N reconnect, early `generation_complete` entries can be evicted from replay; persisted assets recover them via the `job_update{completed}` reload — verify the reconnect replay path preserves `index` ordering for what survives, and document that very-high-N reconnects may drop early *live* variants from replay (persisted side recovers them).
- **`running` placeholder + index-less patches.** `generation_complete` fires only at commit, so the pre-first-artifact spinner relies on the `node_update{running}→upsertLiveGeneration` placeholder; a node that errors before any artifact must be settled by the index-less `node_update{error}` patch via the newest-slot fallback (§8.2). Tested in §13.
- **`job_id`/`node_name` in the actor.** Resolved (was asserted-resolved-but-wrong): the actor has **no** `job_id`; the runner relay stamps it at `unified-websocket-runner.ts:1855-1861`. `node_name = node.name ?? node.type`, not `node.data?.title`. The browser relay must apply the same stamping.
- **Client/server version skew (§12).** New-client/old-server erases generations (client deletes the `node_update` write; old server never emits `generation_complete`). Lockstep in Electron (verified `0.7.0-rc.23`); for remote `serve`, gate the step-3 deletion behind a capability probe or keep the `node_update` write until the server is known to emit the new event.
- **`mergeGenerations` jobId coupling.** The live/persisted collapse depends on all N variants and N assets sharing one `jobId` (`nodeGenerations.ts:74-82`, `assetToGeneration` at `:58-60`). `index` must not leak into the jobId match. Verified the merge groups purely by `jobId`; confirm `assetToGeneration` keeps grouping intact when N assets land.
- **All consumers migrated.** The consumer census (§8.5–§8.11) must be complete; a missed reader of `node_update.result`/`output_update.value` as an artifact silently loses variants. Re-run the grep before sign-off.

---

## Appendix — Addressed review notes

Findings verified against the working tree; each is either incorporated above or marked corrected-as-wrong with why.

1. **Double-save rationale inverted (high) — INCORPORATED + CORRECTED.** Verified `actor.ts:1000`/`:1290` assign `_latestResult = outputs` (same ref as the would-be `generation_complete.outputs`), and the `autoSaveAssets` guard (`:468`/`:518-520`) is reference-based. The draft's "different instances → double-save" was inverted for the `process()` path (it'd be a no-op there); the real double-save risk is the `genProcess` fresh-spread path (`:994`). Rationale rewritten in §7 and Decision 3 around (a) under-save of runs 1..N-1 and (b) the genProcess-only double-save.

2. **genProcess stream-end overwrite-merge (high/critical) — INCORPORATED.** Verified `_streamingCollectedOutputs` is `Object.assign` overwrite-merge (`:984`) then `{...}` (`:994`). Stated the load-bearing invariant in §5, scoped multi-artifact-per-slot streaming out (§3, Decision 1 corollary, §10), added a guard test (§13).

3. **Index-less running/error patches into an index-keyed store (medium) — INCORPORATED.** Specified the index-less-patch contract in §8.2 (newest-slot-by-jobId fallback retained for `running`/`error`; explicit `index` only from `generation_complete`) + a §13 test for running→error-with-no-generation settling to one errored gen.

4./21. **Actor `job_id`/`node_name` wrong-as-written (high/medium) — INCORPORATED + CORRECTED.** Verified zero `job_id` in `actor.ts`; backfill at `:1855-1861`; `_emitNodeStatus` uses `node.name ?? node.type`. §5 emit snippet now omits `job_id` and uses `node.name`; §15 open question resolved.

5./15./16. **Browser path autosave + normalize gaps (critical/high) — INCORPORATED.** Verified `autoSaveAssets` only in `unified-websocket-runner`; browser normalize only handles `.result`/`.value` (`browserWorkflowRunner.ts:435-441`, `browserRunner.worker.ts:187-203`). Added §8.5, Decision 9, touch-point #5, browser test, and rollout/§3 corrections.

6./7. (Duplicate of 4 from a second reviewer.) — Same correction; consolidated.

8. **Silent-scrub running placeholder (high) — INCORPORATED.** §9 now gates **both** the `generation_complete` write and the per-frame `running` placeholder on `isSilentJob`; §13 test drives both halves per frame.

9. **`_messages` retention vs "authoritative for autosave" (medium) — INCORPORATED + CORRECTED.** Verified `MAX_RETAINED_MESSAGES=10_000` slice (`:1697-1700`) and that autosave reads the live relay, not `_messages`. Dropped the "authoritative for autosave" claim; §15 documents truncation + reconnect implications.

10. **Replay duplicate assets (medium) — INCORPORATED.** Verified `Asset` has `node_id`/`job_id` but no `index`; guard is per-object. Added Decision 8 (`(job_id, node_id, index)` idempotency) and a fresh-object replay test.

11. **New-client/old-server skew (medium) — INCORPORATED.** Verified `web` and `websocket` are lockstep `0.7.0-rc.23`. §12 documents Electron lockstep vs remote-serve risk and the capability-probe mitigation.

12./13. **Python streaming-output + streaming-input site coverage (high/medium) — INCORPORATED.** Verified `PythonNodeExecutor` implements only `process()`/`genProcess()` (no `run()`), so site #5 is TS-only and Python streaming-input uses site #3. Verified the empty terminal `result` for `execute.stream`. §10 corrected; streaming-output multi-artifact scoped out.

**Critical (mobile)** — INCORPORATED (§8.6, Decision 10, touch-point #11).
**Critical (timeline/sketch)** — INCORPORATED (§8.8, touch-point #9). Verified the `selectedOutputNodeId`/`jobOutputs`/`extractAssetId`/fail-job pattern.
**High (browser worker)** — INCORPORATED (folded into §8.5).
**High (mini-apps)** — INCORPORATED (§8.9, touch-point #10). Verified result tiles built entirely from `output_update`.
**High (browser autosave)** — INCORPORATED (Decision 9, §15).
**Medium (CLI)** — INCORPORATED (§8.11, Decision 12, touch-point #12).
**Medium (chat generation)** — INCORPORATED (§8.10, Decision 11).
**Medium (constant/input leak)** — INCORPORATED (Decision 7). Verified the `skipResult` set (`actor.ts:437-439`) and reconciled the `result`-read rules.
**Medium (setOutputResult call sites)** — INCORPORATED (§8.7, Decision 4). Verified 4 runtime call sites + 1 doc comment.

No findings were dropped as wrong; the two that were *partially* wrong (the double-save rationale and the `_messages`-authoritative claim) are corrected in-body rather than silently removed.
