docs(ledger): C27 — declared taps contract (#282)

Records the tap/binding contract: taps are the output-side twin of input_roles
(C26), resolved and hoisted at compile, bound run-mode-aware via a caller-built
sink (engine stays aura-core-only), recorded on the single run and inert in a
sweep. Documents the deliberate DCE-deferral — build-time elision now,
chain-pruning when DCE (C23) lands — and the unbound-is-inert non-error
asymmetry vs a mandatory input role.

refs #282
This commit is contained in:
2026-07-18 00:17:01 +02:00
parent 2e8d74903c
commit 7a4e5eb99d
+43
View File
@@ -2542,6 +2542,49 @@ plan so the two halves cannot drift (C1/C4 determinism).
---
### 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 sink (a `Recorder`) 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. The single-run path (`run_signal_r`)
binds and records each declared tap, persisting the series through the existing
trace store (`env.trace_store()`), 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). 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. (#282, 2026-07-18.)
---
## Open architectural threads not yet resolved
- **Playground & World UI surface** — the playground is core (C22); the surface