Release

Announcing Toise 0.21.0 — who said it, and why it went

2026-10-14

Three of the changes in this release cannot be verified by reading a diff. So two prereleases were cut, and all three were measured on real data before this one was tagged. The reservations that survived the measurement are listed below, not omitted.

The short version: an edge now says who asserts it and through which collector; a disappearance says why, in the producer's own words; a path no longer runs through something two machines merely both observed; and an insecure configuration is a metric you can alert on, instead of a warning printed once at boot.

One answer changes shape.

find_path no longer walks through a shared observation. Two interfaces that heard the same hardware address are not connected to each other by that fact, so a path now reports the device that carries them rather than the cache entry they share. Two machines that dial the same object store, resolver or upstream API are no longer rendered as connected either. If you depended on the old answer, you were reading a co-observation as a hop. Nothing else changes shape; a 0.20 deployment upgrades in place.

When one collector loses sight of a thing, Toise no longer forgets it for the others

An entity or an edge stays live while any producer still asserts it (edges since 0.18.0), and a producer may only release its own reference. The grain of "its own" was the producer. That turned out to be too coarse, because one producer is often several collectors: an agent running host ARP and SNMP ARP sees the same neighbours twice, and LLDP and CDP see the same links. Those overlaps are the normal case, not the corner.

With the reference keyed on the producer alone, one method losing sight of an edge released the whole producer's reference and deleted an edge another method was still asserting, which then re-created it on its next cycle. And collected_by reported whichever method had spoken last, so an agent with six collectors returned one rotating value where a reader needs the set.

A liveness reference now names the speaker: the producer and the collection method it spoke through. The rule is unchanged; only its grain moved. Edges gain via_asserted_by and via_collected_by on get_neighbors (assertedBy / collectedBy in GraphQL), and the answer is a set, including across the methods of one producer. Two edges of one type are not the same claim: an adjacency protocol states a link, a forwarding table lets one be inferred, and a consumer drawing a map can now show a measurement differently from a correlation.

An independent review found two serious defects in this change on the day it merged, and a third was found later by reading an upgrade plan rather than the code. Two concerned references restored from a snapshot written before the key carried the method, one a parked edge that credited the method that completed its endpoints rather than the one that asserted it. All three are fixed here. Producers change nothing: one that names no instrumentation scope keys on its id alone, byte for byte as before.

A disappearance says why, and distinguishes an incident from a blind spot

Every deletion carries a plain-language gloss beside its delete_source, because that gloss is the field a consumer actually reads. It took only the source, so every producer deletion read as "the producer reported this gone". A producer now reports deletions whose reason is that it stopped watching a device that may well still be running. Its own term sat in delete_reason, next to a sentence asserting the opposite.

The gloss now takes the reason, quotes it, and widens the reading rather than narrowing it. It does not interpret the value: the reason is an open enum in producer vocabulary, and paraphrasing a word Toise does not define is how a reader ends up trusting the gloss over the producer. "The thing stopped existing" and "we stopped looking at it" are the difference between an incident and a blind spot, and until now the difference was unreadable.

Measured end to end on a test bench: a switch paused for six minutes, fifteen entities disappeared carrying the producer's own word, and the answer quoted it.

A path is a path, not a shared observation

Two fixes, one principle. First, find_path was walking through an observed network.endpoint. An endpoint is declared in one relation pair only, as the target of depends_on, and nothing leaves it; so every transit entered by one inbound dependency and left by another one reversed, and two machines that dial the same store were rendered as connected. On a fleet, that made a large fraction of host pairs falsely reachable. A terminal entity may now be reached, and may be the start, but may only be left by an edge it is the source of. When the search then fails, the answer names the shared endpoints both ends depend on (convergences) instead of denying the link and burying the only useful fact (#442).

Second, this release registers network.neighbor: a link-layer neighbour an observer saw on one of its own interfaces and could not resolve to anything it holds. Its identity is the observer plus the MAC, on purpose, since a MAC is not globally unique and a wrong merge is permanent where a split is reconciled later by a belief. The first emission of such a thing creates a new way to be wrong: one MAC heard on two interfaces of a host is a convergence, and a traversal that enters and leaves it by the same relation type reads a co-observation as a hop. A per-type crossing rule now forbids exactly that, for a neighbour and for an interface entered through aggregated_into. Per type, because the general rule was refuted within the hour: a switch is entered and left by has_interface both times, and that is forwarding.

A warning became a control

Three insecure configuration conditions had been true, correctly detected and correctly logged, in a production log for months. The control worked and protected nothing, because a WARN at boot is read once, by nobody.

Insecure configuration is now a metric. toise_insecure_config names each condition, toise_insecure_config_conditions counts them and is published even at zero, and a last-check timestamp sits beside it, because a zero total proves only that something wrote a zero while a fresh timestamp proves the list was walked. The documentation gives the alert rules, including the presence rule a vanished series needs.

Found on an install the day before the cut, from two startup lines: the container image binds 0.0.0.0 by default, so behind a terminating proxy, which is the recommended deployment, the exposed-listener condition was permanently true and the alert would have fired forever. An operator can now declare tls_terminated_upstream. It suppresses no_tls only, never no_auth, and the declaration is itself exported as a metric, published as 0 or 1 always: a setting whose only effect is to quiet a control has to leave a trace (#464).

Two smaller ones in the same spirit. /readyz is deliberately unauthenticated and was returning the failing tenant's identifier and the storage engine's error text to anyone who asked; it now says whether, and the reason goes to the log on every transition. And the exposed-listener warning names what is actually missing, instead of recommending both auth and TLS to an operator who had already set one.

Also in this release

Upgrading

Nothing to do: the data directory is read in place and the log sequence continues. An upgrade from 0.20.0 was exercised, and so was one from the prerelease: sequence advancing, no spurious retraction, provenance unchanged at two, five and ten minutes after restart.

Two things to know. find_path changes shape as described at the top. And a producer must not enable network.neighbor emission until a Toise carrying the type is deployed: under the default strict vocabulary the records are rejected one by one until then, and the conformance kit cannot catch it, since it validates shape, not registry membership. Watch toise_ingest_records_total{result="rejected"}.

Since the last post

Two releases shipped between 0.18.0 and this one without a post here. 0.19.0 gave the three entity types that lacked a display_name one, and made the ingest counters say whose records were refused on a multi-tenant server. 0.20.0 closed twenty security advisories reachable from Toise's own code; every earlier binary was compiled against a standard library with known advisories. It also made list answers pageable so a tenant holding more entities than one answer carries can retrieve all of them. If you run anything older than 0.20.0, upgrade for that alone. Both are detailed in the changelog.

Reservations, stated rather than omitted

The change-feed under-reporting flagged in the 0.18.0 post (#397) was closed on 2026-10-07 after a check under real volume: the read path is sound and the symptom does not reproduce. The sub-linear counts that prompted it reflect bursty emission, each producer re-asserting its inventory on its own cycle, not loss.


Get it

go install github.com/toise-dev/toise/cmd/toise-server@v0.21.0

Binaries for linux and darwin (amd64 / arm64) are on the release, each with a checksum, and the multi-architecture container image is on GHCR as v0.21.0 and latest. See the 0.21.0 docs and the changelog.