Two things to know first
ntl-core is synchronous. The crate contains no async functions at all,
so there is no .await anywhere on this page. That is deliberate: holding no
async runtime is what keeps the crate building for
wasm32-unknown-unknown, which CI enforces on every commit. Async lives one
layer out, in ntl-net.
Synapses are store state, not a collection on the node. There is no
node.synapses(). A node reads them through its store
(NodeStore), which is what makes them survive a
restart — a learned weight that lived only in memory would be lost on every
process exit without saying so.
Every sample on this page is compiled and executed by
runtime/ntl-core/tests/api_reference_synapse.rs. If the API changes, that
test stops building, which is the only way documentation like this stays
true.Where the types live
The crate isntl_core — note the underscore in Rust paths.
Synapse, SynapseId, SynapseState, NodeId, NodeStore and StoreError
are re-exported from the crate root. SynapseRecord and SynapseFilter are
storage types and live in ntl_core::store; Transport and SynapseConfig
live in ntl_core::synapse.
The samples below all assume a node. Building one is synchronous, and a store
is required:
SynapseState
SynapseState::can_carry() answers that last column, and
is_terminal() is true only for Pruned:
Weakening counting as eligible is a normative requirement of
synapse-lifecycle, not a tuning preference. A
synapse recovers weight only by carrying traffic that then succeeds, so a
state that is reachable from a single bad outcome and ineligible for traffic
is a one-way trap. With initial_weight and active_threshold both at 0.1
by default, every new synapse would fall into it on its first failure.
Only two states are not derived from the weight: Pruned is terminal, and
Forming is left alone because a weight change is no evidence that a
handshake completed. Synapse::activate() is the only exit from Forming.
SynapseRecord — what is persisted
SynapseRecord is the projection that survives a restart. It is deliberately
not the live Synapse struct. Every timestamp is nanoseconds since the Unix
epoch, and the field names say so.
signature_failure_prune_threshold failures within one influence
window to be pruned, and a restart must not be a free amnesty.
The live Synapse struct adds the local node, the transport, and the local
copies of the configured thresholds (max_weight, decay_rate,
active_threshold, dormancy_threshold). None of those are properties of
the peer relationship, so they come from configuration rather than the store
when a record is rehydrated:
Listing synapses
NodeStore::list_synapses takes a filter and returns records ordered by
weight, descending.
SynapseFilter has two constructors:
SynapseFilter::eligible()—ActiveandWeakening. Use this for anything routing-related.SynapseFilter::active()—Activeonly. Use it for reporting on healthy connections, not for choosing one to send over. ExcludingWeakeningfrom routing is the trap described above; it was a real bug once, and a probe showed a failing peer being retried exactly once in 301 rounds.
Looking up a single synapse
synapse_for_peer finds the synapse to a given node; get_synapse fetches
one by identifier. Both return Option, because an absent synapse is not an
error.
Forming and reactivating
There is nonode.connect(url). Dialling an address is the transport’s job,
in ntl-net; the core’s part is the record. Node::upsert_synapse forms one
to a peer, or brings an existing one back:
ntl_core::Result — the node converts store failures on the way out. What it
does depends on what it finds:
- Nothing — creates the synapse and completes its handshake, since this
call is only reached once the transport has verified the peer against the
key that signed its handshake. It also records the peer in the topology
table with
PeerSource::Observed. - A
Dormantsynapse — reactivates it atinitial_weight. Returning it unchanged would makeDormantterminal in practice: it cannot carry signals, and weight is only earned by carrying them. - A
Prunedsynapse — refuses, until the signature-failure cooldown has elapsed (one hour by default). Otherwise the prune would cost an attacker a single reconnect. - Anything else — returns the existing record untouched.
Writing and removing records
put_synapse inserts or replaces; delete_synapse removes. Deleting an
absent synapse is not an error, so the second call below succeeds too.
Node::apply_receipt, Node::sweep_timeouts and
Node::penalize_signature_failure — which journals the decision, applies the
reward, respects the peer’s influence budget, and re-normalises outbound
weights. Writing a weight directly bypasses all of that.
Transport
Synapse, taken from
SynapseConfig::preferred_transport. It is not part of SynapseRecord: which
wire a peer is currently reachable over is not learned state, and should not
outlive the process that observed it.
preferred_transport and fallback_transport are commented out in
config.example.toml, and deliberately so: the reference implementation is
TCP-only, and neither key is read by anything yet. The enum is real; the
selection is not wired up.SynapseConfig
Reached asnode.config().synapse, or set in the [synapse] section of a
node’s configuration file.
initial_weight and active_threshold coincide at 0.1, which puts a
freshly formed synapse exactly on the Active boundary: one rejection moves
it to Weakening. That is the boundary
synapse-lifecycle warns about, and the reason both
are named here rather than hard-coded — retuning one without seeing the other
is how the routing-ossification bug happens.