Release

Release

Announcing Toise 0.22.0 — no more answers it would not give

2026-10-17

Every fix in this release has one shape: Toise knew something and the surface did not carry it. An impossible cadence recited as a bound. A provenance field that meant two different things. Forty-seven thousand lost edges with no trace. A join key offered for twice the cases it covers.

None of them was found by reading the code. Each came from someone measuring: an operator searching logs, a consumer running a fixture, a producer reading its own emitter. In four cases the measurement refuted something the maintainer had asserted. Several prereleases were cut for this release, and more than one was refused on the test bench; the refusals are summarised at the end, because they are the reason the stable is trustworthy.

One answer changes shape for consumers.

A read at a past instant (as_of) no longer fills the live provenance fields from the present. Who asserts an entity, and how often it is observed, are live state; replaying the log does not rebuild them. Those fields are now withheld on a past-instant read, with a sentence saying what that costs and how to get the answer. Nothing changes on a live read, and no field is removed; the tool contract gains one optional field. A 0.21 deployment upgrades in place.

An impossible promise is refused, and the refusal shows

A liveness interval is not an observed fact. It is a promise to speak again, and a producer can promise something impossible. One did: a malformed interval gave a host a 153-year bound, and that host sat in a production graph for eight weeks, immortal, while the read surface quoted the figure back as a legitimate cadence. Nothing marked it as a defect.

The promise is now refused; the entity is kept, because an interval is not a fact and ADR 0022 stands. "No cadence" has two causes that must not read alike, nobody promised one or somebody promised an impossible one, so observation_interval now distinguishes unknown from none. And the refusal survives a restart: the first cut guarded live observations only, so a snapshot holding the impossible interval restored it intact, which is exactly how that host survived restarts for 56 days. The stored absolute deadline falls with the promise too; refusing the interval alone would have left a deadline armed in the year 2177.

A counter now records every refused promise, by producer and entity type, with the largest refused value kept as a gauge. The magnitude is the diagnosis: a few times the bound is a misconfiguration, a thousand times over is a unit error.

A past-instant read no longer borrows the present

The withholding described in the notice above had been designed, documented in four schema texts, and never wired. Both guards tested for a condition that is never true on an as_of read, so the contract stated the intent and the code answered from the present: a past-instant answer named the producers asserting the entity today beside its state then. That is a false attribution, not an approximation, and it is the worst shape of the defect this release corrects. Every other one was an absence that failed to declare itself, and an absence at least leaves a reader looking. A false value stops them.

Two null fields and no explanation cannot tell withheld from unanswerable, so a withheld field carries a sentence naming what it costs and how to get the answer. GraphQL keeps the defect for now, by decision, and its schema says so: a field resolver receives only its object and cannot know the query named an instant. No known consumer selects those fields on an asOf query today.

Lost edges are counted

An edge whose endpoints have not arrived parks until they do, and a parked edge can be discarded: two expiries, and a third path that needs no clock at all, eviction of the oldest entry when the buffer is full. Forty-seven thousand edges had been lost on one deployment with no trace beyond a warning line in a flood of them. A counter now records discarded edges, labelled by relation type and by the type at each end. The three labels are what makes the figure diagnose itself: one value named a producer's shape and a missing emitter at once, where the relation type alone would only have said that an edge was lost.

The counter covered the two expiries and missed the eviction, so it read zero under the one condition that loses edges fastest: a burst, which is also when an operator is least able to pick a warning out of the log. Found because the bench asked what input makes an edge drop and could not make the counter move. A later prerelease found that both new counters were installed after boot had opened every persisted tenant, so only a tenant created after boot ever counted; both are now installed before the receiver accepts an observation.

Two entity types, two join keys

host.service is a unit the system's supervisor manages: a systemd unit on Linux, a Windows service on Windows. One type, because it is one concept. Its identity is the host and the full name the supervisor gives, since nginx.service and nginx.socket are two different units and a bare nginx fuses them into one entity whose attributes oscillate. Nothing upstream covers this ground yet, so the spelling is an extension of the OpenTelemetry host.* namespace and it is proposed, not settled: it may change when an upstream experience report lands. Choosing an upstream-owned namespace over a private one is deliberate; a private prefix has no collision risk and guarantees a re-key the day the vocabularies converge.

k8s.node is the cluster's view of a machine, keyed on the node UID and carrying the role in the cluster rather than facts about the machine. A node cordoned while its host is perfectly healthy is what makes the two distinct.

host.service.name joins a service to its telemetry. service.endpoint does too, but conditionally: only a listener that an active check targets has a series carrying it, and offering it to every listener would be a partial false promise, one that works often enough to be trusted and then returns an empty filter. The graph holds the fact that decides, so telemetry_keys withholds the key where it cannot work instead of offering it everywhere with a caveat to read.

Also in this release

Upgrading

Nothing to do: the data directory is read in place and the log sequence continues. The change to past-instant reads is additive for every consumer, since no field is removed. If you consume telemetry_keys for listeners, expect service.endpoint only where it can work.

Known, open, and stated rather than omitted

What the prereleases refused

The stable supersedes several betas, and each was replaced because the qualification itself found a gap: a cadence refused live but restored intact; an edge held for ever by a speaker that had declared its source gone; a counter that could not move under the condition that loses edges fastest; a past-instant read answering from the present; and finally two counters that could not fire on any tenant that existed at boot. None of that was found by reading a diff. A test bench that can make an instrument move, or fail to, is what turned these into fixes rather than release notes.


Get it

go install github.com/toise-dev/toise/cmd/toise-server@v0.22.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.22.0 docs and the changelog.