Release
Announcing Toise 0.21.0 — who said it, and why it went
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
-
monitorsno longer propagates a monitored target's failure into the agent watching it. The agent is healthy and its reading is bad, which is a different fact; one unreachable target was inflatingimpact_ofby the whole fleet that agent watches. -
Removal by absence is scoped per collection method. One agent running
several collectors had them delete each other's edges, measured as a
bound_toflipping every one to three minutes. A producer now emits the complete set per method, which is what each method observes anyway. -
The MAC rendering is normative and the same everywhere:
AA-BB-CC-DD-EE-FF, the form semconv requires. The conformance kit rejects any other, wherever a MAC appears, because two renderings of six bytes are two entities and the join silently finds nothing. -
identity,attributes,asserted_byandcollected_byare always rendered on an entity. An empty list means asked and none; null means not rendered at this verbosity. Measured before changing it:attributeswas absent on the majority of entities in two production tenants. -
toise-server --versionprints the version and commit and exits, so a file on disk can be identified before it is deployed. - The demonstration fixture no longer contradicts the identity rule it exists to teach: it keyed a route on a host's name and carried the next hop as a descriptive attribute, producing exactly the oscillation the three-key identity eliminates. Found by someone running it.
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
- Link aggregation is absent from the test bench, so the half of the crossing rule that concerns an aggregated interface was never exercised. That is a gap in the bench, not a property of the model, and it reopens the day the bench gains a bond.
- The crossing rule's proof for neighbours is three queries, placed where the rule decides. Weak, and worth saying.
-
FromandToin the relation registry are read by nothing, so they describe rather than constrain.monitorsdeclared the wrong target for three releases and no test could notice (#494). -
A restored log re-stamps
commit_seqin batches of a thousand, so pairing events on it after a disaster recovery groups unrelated changes (#496).
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.