ntl_core::signal — note the underscore: the crate is ntl_core, not ntl.
The two things to know first
ntl-core is synchronous. It contains no async functions at all, so
there is no .await anywhere below. That is what keeps the crate building for
wasm32-unknown-unknown, which CI enforces on every commit. Async lives one
layer out, in ntl-net.
A builder does not emit. Signal::data and its siblings return a
SignalBuilder, and the builder is handed to a node — node.emit(builder) —
which stamps origin, identifier and timestamp. There is no SignalBuilder::emit.
Every sample on this page is compiled and executed by
runtime/ntl-core/tests/api_reference_signal.rs. If the API changes, that
test stops building — which is the only way documentation like this stays
true. cargo doc --open remains the authoritative reference.Constructors
There are six, plusSignal::receipt. Each returns a
SignalBuilder. discovery and heartbeat take no topic; the rest take
&str.
build_unsigned(origin) closes a builder without a node, using the host clock
and a seeded generator; it is a convenience for tests and binaries and is not
compiled for wasm32. Core logic should use build_unsigned_with(origin, clock, rng) so time and randomness stay injectable — or, in an application,
node.emit.
Builder methods
Two behaviours are easy to get wrong from the signatures alone.
with_weight
clamps rather than rejecting, and the topic is not a field — it is
pushed onto the front of tags.
Emitting through a node
emit returns ntl_core::Result<Signal> synchronously. It also claims the
signal in the node’s dedup cache, so an emission that loops back is not
mistaken for a new arrival — which is why a locally emitted signal goes to
node.receive_local, not node.receive.
For request-response, correlate the reply with the request’s identifier:
Signal fields
The struct has fifteen public fields. All arepub; there are no accessors.
emit stamps identity, not a signature — signature comes back empty, and
signing happens before transmission:
validate() therefore refuses a signal until it has been signed. It checks
three things: weight in range, non-zero TTL, and a non-empty signature.
SignalType
Receipt, not Ack. It was named Ack in
0.1.0-draft; the wire value is unchanged, but the type now carries a structured
outcome, because a bare confirmation cannot say why a signal failed.
to_type_byte and from_type_byte cross the wire. Custom is byte 15 and has
no round trip: the discriminant alone does not carry the application-defined
name, so from_type_byte(15) returns None and the caller must supply it.
PropagationScope
Weighted { min_synapse_weight: 0.0 } is the default. Flood is the one scope
that ignores max_fanout, so max_hops is the only thing bounding total
fan-out: a flood of depth d over nodes of degree f touches on the order of
f^d synapses. Use it sparingly.
Delivery and receipts
BestEffort may be silently absorbed below min_propagation_weight. That is
fine for telemetry and disqualifying for anything with a side effect, so
Acknowledged guarantees at-least-once delivery or a receipt saying it
failed.
Signal::receipt(&receipt, origin) builds the reply: it correlates to the
acknowledged signal, routes Targeted back at that signal’s origin, and
refuses to be acknowledged itself — .acknowledged() on it is a no-op.
RejectReason is BelowThreshold, TtlExhausted, NoRoute,
TransportFailure, QueueFull, UnsupportedType or Refused. Check
is_transient() before retrying: retrying a terminal rejection is pure waste.
Propagation mechanics
hop decrements TTL and appends to the trace; attenuate scales the weight
and clamps it back into range; has_visited is the loop check.
attenuate_for_hop(factor, min_propagation_weight) is the form a router should
use: for an acknowledged signal it clamps at the floor while TTL remains,
because otherwise a path of more than a few hops guarantees a below_threshold
rejection regardless of routing quality.
Encoding
The wire format is CBOR.Signal::MAX_SIZE is 1 MiB, and decode checks the
bound before parsing — validation is ordered cheapest-first so malformed
traffic cannot impose expensive work.
estimated_size() gives a cheap approximation without encoding.
What the signature covers
signing_bytes() returns the signal’s immutable content: id,
signal_type, version, origin, timestamp, payload, encoding,
scope, correlation_id, tags and delivery. A relay cannot rewrite a
payload or downgrade an acknowledged signal to best-effort without breaking it.
Four fields are deliberately excluded, because propagation mutates them by
design and a signature over them could not survive a single hop:
signing_bytes(), never encode(). With the classical-crypto feature:
SignalId
A ULID, so identifiers sort lexicographically by emission time. It serialises as the 16 raw bytes the wire format specifies, not as the 26-character Crockford base32 string — the string form is for humans and logs, and costs 10 extra bytes per signal.SignalId::generate(clock, rng) takes its time and randomness as arguments:
ulid’s own constructor pulls in getrandom, which does not build for
wasm32-unknown-unknown, and an injected clock makes ULID time-ordering
directly testable. generate_now(rng) is the host-only convenience, and
from_parts(timestamp_ms, randomness) the fully explicit form.
Imports
Everything above resolves from these paths:Signal, SignalId, SignalType, NodeId, PropagationScope,
DeliveryClass, Receipt and RejectReason are re-exported at the crate
root. Encoding and SignalBuilder are not — reach for them in
ntl_core::signal.