Release
Announcing Toise 0.13.0 — one answer per question
Three places where the same question had two answers, or none. None of them was a bug in the ordinary sense — no crash, no rejected record, nothing a test would have caught. Each was a silence: somewhere the contract did not say, or said it once in a place only half the readers look. What they cost is worth writing down, because the pattern repeats.
One: the same machine, spelled two ways
A producer wired up a Kubernetes probe alongside its existing agent. Both
read /etc/machine-id to identify the node. Both were correct.
They produced two different strings:
/etc/machine-id 8b86170405bc4382b0577eac3df5e730
via the library 8b861704-05bc-4382-b057-7eac3df5e730
Same file, same bytes, one formatted as a UUID and one not. Toise matches identity byte for byte and never guesses, so that is two hosts — one duplicate per node, per cluster, permanently, with nothing in the graph saying they are the same machine.
The detail worth keeping: verifying that both sides read the same file proved nothing. The derivation looked so obviously identical that nobody thought to compare the output. Only diffing the emitted strings found it.
Our contract said host.id is the machine-id and stopped there,
which is how a third producer reading the file itself would land on the raw
form in good faith and pay the same price. So the rendering is now
normative — a lowercase hyphenated UUID — and the conformance kit
fails on the raw 32-hex form and on uppercase hex,
wherever the key appears: a host's identity, a VM's hypervisor id, a
host-local endpoint's fourth key, a relationship target. A check per type
would have missed three of those four.
A failure rather than a warning, at the producer team's own request, and their reasoning is the better one: a warning gets read once and filed, while this defect is silent and surfaces months later against an entity everything already points at.
One nuance we insisted on. The check's message does not simply say "use hyphens" — for a producer already emitting the other form in production that would be destructive advice, because its graph is keyed on what it emits today and aligning re-keys every host. A check that fails has to say what to do, not only that something is wrong.
Two: a fallback identity we never specified
Our contract gave db a ladder — PostgreSQL's
system_identifier, MySQL's server_uuid, then an
operator-configured name — and stopped. But MariaDB and Redis expose no
stable identifier (Redis's run_id changes at every restart),
and most operators configure nothing. The bottom of the ladder was the
common case, and we had left it blank.
So the producer filled it, reasonably, with address:port. And
127.0.0.1:3306 is byte-identical on every machine in the
estate. Every local database collapsed into one entity whose
attributes flipped between servers every five minutes — 118 attribute
updates in six hours, and worse than noise: its telemetry join keys pointed
at one machine while its attributes described another.
The fix took a round trip worth reporting. Their first proposal scoped the
value by host, which fixes the collision. We pushed back, and the argument
that carried was not ours about aesthetics: our own contract already
answered this question for service instances, with
<service.name>@<host.id>, and explicitly rejected
address:port. Reusing that shape answers "how do you name a
local thing with no stable id" once rather than once per type.
mysql:3306@8b861704-05bc-4382-b057-7eac3df5e730
They adopted it, and corrected a factual error in our second argument while doing so — we had claimed two identities would be byte-identical; they differ by a transport suffix. The conclusion held on the other leg. We would rather learn that in an email than discover it in a decision record both sides had signed.
A consequence for operators: a host-local identity was previously refused an
anchor, so a local database sat in no host's impact radius at all. With a
unique identity it gains its runs_on. That is a hole in the
graph closing, not a modelling nicety.
Three: two surfaces answering "is this the same machine?"
When two producers describe one real machine from different vantage points —
a hypervisor's VM and an in-guest host — Toise stores two entities and keeps
storing two. Merging them would destroy the ability to say which producer
saw what. When a producer can justify the link, it asserts a
same_as edge with a confidence and a basis, and Toise derives
the grouping at read time without ever writing it back.
That overlay existed only on the MCP surface. A GraphQL consumer had to walk
same_as itself and pick its own threshold — which is precisely
how two read surfaces come to disagree about a question that must have one
answer. Identity should not depend on which protocol you asked over.
canonical(id: $id) {
aliases { id type label }
links { from to confidence basis }
}
The walk now lives in one place and both surfaces call it, so they cannot
drift. It is a top-level query rather than a field on the entity, and that
is deliberate: a group is derived from edges, and edges change, so
asOf has to select the graph the belief is read from.
Evidence below the confidence threshold stays in the graph and collapses nothing. A wrong merge answers confidently about the wrong machine, which is worse than a visible gap.
A trap we would not have documented on our own
The producer team rehearsed their identity migration end to end against a live instance before shipping it — old binary to create the old entity, new binary to cut over — and reported something we had never written down.
Bridging a re-key with a one-cycle same_as keeps the old and new
timelines joinable without merging anything. But once the old entity is
gone, the cascade removes every edge touching it, including the bridge. So a
current-state query for same_as afterwards returns
zero — correct, since an edge to a deleted entity is
meaningless in the present, but indistinguishable from having emitted
nothing at all.
Someone verifying a migration with the obvious query would have read zero and concluded the whole thing failed. It is now documented, with the dated read that actually verifies the emission. Finding it took someone rehearsing a migration rather than trusting it, which is the part worth copying.
Also in this release
The liveness sweep used to log
expired stale entities count=14 for a pass that expired seven
entities and cascaded seven relations: the label said entities, the number
was both. During a flapping investigation that ratio is the whole signal — a
pass that expires one entity and cascades twenty edges reads nothing like one
that expires twenty entities. The line now carries entities= and
relations= separately. Worth knowing if you parse
it.
And ADR
0034 proposes network.segment, so that why can't A reach
B becomes a graph question. Proposed, not implemented — and the
interesting part is what it refuses to freeze. Docker Swarm assigns an
identifier, so that subtype is settled. Kubernetes in its default flat
network has no object to identify at all, and a VLAN id is not globally
unique while a VLAN trunked across five switches is one segment rather than
five. Both stay explicitly open, with their reasons written down, because the
reason says what would have to be true for them to close. An identifier
engraved wrongly costs a migration; an open one costs a sentence.
Get it
go install github.com/toise-dev/toise/cmd/toise-server@v0.13.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.13.0 docs and the
changelog.
Producers get the host.id check with
pkg/emit/v0.8.0.