Repository navigation
v0.20.0
This is the release qualified on commit
2cb70ea, the same commit asv0.20.0-beta.
0.20.0 - 2026-10-12
The release that closes twenty security advisories reachable from our own code,
and makes a read surface able to enumerate. Every binary published before this
one was compiled against a standard library with known net/http and
html/template advisories: the exposure is in the artefacts already deployed,
not in a future build, so this is an upgrade rather than a choice.
The functional headline is narrower and was measured by a consumer: a tenant
holding 236 entities of one type could retrieve 200, and the remaining 36 were
unreachable by any sequence of calls — the answer named what it was withholding
and offered no way to obtain it. The list tools page now, ordered by a handle
every replica computes identically, so a page sequence survives a failover.
The rest came from three consumers reading production and one producer session
reading its own code. Four of the items below exist because someone measured
something I had asserted, and two of my own items were withdrawn the same way.
Security
-
Twenty advisories reachable from our code are closed.
govulncheck
reported twenty vulnerabilities reachable from our own call paths — not
merely present in the module graph: six innet/http, three in
html/template, others in the standard library of go1.26.5, five in
golang.org/x/netv0.55.0 and two ingoogle.golang.org/grpcv1.82.1. The
godirective now requires 1.26.9,x/netmoves to v0.60.0 and grpc
to v1.83.2 in both modules; both now report zero reachable
vulnerabilities.The exposure was in the artefacts, not in a future build: the release
workflow compiled at 1.26.5, so every binary published before this release
carries the vulnerable standard library. Upgrade rather than wait.grpc v1.83.2 and not the v1.83.1 one advisory named: that version left
GO-2026-6443standing — a server panic on a missingauthorityorHost
header, reachable through the OTLP ingest receiver, which is the one surface a
stranger can reach.
Added
-
The list tools can be paged, so nothing is unreachable any more.
after/
next_cursoronfind_entitiesandget_neighbors, plus atruncation
sentence beside the boolean. Measured on a live tenant:describe_schema
reported 236network.endpointandfind_entitiescould return 200 — the
remaining 36 were unreachable by any sequence of calls, and that type carries
no attributes, so there was not even a filter to slice on. An answer declared
what it was withholding and offered no way to obtain it. Pages are ordered by
identity_fingerprint(replica-identical, unmoved by re-minting), and
neighbours by depth then fingerprint so the closest still come first. Not an
offset: over a live projection an offset silently skips and repeats. (#432) -
Filter changes by attribute, as the event saw it.
matchon
recent_changesandrecentChanges, applied before the page bound. The
attribute is tested against the entity as the event recorded it, not
against the live graph: an entity deleted during the window is gone from the
projection, and in an incident its changes are the ones worth looking at — so
an entity that dies mid-window still matches on the attribute it died with.
That also removes the store-format change this feature was priced at. Relation
changes carry no entity and are excluded while the filter is set, and the tool
says so. Costs decoding, like the scope filter. (#380) -
Expanding a folded owner gives kinds, not children. Three levels instead of
two: owner, then one node per child type with its count, then the things. A
switch with twelve ports opens to12 network.interfaceand then to the ports,
costing a click and losing nothing; a host with 357 services opens to
357 service.instanceand stops there until the reader narrows it. Past 60 of
one kind, opening is refused with a line saying to use the attribute filter —
drawing them rebuilds the hairball the fold exists to escape. Measured at two
customers: 367 entities to 3 drawn nodes and 548 to 6, because 97% of their
relations are a singleruns_on. (#392) -
GraphQL answers are no longer a mute source.
graphScopenow reports
freshness (newestEvent, read from the journal — when a producer last spoke —
not from when the projection was rebuilt) and acceptsasOf, under which the
counts come from the folded graph rather than the live one.Entitygained
assertedByandcollectedBy, which GraphQL had never carried in any form.
A source that cannot declare its own scope and freshness loses an arbitration
by default, and GraphQL consumers were holding one. A test now asserts the
invariant the issue named: both surfaces report the same numbers for the same
question at the same instant. (#359, #394) -
An entity names the collection methods asserting it, not only the
producers:collected_bybesideasserted_by.asserted_byis who sends it,
at the resource grain the reference counting is keyed on;collected_byis how
it was gathered, which is the grain the producer contract makes normative and
the one a producer debugs in. An entity seen by bothsnmp-routeand
snmp-lldpis a different situation from one seen bysnmp-routefrom two
agents, and the two were indistinguishable. (#394) -
A change names the collection method that observed it.
scopeon every
change event, on both surfaces, plus ascopefilter onrecent_changes/
recentChanges. The producer contract puts provenance on the instrumentation
scope — one per collection method — and no read surface could reach it, so
"which of my collectors is losing its entities" was unanswerable: 1312
liveness expirations in twelve hours with no way to attribute them. On a
liveness expiry the scope names the collector that stopped speaking, not an
author of the deletion: the scope is remembered on the liveness reference and
read back when it lapses, because Toise authors that event. The filter is
applied before the page bound, not on the bounded page — and it costs decoding,
because the scope is not in the time index. (#394) -
A change page says which end it holds.
ChangeConnectionnow declares
order(OLDEST_FIRST/NEWEST_FIRST) plusholdsNewestand
holdsOldest. The two change pages run in opposite directions while sharing
one type —entityHistoryoldest-first,recentChangesnewest-first — and
nothing said which.pageInfo.hasNextPageanswers "is there more"; these
answer "is the newest event in what I am holding", which is the question a
reader acts on. A reader who asked for a generous history page and took the
last edge believed they held the newest event while holding one days old.
(#366) -
A commit groups its events, and says so. Every change event now carries
commit_seq(commitSeqon GraphQL): the events of one producer observation
share it, and events from different commits do not. A gateway change is
deliberately a route deleted plus a route created, and the engine committed
both in one durable append while telling no one — so every consumer had to
re-invent the pairing with its own tolerance window, andrecorded_atis set
per event, so not even equality on it was guaranteed to hold within a commit.
Pair on equality now, with no window. Toise still does not say what the
pairing means: that a route moved is the consumer's conclusion, not the
engine's (ADR 0022). Absent on events written before the field, where absence
means unknown rather than a commit of its own. (#407)
Fixed
- Paging no longer costs what it saved. The first cut of the paged list
tools sorted by recomputing each entity's fingerprint inside the comparator,
which runs O(n log n) times over a hash that allocates: the bench gate
measured +1209% on time and +27624% on allocations. The ordering keys are now
read from the projection's hash index and the page is cut inside the
projection, so only the page is materialized. Against the pre-paging
baseline, time is at parity and memory is down 53%. - The vocabulary is checked against the registry. The contract and the code
had disagreed three times in one week, each costing a wrong answer given to a
producer — including a relation type the contract called unregistered that had
been registered for four versions. A test now fails on a registered entity
type that no data-model document gives an identity for, with a positive
control so it cannot pass by reading nothing.
Documentation
- The export size cap is declared, and the no-split rule is corrected.
Producers were told to emit a full snapshot "as one OTLP export" with no
mention of the 4 MiB cap, past which an export is refused whole — an
instruction unreachable for a 5,000-host estate. And the only way out was
forbidden too strongly: a partial snapshot does not read as entity
deletes. Removal by absence is scoped to one entity's own relationship array,
so an omitted entity is not deleted at all. Splitting is safe between
entities, never within one, and every part must land within the shortest
declared interval in the batch. - The Go toolchain requirement states 1.26.9 and why, so a reader building
from source does not reproduce the exposure this release closes.
Changed
-
The CI workflows read the Go version from
go.modinstead of pinning it
four times. Three said 1.26.5 while the bench said 1.26.4: what was measured
was not what was built. One source now, so the drift cannot recur. -
The governance keys Toise invented left the
entity.*namespace. They are
nowsenhub.owner.team,senhub.owner.contact,senhub.location.site/
.datacenter/.rack/.room,senhub.lifecycle.statusand
senhub.label.<key>; the network device role issenhub.device.role. The
semconv naming rule is explicit that an existing OpenTelemetry namespace must
not be used as a prefix for application-specific keys, andentity.*is the
entity-events convention's own.entity.id,entity.type,entity.state,
entity.relationshipsand the event names come from that convention and are
unchanged.Nothing is rejected and nothing is rewritten. These are descriptive
attributes, so the engine kept whatever producers sent (ADR 0022) and the
entity.*values already in a graph are still there and still readable. No
entity is re-minted, these never having been identity.describe_schemanow
lists each key's previous spellings in awasfield, because a consumer
filtering over history needs the key to find as well as the key to use.A producer migrates by emitting the new spelling. Done before 1.0 rather than
after: had upstream later definedentity.owner.teamwith other semantics,
field data would have meant two things at once with nothing to separate them.