Release
Release
Announcing Toise 0.20.0 — twenty advisories closed, nothing unreachable
Two headlines. Every binary published before this one was compiled against a standard library with known advisories, so this is an upgrade rather than a choice. And a tenant holding 236 entities of one type could retrieve 200 of them, with no sequence of calls that reached the other 36; the list tools now page.
The rest came from three consumers reading production and one producer reading its own code. Several items below exist because someone measured something that had been asserted, and two planned items were withdrawn the same way.
Upgrade rather than wait.
Twenty vulnerabilities reachable from Toise's own call paths were
reported by govulncheck: six in net/http,
three in html/template, others in the standard library of
the Go version earlier releases were built with, five in
golang.org/x/net and two in google.golang.org/grpc.
One of the grpc advisories is a server panic on a missing
authority or Host header, reachable through the
OTLP ingest receiver, which is the one surface a stranger can reach.
The exposure is in the artefacts already deployed, not in a future
build. This release is built on Go 1.26.9 and both modules report zero
reachable vulnerabilities. Otherwise it is drop-in: no wire-contract
break, no data migration.
Nothing is unreachable any more
find_entities and get_neighbors take
after and return next_cursor, with a sentence
beside the truncation flag saying what was withheld. Measured on a live
tenant: describe_schema reported 236 network endpoints and
find_entities could return 200. That type carries no
attributes by design, 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, which every
replica computes identically and which re-minting does not move, so a
page sequence survives a failover. Neighbours order by depth first, so the
closest still come first. It is not an offset: over a live projection an
offset silently skips and repeats. The first cut sorted by recomputing
each fingerprint inside the comparator and the bench gate caught it, at
twelve times the time and far more allocations; the ordering keys are now
read from the projection's index and only the page is materialised. Against
the pre-paging baseline, time is at parity and memory is down by half.
A change says who observed it, and which commit it belongs to
The producer contract puts provenance on the instrumentation scope, one
per collection method, and no read surface could reach it. "Which of my
collectors is losing its entities" was unanswerable: on one deployment,
1312 liveness expirations in twelve hours with no way to attribute them.
Every change event now carries scope, on both surfaces, and
recent_changes filters on it. On a liveness expiry the scope
names the collector that stopped speaking, remembered on the liveness
reference and read back when it lapses, because Toise authors that event.
In the same spirit, an entity now lists collected_by beside
asserted_by: who sends it, and how it was gathered. An entity
seen by two collection methods of one agent is a different situation from
one seen by one method from two agents, and the two were
indistinguishable.
Every change event also carries commit_seq. 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 re-invented the pairing with its own tolerance window. 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.
The field is absent on events written before it existed, where absence
means unknown rather than a commit of its own.
Two more things a reader of the change feed needed. match on
recent_changes filters by attribute as the event recorded it,
not as the live graph holds it, so an entity that died mid-window still
matches on the attribute it died with. And a change page now declares its
order and whether it holds the newest or the oldest event: the two change
pages run in opposite directions while sharing one type, and a reader who
took the last edge of a generous history page believed they held the newest
event while holding one days old.
GraphQL stops being a mute source
graphScope now reports freshness, read from the journal as the
moment a producer last spoke rather than the moment the projection was
rebuilt, and accepts asOf. Entity gained
assertedBy and collectedBy, 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 asserts that both surfaces report the same numbers for
the same question at the same instant.
The governance keys leave the entity.* namespace
The keys Toise invented for ownership, location, lifecycle and labels are
now senhub.owner.team, senhub.owner.contact,
senhub.location.site and its siblings,
senhub.lifecycle.status, senhub.label.<key>
and senhub.device.role. The semantic-conventions naming rule
is explicit that an existing OpenTelemetry namespace must not be used as a
prefix for application-specific keys, and entity.* belongs to
the entity-events convention. The keys that come from that convention are
unchanged.
Nothing is rejected and nothing is rewritten. These are descriptive
attributes, so the values already in a graph are still there and still
readable, and no entity is re-minted. describe_schema lists
each key's previous spelling in a was field, 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 defined a key with the same name and other
semantics, field data would have meant two things at once.
Also in this release
-
In the example viewer, expanding a folded owner gives kinds, not
children: owner, then one node per child type with its count, then the
things. Past 60 of one kind, opening is refused with a line pointing at
the attribute filter. Measured on two deployments: 367 entities to 3
drawn nodes and 548 to 6, because almost all of their relations are a
single
runs_on. - The export size cap is now documented. 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 large estate. Splitting is safe between entities, never within one, and a partial snapshot does not read as entity deletes.
- The vocabulary is checked against the registry. The contract and the code had disagreed three times in one week; a test now fails on a registered entity type that no data-model document gives an identity for.
-
The CI workflows read the Go version from
go.modinstead of pinning it four times. Three said one version while the bench said another: what was measured was not what was built.
One reserve left open
One module-level advisory in klauspost/compress was left
unbumped here: it was unreachable from Toise's code, and bumping it then
would have meant shipping a stable that nothing had qualified. It was
closed in 0.21.0.
Get it
go install github.com/toise-dev/toise/cmd/toise-server@v0.20.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. See the 0.20.0 docs and the changelog.