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
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)