Skip to content

0.13.0

Released 2026-08-13.

One answer per question. Three places where the same question had two answers, or none.

No wire-contract break, no data migration. Every change is additive.

same_as on GraphQL

Toise stores two entities when two producers describe the same real machine from different vantage points — a hypervisor's compute.vm and an in-guest host. That is deliberate: merging them would destroy the ability to say which producer saw what (ADR 0018).

When a producer can justify the link it asserts a same_as edge carrying a confidence and a basis. Toise derives the grouping at read time, and never writes it back.

That overlay was reachable only over MCP. A GraphQL consumer had to walk same_as itself and pick its own threshold — which is how two read surfaces come to disagree about whether two entities are one machine.

query WhoElseIsThis($id: ID!) {
  canonical(id: $id) {
    aliases { id type label }
    links { from to confidence basis }
  }
}

Null when nothing qualifies. Otherwise every entity reachable over same_as edges at or above identity_confidence_threshold (default 0.9), transitively, with the edges that justify it.

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 Entity so that asOf selects the graph the belief is read from: a group is derived from edges, and edges change over time like anything else.

Below-threshold evidence stays in the graph and collapses nothing — a wrong merge answers confidently about the wrong machine, which is worse than a visible gap.

host.id has one spelling

/etc/machine-id holds 32 hex digits with no separator. A library that formats the same bytes yields a hyphenated UUID:

/etc/machine-id   8b86170405bc4382b0577eac3df5e730
formatted         8b861704-05bc-4382-b057-7eac3df5e730

Same machine, same file, two strings — and under exact identity, two entities with nothing in the graph saying they are one. A producer met this between its own agent and a new Kubernetes probe: one duplicate host per node, per cluster, silent. Checking that both sides read the same file proved nothing; only comparing the emitted strings caught it.

The contract now pins the rendering — lowercase hyphenated UUID — and the conformance kit fails on the raw 32-hex form and on uppercase hex, wherever host.id appears: a host identity, a compute.vm's hypervisor id, a host-local network.endpoint's fourth key, a relationship target.

A failure rather than an advisory, because the defect is silent and surfaces months later against an entity everything already points at.

If you already emit another rendering

Coordinate the change; do not switch on upgrade. Your graph is keyed on the form you emit today, and aligning re-keys every host and cascades every edge that touches one. The check's message says so.

The db fallback identity

The contract listed PostgreSQL's system_identifier, MySQL's server_uuid, then an operator-configured logical name, and stopped. But MariaDB and Redis expose no stable identifier (Redis's run_id changes at every restart), and most operators configure nothing — so the fallback is the common case, not the exception.

Left unspecified, a producer reasonably invented address:port. And 127.0.0.1:3306 is byte-identical on every machine, so every local database in an estate collapsed into one entity whose attributes flipped between servers: 118 attribute updates in six hours, with telemetry join keys pointing at one machine while the attributes described another.

The fallback is now specified:

mysql:3306@8b861704-05bc-4382-b057-7eac3df5e730

<db.system.name>:<port>@<host.id> — reusing the @<host.id> shape the contract already defines for service.instance, so how do you name a local thing with no stable id has one answer instead of one per type. The port discriminates within a host; it is not the basis of the identity. Routable addresses are left alone: they already distinguish the target.

The contract now also documents how to migrate an identity — an unconditional explicit entity.delete of the old one, and a one-cycle same_as bridging the two so the timelines stay joinable without anything being merged — together with the trap that follows. The cascade removes the bridge along with the deleted entity, so after the cutover a current-state query for same_as returns zero and looks like a failure to emit. Verify with a dated read at the cutover instant.

Local databases gain a runs_on

A host-local identity was previously refused an anchor, so a local database was in no host's impact radius at all. With a unique identity it gains one — a hole in the graph closing, not a modelling nicety.

Also

  • The liveness sweep counts entities and relations apart. It logged liveness sweep expired stale entities count=14 for a pass that expired 7 entities and cascaded 7 relations: the label said entities, the number was both. During a flap triage that ratio is the signal.

    Log format change if you parse these lines: the message is now liveness sweep expired stale records with entities= and relations= instead of a single count=.

  • ADR 0034 proposes network.segment — a reachability domain, so that why can't A reach B becomes a graph question. Proposed, not implemented: only the Docker Swarm subtype has an assigned identifier, and the Kubernetes and VLAN cases stay explicitly open rather than being guessed.

Upgrading

Nothing to do. Existing types, relations and stored events are untouched. If you parse the liveness sweep log line, note the format change above.