Files
Aura/docs/design/contracts/c27-declared-taps.md
T
claude 98342246f6 docs(ledger, glossary): ratify the fold summary-row timestamp semantics
Fieldtest spec_gap (#335): the timestamp a fold's one summary row
carries was undocumented. Ratified as-is (derived decision, minuted on
the issue): the row is emitted at finalize and stamped with the instant
of the last contributing (warm) value (TapFold::last_ts) — first alone
pins the first contributing instant, and min/max deliberately do not
carry the extremum's instant (a whole-window row privileges no interior
instant, and the extremum ts would cost extra state for no consumer).

The issue's initial finalize-ts reading (and the first draft of this
ratification) was refuted in review against tap_fold.rs: a tap that
goes cold before run end stamps the last WARM eval's instant, not the
run-end instant — observationally identical only while the tap stays
warm to the end, which is what the fieldtest fixtures did. Correction
minuted on the issue.

Recorded in C27's Current state and the glossary tap-plan paragraph;
the fold-registry roster doc lines state it on the surface itself
(sibling commit).

closes #335
2026-07-24 16:29:50 +02:00

6.9 KiB

C27 — Declared taps: named measurement points bind sinks run-mode-aware

Guarantee. A blueprint may declare taps — named, pure output-side declarations { name, from: {node, field} }, the output-side twin of input_roles (C26). A tap names an interior producer's output field without naming a sink, exactly as a Role names an abstract input without naming a source. At compile the tap resolves — and, for an interior composite, hoists to the root — through the same lowering remap edges and OutField re-exports use (resolve_tap_wire, a flat_taps accumulator threaded through the lowering recursion), landing in FlatGraph.taps as a FlatTap { name, node, field } whose name survives compile and is load-bearing for by-name binding (like SourceSpec.role, #275).

Binding is run-mode-aware: the run-mode-owning layer constructs a consumer at a bound tap via FlatGraph::bind_tap, which takes a caller-built Box<dyn Node> sink (so the engine keeps its aura-core-only production dependency — it never constructs a domain sink type) and appends it plus an edge before bootstrap. What consumes a tap is declared per run by a tap plan (#283): a Named { label, params } subscription resolves against a layered fold registry (core vocabulary record | count | sum | mean | min | max | first | last; growth is a new Rust entry, C25, injectable by higher layers without a core edit), and Live(closure) is the single deliberately non-data variant. Both declared-tap entry points — the single-run path (run_signal_r) and the bare measurement path (run_measurement) — bind through one shared wiring pair, so they cannot drift: record persists the full series at constant memory through the trace store's streamed write path (env.trace_store()), folds keep an O(1) accumulator and land one summary row at finalize, so the tap columns surface through the same tooling the campaign path feeds; a sweep/reduce run leaves taps unbound.

Forbids. A tap carrying a channel endpoint or effect in the serialized artefact — recording policy is run-mode authority, not fragment-embedded (a fragment must not drag its measurement decisions into every harness that embeds it; the tier ontology, C20/C21). The engine constructing a domain sink type (the aura-core-only wall — the sink is caller-built). Order statistics (median, etc.) inside the graph — they stay sink/analysis-side (C18); multi-instrument study inputs stay harness/World tier.

Non-error. An unbound tap is inert, not a fault — unlike an unbound root input role, which check_root_roles_bound rejects (C26): observation is optional, a fed input is mandatory. A declared-but-unbound tap compiles and runs, its producer evaluating and its output discarded (a no-out-edge producer is a valid runnable sink — the Kahn sort emits it, check_ports_connected gates only inputs).

Why. Observability must be expressible in a hand-authored blueprint — the measurement-shaped study computes in the graph and surfaces via taps, no throwaway Rust harness — while recording stays a run-mode decision, not a fragment-embedded effect. Taps are designed DCE-compatible (a bound tap is a natural DCE root, an unbound tap a dead declaration) but this contract does not depend on DCE: an unbound tap's sink is simply never constructed (build-time elision, which the engine already tolerates). (#282, 2026-07-18.)

Current state

The tap types are realized in aura-engine: the authoring-level Tap (crates/aura-engine/src/blueprint.rs, the output-side twin of Role), the compiled FlatTap { name, node, field } and the FlatGraph.taps field (crates/aura-engine/src/harness.rs), and FlatGraph::bind_tap, which appends a caller-built Box<dyn Node> sink plus an edge and raises a typed UndeclaredTap on a tap the graph does not declare (duplicate detection across binds is the caller's — the method keeps no cross-call state). Lowering resolves and hoists taps via resolve_tap_wire and the flat_taps accumulator (blueprint.rs). The subscription seam is aura-runner::tap_plan (#283): TapPlan/TapSubscription, the layered FoldRegistry (each entry carries a doc line — the help surface and the roster-enumerating refusal — plus a scalar-typed param schema; all core entries are param-less today, the seam ships in every entry's build signature), and the shared bind_tap_plan/BoundTaps pair called by both declared-tap entry points, run_signal_r (aura-runner::member) and run_measurement (aura-runner::measure) — both arms of the single CLI verb aura run, whose repeatable --tap TAP=FOLD selector (#310) makes the Named selection data-reachable: no flag keeps the record-all default, any flag replaces the plan entirely (unlisted taps stay unbound/inert). The boundary is thereby fixed in place: selecting a subscription is run-mode authority, exercised by the run-mode owner — on the one-shot path the CLI invocation itself, a projection exercising this contract's authority, not a second home for intent under C25 — while adding a fold stays a Rust entry (role 2). The campaign/document carrier, and the reconciliation of the presentation-tap namespace (persist_taps) with declared taps it requires, is deferred to the Measurement-reachable milestone (#312/#327, minuted on #312). The record consumer (aura-runner::tap_recorder::TapRecorder) holds the trace store's streaming writer in-graph — initialize opens (deferred acquisition), eval appends (Timestamp, Cell) (zero per-cycle heap, #77), finalize reports exactly one terminal outcome; fold consumers (aura-std::TapFold) land one summary row, emitted at finalize and stamped with the instant of the last contributing (warm) value — first alone pins the first contributing instant; min/max deliberately do not carry the extremum's timestamp (a whole-window row privileges no interior instant) — ratified as-is, #335; live closures run inline (aura-std::TapLive). The sweep/reduce path never calls bind_tap.

The chain-pruning benefit — a sweep paying zero for the study wires behind an unbound tap — is deferred to the future DCE cycle (C23); the mechanism ships now, verified sound.

See also

  • C26 — the input-side twin (input_roles); check_root_roles_bound, the mandatory-input counterpart
  • C23 — compilation/lowering and the deferred DCE cycle the tap design anticipates
  • C18 — the trace store and registry the tap series feed; order statistics live sink-side
  • C20, C21 — the tier ontology behind run-mode recording authority
  • C8 — the node/sink contract (a no-out-edge producer as a valid runnable sink)