Release

Announcing Toise 0.10.0 — delete provenance and host-scoped endpoints

2026-07-30

When something disappears from your infrastructure graph, the first question is always the same: did it go away, or did we stop hearing from it? Until now Toise could not tell you. 0.10.0 records the answer on every disappearance, and stops two machines' loopback services from collapsing into one node. Both changes are additive.

This release ratifies an alignment round with the reference producer, and most of it comes from a real incident rather than a design session — the kind of round where the interesting output is the rule you write down afterwards. It is not a wire-contract break and needs no data migration: newly written events carry schema version 1.1, older events read back unchanged.

Who authored the disappearance

Before 0.10.0, an entity expired by the liveness backstop and an entity deleted by its producer were rigorously indistinguishable on every read surface, and a removed relation covered three origins with no discriminant at all. That is exactly the question a flapping incident asks: did my agent delete this, or did Toise reap it after a missed heartbeat?

Every deletion now carries its author: producer for an explicit delete or an edge dropped by absence on re-emit, liveness_expiry for Toise's own backstop, cascade when an endpoint died and took the edge with it. It surfaces on recent_changes, entity_history and graph_diff — including the transient bucket, which turns the flap surface into a one-call triage — and on GraphQL as ChangeEvent.deleteSource.

Two things it deliberately does not do. It never touches delete_reason, which stays the producer's verbatim motive and is never used to derive the source. And events recorded before 0.10.0 read back with an unknown source rather than being retroactively labelled producer — a guess written into history is worse than an admitted gap.

Two hosts, two loopbacks

An endpoint is identified by what an observer can actually see: {server.address, server.port, network.transport}. That works because two observers dialing the same routable address should land on the same node. For an address whose scope is narrower than the observation domain, the same rule tells a lie: every host's 127.0.0.1:5432 collapsed into a single node shared by the whole fleet.

Endpoints in exactly 127.0.0.0/8, ::1, 169.254.0.0/16 and fe80::/10 now carry the observing host's id as a fourth identity key. Everything else — RFC1918 and CGNAT included — keeps the three-key form and its cross-observer join, because a LAN address is shared by construction and that sharing is the point. Resolution honors the scope: a host-scoped endpoint resolves only against its own host's listeners, never through a fleet-wide scan that would re-merge exactly what the fourth key separated.

Rules that came out of the incident

A producer was flapping, and the cause was a sizing rule read one level too low. The recommended slack on entity.report.interval applies to the effective re-emission cadence — what a consumer can actually observe — not to the internal heartbeat tick. Sizing on the tick gave that producer no tolerance to a single missed re-emission, which is a mechanical flap waiting to happen. The contract now says so.

Alongside it: telemetry.relay.* as the vendor-neutral way to say "this telemetry passed through this agent on this host" on relayed spans, first relay winning and the set of three inserted atomically; and address canonicalization frozen, so that a CIDR is explicit-prefix with zeroed host bits and every IPv6 identity is RFC 5952 text.

Security

grpc v1.82.1 and the Go toolchain 1.26.5 in both modules, clearing two advisories; govulncheck is symbol-clean. Producers get the same grpc bump through pkg/emit/v0.5.1.


Get it

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

Binaries for linux and darwin (amd64 / arm64) are on the release, each with a checksum, and the container image is on GHCR. See the 0.10.0 docs and the changelog.