Skip to main content
Signals are the fundamental data unit in NTL. This page documents 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, plus Signal::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.
Tags are visible to every node on the path, and they are covered by the origin signature but not encrypted. Do not put sensitive data in them.

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 are pub; 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

The acknowledgement variant is 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:
The consequence is worth stating plainly: an on-path node can inflate a signal’s weight or TTL, and the origin signature will still verify. What bounds the abuse is that a receiving node enforces its own max_accepted_ttl and weight limits, hop-to-hop authenticity comes from the synapse rather than this signature, and a peer that misbehaves loses synapse weight. See threat-model §6.
Sign 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.
Last modified on September 11, 2026