# C8 — The node contract: history > FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp and may be superseded; this file is NOT current truth and NOT a grounding surface. Current contract: [c08-node-contract.md](c08-node-contract.md). **Realization (cycle 0005).** `NodeSchema.output` is a `Vec` (named base columns; length 1 = scalar). Binding is **field-wise only**: `Edge::from_field` selects one producer column per edge; consuming a whole record is N edges (no "bind whole record" mechanism). The K fields of one record are **co-fresh by construction** (one `eval`, one timestamp), so C6 is untouched. `eval` returns `Option<&[Cell]>` — a borrowed row into a node-owned buffer — so the forward path allocates nothing per cycle (C7) (the carrier is now a tag-free `Cell` — see the C7 carrier note). **Realization (cycle 0006).** The pure-consumer (sink) half of this contract is now realized at the substrate: **recording is a node role, not a type.** A recording node reads its typed input windows + `ctx.now()` in `eval` and pushes the record to a destination it holds as a field (a channel, a chart handle) — an **out-of-graph side effect**. There is no `Sink` type, trait, or engine flag: a node that only records returns `None` (pure consumer), and a node may record **and** return an output the engine forwards in the same `eval` (the "both" case). **Encoding & return contract.** A pure consumer declares `output: vec![]` — the empty record *is* the sink declaration; there is no separate type, trait, or marker. Its `eval` returns `None` or a zero-width `Some(&[])`, and the run loop debug-asserts the returned row's width equals the declared output width (`row.len() == schema.output.len()`). Field-wise wiring resolves `Edge::from_field` against the producer's `output` at bootstrap, so no edge can bind a field of a zero-output node — it fails with `BadIndex` — making a sink structurally unwireable as an in-graph producer; its only output is the out-of-graph side effect. In-graph routing stays engine-owned data (the edge table); the escape out of the graph is the node's own side effect — and that boundary is the determinism / graph-as-data boundary (C1/C7). **Realization (cycle 0015 — param declaration).** The tunable-parameter half of this contract is now realized: a node declares its knobs in `schema()` as `params: Vec` (`ParamSpec { name: String, kind: ScalarKind }`), and `Composite::param_space()` (cycle 0024; was `Blueprint::param_space()`) aggregates them into one flat, path-qualified list — a read-only projection of the graph-as-data (C9), mirroring the inline order (C19/C23) so a param's slot matches the later flat-node order. Two refinements to the guarantee's "typed, with ranges": (1) the declaration carries `name` + `kind` only — the **search-range is the run's, not the node's** (which subset / grid a sweep covers is an experiment axis, #32/C20; the node declares the knob's existence and type, never its search interval). (2) Identity is **positional** (the slot, C23 "by index, not name"); the path-qualified `name` is a non-load-bearing debug symbol (like `FieldSpec.name`) — uniqueness is at the slot. **Refinement (cycle 0032):** identity in the **flat graph** stays positional (C23, unchanged), but the `param_space()` **name projection** — the authoring / by-name address space (the surface a sweep axis or single-run binding addresses) — must be **injective** for a blueprint to compile (a duplicated path is unaddressable; see C9). Two layers: positional wiring below (the flat graph), an injective name address space above (the authoring boundary). So same-type siblings in one composite no longer silently share a name — they must be `.named(...)` apart, which is also what disambiguates a same-type fan-in (C9). A vector knob (`LinComb.weights`) expands to `N` flat `weights[i]` entries, `N` topology-fixed (C19). Permitted kinds are `i64`/`f64`/ `bool` (a `timestamp` knob is a structural axis, C20, never a numeric sweep param). Binding a value to a slot landed in cycle 0016 (#31, see C19/C23); enumerating a sweep (#32) is the deferred next layer. The 0016 binding also moved the param declaration's *authoring* home into the value-empty recipe, whose `params()` is read pre-build. In 0016–0023 the built node's `schema().params` still reported the same slots, kept in lockstep by a per-node test (a duplication filed as debt, #36); **cycle 0024 dissolved this** — the signature is declared *once* on the recipe and the built node no longer carries `schema()` (#36 closed). See the C8 cycle-0024 realization. **Realization (cycle 0024 — the signature lives in the blueprint, #43/#36).** The node's whole declared interface — its `NodeSchema` (input scalar types + firing, output record, params) — now lives **once**, on the value-empty recipe `PrimitiveBuilder` (ex-`LeafFactory`), read pre-build by `param_space()`, the compile-time structural validation, and the render. This closes two debts: **#43** (a value-empty recipe used to declare *no* input/output interface pre-build — only params) and **#36** (params declared twice, recipe vs built node, kept in lockstep by a per-node test — those 8 tests are deleted, the duplication structurally gone). The split that makes this work: a node's signature is **fully static** per blueprint (input kinds/firing, output fields, params — verified across the roster; a variable- arity node like `LinComb` takes its arity as a *recipe argument*, not an injected param), so it can be declared without building; the **one** param-dependent quantity, an input's buffer **lookback** depth, is no longer in the signature but answered by `Node::lookbacks() -> Vec`, read only by bootstrap to size the windows. `Node::schema()` is therefore **removed** — the signature is pre-build data, not a built-node method. `BlueprintNode::signature()` answers uniformly for both arms (a primitive returns its recipe's schema; a composite *derives* it from the interior: role kinds in, re-exported field kinds out, aggregated params), so "every node has a signature in the blueprint" holds for composites too. **Realization (cycle 0027 — name input ports, refs #21/#51).** `PortSpec` now carries a `name: String` (it drops `Copy`, like `ParamSpec`), so an input port is named just as `FieldSpec.name` (output) and `ParamSpec.name` (param) already are. The name is **non-load-bearing** (C23): wiring stays positional by slot, bootstrap and the run loop never read it; it exists for tracing / graph rendering (#13). Leaf primitives declare their slot names (`SimBroker`'s `exposure`/`price`, etc.); `derive_signature` carries a composite's `Role.name` into the derived input port, so the graph model (`model_to_json`) is homogeneously named across inputs, outputs, and params and across both graph levels. This does **not** close #21 (a swapped same-kind wiring is still only kind-checked) — it makes those slots self-documenting; a name-consuming validation is its own future cycle. **Realization (cycle 0040 — wiring totality: every input slot connected exactly once, #65).** The node contract's "the engine provides a window into each input" presupposes each input is actually fed; until now an unwired interior input slot was accepted and bootstrapped to a silent empty column (`harness.rs`), and two producers into one slot — ill-formed, since a slot holds one column — was likewise uncompiled-against. Cycle 0040 makes a valid graph **total and single-valued** over its interior input slots: `check_ports_connected` (in `validate_wiring`, run at every nesting level) requires every interior node's every declared input slot to be covered by **exactly one** wiring act — one interior `Edge { to, slot }` or one role `Target { node, slot }`, edges and role targets counted uniformly. Zero coverage is `CompileError::UnconnectedPort`, more than one is `CompileError::DoubleWiredPort`. A composite's **own** input roles (`source: None`) are coverage *providers* (the wired-by-enclosing boundary, the root case already guarded by `UnboundRootRole`) — never consumers, so only interior input slots are subject to the rule. There is **no optional-input concept**: every declared input port is required (no shipped node runs meaningfully without one; an unwarmed mode-A input is *wired-but-not-yet-valued*, not unwired). The check is **index-based and name-free** — it touches no name machinery and emits nothing into the flat graph, so C23 is untouched (it proves the existing raw-index wiring is total and single-valued). Inherited identically by the raw `Composite::new` path and the `GraphBuilder::build()` path (both compile via `compile_with_params`). **Refinement (Construction-layer milestone — render labels, 2026-06-05).** A node additionally exposes `label() -> String`, a **single-line, non-load-bearing** render symbol: a default trait method the run loop never calls (wiring is by index, C23). Overrides carry the node's identifying params (`SMA(2)` vs `SMA(4)`) so a graph render (C9 graph-as-data, #13) disambiguates identical node types and surfaces a mis-wiring. Like `FieldSpec.name`, it is an informative debug symbol, not part of the C8 dataflow contract — adding it changes no run behaviour. **Refinement (cycle 0070 — end-of-stream `finalize` lifecycle, 2026-06-25).** The node contract gains a second lifecycle phase beside `eval`: `finalize(&mut self)`, a **default-no-op** trait method the engine calls **once per node, in topological order, after the source loop drains** (the `Harness::run` epilogue). It lets a sink **fold online** — accumulate per `eval` into O(trades)/O(1) owned state and flush one compact summary at stream end — instead of pushing a record every fired cycle into an unbounded channel that buffers O(cycles) rows until the run ends (the retention BLOCKER #138 hit: a full-history Stage-1 sweep held ~2 GiB/member over ~5.5M one-minute bars). C1/C7/C8 are preserved: `finalize` runs once *after* the deterministic event loop, adding no within-sim concurrency (C1); a folding sink holds only owned accumulator state, no interior mutability (C7); it still declares `output: vec![]` and its flush is the same out-of-graph side effect (C8) — one summary row, not a per-cycle stream. Realized by `GatedRecorder` (emits only gated rows + the genuine final row) and `SeriesReducer` (folds one column, emits one summary row), aura-std siblings of the per-cycle `Recorder`, which survives for the live / `--trace` / test-tap path. The recording sink's *own* per-cycle allocation (the `Recorder`→`Probe` rename + its accumulate-vs-stream choice, #77) stays open; `finalize` is the "non-channel exit from the graph" that issue's accumulate-then-read option named as missing. **Realization (2026-07-11 — composite `doc`, #125).** `Composite` carries an optional authored rationale `doc: Option` — the prose twin of its `name` and the same C23 category: a non-load-bearing debug symbol. Authored via `Composite::with_doc` / `GraphBuilder::doc`; persisted as a Tier-1 additive-optional `CompositeData` field (no format-version bump; absent-field documents keep their exact bytes, so existing content ids are untouched); **blanked in the identity projection** (`strip_debug_symbols`) while staying canonical-byte-bearing; dissolved at inline like the name (never reaches `FlatGraph`). Surfaced read-only (C22): the graph model emits an optional trailing `"doc"` fragment per scope (`json_str` hardened for multi-line free text — `\n`/`\t`/`\r` named, other control chars as `\u00XX`), the viewer shows it in both composite view states (collapsed tooltip, expanded cluster frame) and the root's as a muted header line. The construction op-script vocabulary deliberately has no doc-carrying surface yet (scope cut recorded on #125). **Refinement (2026-07-21 — start-of-stream `initialize` lifecycle; #77 resolved, #283).** The contract gains `finalize`'s mirror: `initialize()`, default no-op, called once per node in topological order before the first source value (a `Harness::run` prologue with its own once-per-run mirror test). Consumers acquire run resources there — the in-graph record consumer (`TapRecorder`, `aura-runner`) opens its streaming trace writer in `initialize`, appends per fired warm cycle, and finishes/reports exactly one terminal outcome at `finalize`; the hook is infallible by signature, so an acquisition failure degrades the node to inert and surfaces once, terminally, at `finalize`. This closes the #77 line the cycle-0070 entry left open: declared-tap consumers now move `(Timestamp, Cell)` (zero per-cycle heap), and the `Recorder`→`Probe` rename is retired — `Recorder` keeps its name on the legacy live/`--trace`/test-tap surface, whose migration onto the subscriber shape is tracked as #308.