Skip to main content
A synapse is a persistent, weighted connection between two nodes. It strengthens when it carries traffic that succeeds and weakens when that traffic fails or stops.

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 is ntl_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.
The signature-failure counter and its window start are persisted for a reason: threat-model §4 requires a synapse that accumulates 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.
Note the return type. The store’s failures are StoreError, which does not convert into ntl_core::Error. A function returning ntl_core::Result cannot use ? on a store call — it has to map the error or return Result<_, StoreError>, as here.
SynapseFilter has two constructors:
  • SynapseFilter::eligible() — Active and Weakening. Use this for anything routing-related.
  • SynapseFilter::active() — Active only. Use it for reporting on healthy connections, not for choosing one to send over. Excluding Weakening from 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.
For anything narrower, the four fields are public:

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 no node.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:
This is a node method rather than a store one, so it returns 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 Dormant synapse — reactivates it at initial_weight. Returning it unchanged would make Dormant terminal in practice: it cannot carry signals, and weight is only earned by carrying them.
  • A Pruned synapse — 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.
In normal operation you should rarely need either. Weights move through the learning path — 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

Transport is a property of the live 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 as node.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.
Last modified on September 11, 2026