Skip to content

v0.20.0

Choose a tag to compare

@github-actions github-actions released this 12 Oct 12:56
· 73 commits to main since this release
2cb70ea

This is the release qualified on commit 2cb70ea, the same commit as v0.20.0-beta.

0.20.0 - 2026-10-12

The release that closes twenty security advisories reachable from our own code,
and makes a read surface able to enumerate.
Every binary published before this
one was compiled against a standard library with known net/http and
html/template advisories: the exposure is in the artefacts already deployed,
not in a future build, so this is an upgrade rather than a choice.

The functional headline is narrower and was measured by a consumer: a tenant
holding 236 entities of one type could retrieve 200, and the remaining 36 were
unreachable by any sequence of calls — the answer named what it was withholding
and offered no way to obtain it. The list tools page now, ordered by a handle
every replica computes identically, so a page sequence survives a failover.

The rest came from three consumers reading production and one producer session
reading its own code. Four of the items below exist because someone measured
something I had asserted, and two of my own items were withdrawn the same way.

Security

  • Twenty advisories reachable from our code are closed. govulncheck
    reported twenty vulnerabilities reachable from our own call paths — not
    merely present in the module graph: six in net/http, three in
    html/template, others in the standard library of go1.26.5, five in
    golang.org/x/net v0.55.0 and two in google.golang.org/grpc v1.82.1. The
    go directive now requires 1.26.9, x/net moves to v0.60.0 and grpc
    to v1.83.2 in both modules; both now report zero reachable
    vulnerabilities.

    The exposure was in the artefacts, not in a future build: the release
    workflow compiled at 1.26.5, so every binary published before this release
    carries the vulnerable standard library. Upgrade rather than wait.

    grpc v1.83.2 and not the v1.83.1 one advisory named: that version left
    GO-2026-6443 standing — 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.

Added

  • The list tools can be paged, so nothing is unreachable any more. after /
    next_cursor on find_entities and get_neighbors, plus a truncation
    sentence beside the boolean. Measured on a live tenant: describe_schema
    reported 236 network.endpoint and find_entities could return 200 — the
    remaining 36 were unreachable by any sequence of calls, and that type carries
    no attributes, 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 (replica-identical, unmoved by re-minting), and
    neighbours by depth then fingerprint so the closest still come first. Not an
    offset
    : over a live projection an offset silently skips and repeats. (#432)

  • Filter changes by attribute, as the event saw it. match on
    recent_changes and recentChanges, applied before the page bound. The
    attribute is tested against the entity as the event recorded it, not
    against the live graph: an entity deleted during the window is gone from the
    projection, and in an incident its changes are the ones worth looking at — so
    an entity that dies mid-window still matches on the attribute it died with.
    That also removes the store-format change this feature was priced at. Relation
    changes carry no entity and are excluded while the filter is set, and the tool
    says so. Costs decoding, like the scope filter. (#380)

  • Expanding a folded owner gives kinds, not children. Three levels instead of
    two: owner, then one node per child type with its count, then the things. A
    switch with twelve ports opens to 12 network.interface and then to the ports,
    costing a click and losing nothing; a host with 357 services opens to
    357 service.instance and stops there until the reader narrows it. Past 60 of
    one kind, opening is refused with a line saying to use the attribute filter —
    drawing them rebuilds the hairball the fold exists to escape. Measured at two
    customers: 367 entities to 3 drawn nodes and 548 to 6, because 97% of their
    relations are a single runs_on. (#392)

  • GraphQL answers are no longer a mute source. graphScope now reports
    freshness (newestEvent, read from the journal — when a producer last spoke —
    not from when the projection was rebuilt) and accepts asOf, under which the
    counts come from the folded graph rather than the live one. 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 now asserts the
    invariant the issue named: both surfaces report the same numbers for the same
    question at the same instant. (#359, #394)

  • An entity names the collection methods asserting it, not only the
    producers: collected_by beside asserted_by. asserted_by is who sends it,
    at the resource grain the reference counting is keyed on; collected_by is how
    it was gathered, which is the grain the producer contract makes normative and
    the one a producer debugs in. An entity seen by both snmp-route and
    snmp-lldp is a different situation from one seen by snmp-route from two
    agents, and the two were indistinguishable. (#394)

  • A change names the collection method that observed it. scope on every
    change event, on both surfaces, plus a scope filter on recent_changes /
    recentChanges. The producer contract puts provenance on the instrumentation
    scope — one per collection method — and no read surface could reach it, so
    "which of my collectors is losing its entities" was unanswerable: 1312
    liveness expirations in twelve hours with no way to attribute them. On a
    liveness expiry the scope names the collector that stopped speaking
    , not an
    author of the deletion: the scope is remembered on the liveness reference and
    read back when it lapses, because Toise authors that event. The filter is
    applied before the page bound, not on the bounded page — and it costs decoding,
    because the scope is not in the time index. (#394)

  • A change page says which end it holds. ChangeConnection now declares
    order (OLDEST_FIRST / NEWEST_FIRST) plus holdsNewest and
    holdsOldest. The two change pages run in opposite directions while sharing
    one type — entityHistory oldest-first, recentChanges newest-first — and
    nothing said which. pageInfo.hasNextPage answers "is there more"; these
    answer "is the newest event in what I am holding", which is the question a
    reader acts on. A reader who asked for a generous history page and took the
    last edge believed they held the newest event while holding one days old.
    (#366)

  • A commit groups its events, and says so. Every change event now carries
    commit_seq (commitSeq on GraphQL): 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 had to
    re-invent the pairing with its own tolerance window, and recorded_at is set
    per event, so not even equality on it was guaranteed to hold within a commit.
    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 (ADR 0022). Absent on events written before the field, where absence
    means unknown rather than a commit of its own. (#407)

Fixed

  • Paging no longer costs what it saved. The first cut of the paged list
    tools sorted by recomputing each entity's fingerprint inside the comparator,
    which runs O(n log n) times over a hash that allocates: the bench gate
    measured +1209% on time and +27624% on allocations. The ordering keys are now
    read from the projection's hash index and the page is cut inside the
    projection, so only the page is materialized. Against the pre-paging
    baseline, time is at parity and memory is down 53%.
  • The vocabulary is checked against the registry. The contract and the code
    had disagreed three times in one week, each costing a wrong answer given to a
    producer — including a relation type the contract called unregistered that had
    been registered for four versions. A test now fails on a registered entity
    type that no data-model document gives an identity for, with a positive
    control so it cannot pass by reading nothing.

Documentation

  • The export size cap is declared, and the no-split rule is corrected.
    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 5,000-host estate. And the only way out was
    forbidden too strongly: a partial snapshot does not read as entity
    deletes. Removal by absence is scoped to one entity's own relationship array,
    so an omitted entity is not deleted at all. Splitting is safe between
    entities, never within one, and every part must land within the shortest
    declared interval in the batch.
  • The Go toolchain requirement states 1.26.9 and why, so a reader building
    from source does not reproduce the exposure this release closes.

Changed

  • The CI workflows read the Go version from go.mod instead of pinning it
    four times. Three said 1.26.5 while the bench said 1.26.4: what was measured
    was not what was built. One source now, so the drift cannot recur.

  • The governance keys Toise invented left the entity.* namespace. They are
    now senhub.owner.team, senhub.owner.contact, senhub.location.site /
    .datacenter / .rack / .room, senhub.lifecycle.status and
    senhub.label.<key>; the network device role is senhub.device.role. The
    semconv naming rule is explicit that an existing OpenTelemetry namespace must
    not be used as a prefix for application-specific keys, and entity.* is the
    entity-events convention's own. entity.id, entity.type, entity.state,
    entity.relationships and the event names come from that convention and are
    unchanged.

    Nothing is rejected and nothing is rewritten. These are descriptive
    attributes, so the engine kept whatever producers sent (ADR 0022) and the
    entity.* values already in a graph are still there and still readable. No
    entity is re-minted, these never having been identity. describe_schema now
    lists each key's previous spellings 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 entity.owner.team with other semantics,
    field data would have meant two things at once with nothing to separate them.