The map widget stops being a scatter of dots and becomes something an operator can read: paths per entity, entities told apart by their own identity, and a cursor that rewinds. Alongside it, the gRPC plugin can finally call a server with Server Reflection switched off — the state Bowire's own scanner recommends — and the scanner learns to see three failures that survive every "is it public?" check.
The map
A path, not a scatter of dots
A stream of positions rendered as independent pins. Good for "where has it been", useless for "where is it going": the operator connected the dots by eye. There is now an optional LineString layer under the pins, off by default, toggled from the map's own overlay and remembered per workspace (#238).
The geometry is derived from the pin collection rather than accumulated beside it. Two things fall out of that: the existing 5000-pin FIFO cap already bounds it, so a vertex cannot outlive the pin it came from; and a pin and its segment cannot disagree about position, selection or colour, because there is one copy of that state.
Entities told apart by their own id
The map grouped and coloured by discriminator — the protocol's word for message type. On a wire carrying many entities that is one bucket for all of them, while the operator's actual question is "show me every ping of UnitID 42 as one track". A configurable track-id path now answers it (#240), with a collapsible legend: one row per track, its colour, how many pins it has on the map, and a switch to hide it.
Two stream shapes had to work and they pull in opposite directions. TacticalAPI puts N entities in one frame; DIS sends one entity per PDU across many. So a path resolves absolutely first, then relatively — walking up from the coordinate's own parent, because the identity lives above the node that holds the position: one level for a flat position, five for symbol.location.content.point.geoPoint. Only the data knows how deep.
Hiding a track is a render concern and never a state one. The master collection keeps every pin, so a hidden track still counts, keeps its selection, and comes back unchanged — rather than being deleted and having to be re-streamed, which for a finished stream means never.
The pin cap stays global. A per-track quota of 5000/N shrinks every existing track's allowance the moment an N+1th track appears, retroactively deleting history from tracks that did nothing, and the quiet ones lose the most. Taking each pin from whichever track is currently longest gives the same protection, because a chatty entity is the longest track.
Rewinding a stream
Once a stream stopped there was no way back to minute 7, and "where was it when the alert fired" needed a tool outside Bowire. A cursor now decides which frames count as already arrived, and the pin layer, the trajectory and the legend all read that one answer (#239).
The axis is frames, not pins — a frame is one update, and one frame may carry thirteen pins. Playback follows the data's own rate rather than a fixed tick, clamped at both ends so two frames stamped the same millisecond cannot spin and a five-minute gap cannot look frozen. The transport stays locked while the stream is live, because a cursor holding a position while the tail moves flickers between the two; the stream ending unlocks it, from finally rather than the happy path, since a stream that ends by throwing is exactly when someone wants to rewind.
Scrubbing never touches the selection. A frame selected while the cursor sits before it stays selected, is simply not drawn, and returns when the cursor passes it.
gRPC without reflection
Calling a server that has reflection switched off
Disabling Server Reflection is the recommended production state — Bowire's own scanner says so — and until now that made the gRPC plugin unable to call such a server at all, because descriptors came from exactly one place (#653).
A caller can now supply a compiled descriptor set, the artefact of protoc --descriptor_set_out=api.protoset --include_imports and the same input grpcurl -protoset takes. It travels in request metadata under a marker key, the way mTLS configuration already does, so no plugin signature changes and the marker is stripped before anything reaches the wire.
Reachable from every surface
The capability stopped at the plugin boundary, so walking it end to end against the sample carried it out: --grpc-descriptor-set on bowire list, describe, discover, call, scan, export and diff; ?grpcDescriptorSet= on /api/services; grpcDescriptorSet on bowire.discover and bowire.lint. All of them write the same metadata marker the plugin already reads, so there is no second channel to keep in step.
Security
The map widget never mounted at all
Not "rendered wrong" — did not render, in every build carrying the current Content-Security-Policy (#655).
MapLibre parses tiles in a web worker it constructs from a blob: URL. worker-src has no default of its own: absent, the browser falls back to script-src, which we set to 'self' plus a nonce. Neither covers a blob, so the worker was refused, MapLibre never reached its load event, and the widget's mount awaits exactly that. Nothing on the server said so — the page was 200, the header strict, and Bowire's own scanner read the policy as healthy, because it grades the header and the header was good.
worker-src 'self' blob: is now spelled out rather than left to inherit, so a later script-src edit cannot move the worker rule with it unnoticed.
Three failures that survive "is it public?"
- A gRPC server that authenticates but does not authorize. The existing probe asks whether a method answers a caller with no credential; here the stranger is correctly turned away, and what fails is the next question.
GrpcAuthorizationProbereaches a verdict from three observations — an anonymous call refused, identity A reaching the handler, identity B reaching it too — because only that combination shows a server gating on having a credential rather than on which one. - A WebSocket with the same shape. A handler that establishes who you are and then proceeds without consulting the permission model passes every "is it public?" check cleanly, and lets every authenticated account reach every channel.
- MQTT credentials crossing a link in the clear. A broker that correctly demands credentials and then takes them over plaintext hands every username and password to anyone on the path — MQTT carries them as plain fields inside the CONNECT packet, with no challenge-response to hide behind.
A reflection marker that read as reassurance
When reflection returned nothing, the probe reported "reflection is disabled (the desired production state)" and stopped. Two things were true and only one was being said: reflection is off, and the transport-authentication check never ran, because it needs a method to call. Read together, that told an operator their server was in the recommended state — about a server whose authentication had not been examined at all. And the better a deployment follows that recommendation, the more often it lands in that branch.
A plugin's documentation page is checked before it is published
fetch-plugin-docs.mjs pulls docs/protocol.md out of every repository carrying the plugin topic and writes it where DocFX renders it onto bowire.io — and DocFX passes raw HTML straight through markdown, so a page's bytes reach a visitor's browser on our own origin. The repositories are the org's own, which makes this a supply-chain guard: it is what stops one compromised plugin repository from putting script on the documentation site. Pages are now bounded in size, required to carry front matter, and refused outright if they carry markup that would execute.
Also in this release
MCP resources address a workspace
They read the workspace-less files, so they answered with the wrong data (#642). Resources are now workspace-addressable, with an index to address them by.
The correlation suggester weighs evidence
It gated on the field name before weighing any evidence, so a shared identifier under an unexpected name was never suggested and a coincidental name always was (#650).
The VS Code extension is 1.0
kuestenlogik.bowire-vscode 1.0.0 is on the Marketplace (#619). 1.0 is a promise about the surface: the bowire.* settings keys and the command ids stay where they are, and moving one is a major version. The extension's version stays independent of Bowire's — it drives an installed CLI rather than bundling one, and hosts anything from 2.5 upwards.
A SCIM provisioning round-trip leaves evidence
scim/events.jsonl records mutations and their outcome, so a connector's reads — the paging walk, the existence filter — left no trace, and the reads are most of what a live round-trip is for. Bowire:Scim:TraceProvisioning (off by default) now writes one line per SCIM request: method, path and query, status, duration, and for PATCH the dialect read off the wire. docs/setup/scim.md grows the matrix to walk and a table naming which provider version was exercised and when.
Off by default, and meant to be turned off again: the lines carry what the connector sent — user names, e-mail addresses, the filters a directory walk used.
SOAP ships in the workbench bundle
It was the only in-repo protocol left out of it without a reason. NATS and Pulsar sit outside because each carries a third-party client library, which the optional-package rule keeps out of a bundle an embedded host has to pay for; SOAP has no dependency at all, its sample's header printed bowire --url soap@… with no install step, and the bundle's own comment already listed SOAP among the universal protocols it contains. Only the ProjectReference was missing. soap@ now answers on a fresh install.
The protocol guide gained a Bundled column at the same time, because the table's split was "ships from this repo" and a reader took it for "works out of the box" — which for NATS and Pulsar it is not.
Under the hood
The release cascade resolves dotnet new template defaults
Bowire.Templates holds its Bowire version twice: as a placeholder in the plugin template's Directory.Packages.props, and as the defaultValue of the template.json symbol that replaces it. The bump step walks past the placeholder on purpose — it is not a version number — and nothing resolved the other half. The default had read 1.6.0 since May while the same template's non-CPM file tracked every release, so dotnet new bowire-plugin generated a plugin against a Bowire five minors old. Four cascade runs merged green, because the postcondition only ever looked at project files.
One marker for "was I explicitly asked for", not one per plugin
A plugin that discovers from a bundled schema, or has an ad-hoc separate-target fallback, needs to know whether the caller pinned it with hint@url — and BowireServerUrl.Parse consumes that prefix before any plugin is reached. SSE and SignalR had each grown a private URL marker for it, kept aligned with a comment. TacticalAPI gated on the prefix itself, which can never arrive, so its discovery returned an empty list on every path while its own unit test passed. There is now one shared marker, and both private ones are gone.
The release gate agrees with the publish step
Two steps in release.yml resolved the same source chain and disagreed about what counts as curated. A template body cleared the first test and failed the second, so the gate would report "found curated block", the publish step would discard it, and the release would go out carrying the auto-generated change list — the exact outcome the gate exists to prevent, with nothing failing.
Acknowledgements
The gRPC reflection work started from a walk against the sample rather than from a unit test, and the three defects it found were the kind only that walk produces. The same is true of the map: every fixture passed while the browser showed thirteen entities as eight tracks, a map that never framed its data, and a checkbox reading unchecked over a visible layer.
The full commit list, contributors, and compare-URL diff are auto-generated below.
Added
- bundle — ship SOAP in the workbench, and reconcile the docs with the code (
6135fd6) - scim — give a provisioning round-trip something to leave behind (#639) (
4b943de) - map — rewind the stream instead of only watching its tail (#239) (
4bd7ba4) - map — group pins by an entity's own id, not by message type (#240) (
d6ac9df) - map — draw the path between sequential frames (#238) (
03fa0b8) - scanner — detect a gRPC server that authenticates but does not authorize (
91a6d6a) - grpc — reach a reflection-less server from every surface (
af72554) - grpc — call a server that has reflection switched off (
b1c8772) - scanner — detect a WebSocket that authenticates but does not authorize (
d94f7a5) - scanner — report MQTT credentials crossing a link in the clear (
019d8d0) - mcp — workspace-addressable resources, and an index to address them by (
4d8f8bb)
Fixed
- discovery — carry the plugin hint in metadata, and to every surface (
6d10904) - docs — check a plugin's page before publishing its bytes (
1e7309e) - discovery — one marker for "was I explicitly asked for", not one per plugin (
d701f9e) - map — the track id lives above the coordinate, not beside it (#240) (
826ff64) - security — name worker-src, or the map never finishes loading (
2d6250e) - scanner — stop the gRPC reflection marker reading as reassurance (
7c3d3b2) - recordings — the correlation suggester weighs evidence instead of gating on names (
0b43cca)
Documentation
- testing — record the v2.7 acceptance run, cover four unwritten rails (
36f6377) - release-notes — curate v2.7.0, and move upcoming to 2.8 (
325e7af) - codeql — why the C# sanitiser technique does not reach JavaScript (
0c526ee) - release-notes — reset upcoming.md to 2.7, and make the gate agree with the publish step (
c33f058) - one source per plugin page, and a visible failure instead of a stale one (
038303e)
Other
- a sixth boat for the VS Code extension (
5b9a861)
Maintenance — 20 maintenance commits · 5 dependency updates
- roadmap — sync from Project board (
19740e8) - roadmap — sync from Project board (
b1b2c4f) - roadmap — sync from Project board (
a0da970) - roadmap — sync from Project board (
e4861f0) - roadmap — sync from Project board (
dc98e55) - roadmap — sync from Project board (
8e8de09) - roadmap — sync from Project board (
ab67125) - roadmap — sync from Project board (
ea616c1) - roadmap — sync from Project board (
82d9cb1) - roadmap — sync from Project board (
ce6b361) - cascade — resolve the dotnet-new template default the placeholder guard leaves behind (
d5afdbd) - roadmap — sync from Project board (
c2e5a9d) - roadmap — sync from Project board (
e88e529) - roadmap — sync from Project board (
db84e78) - roadmap — sync from Project board (
7625d07) - roadmap — sync from Project board (
95eae24) - roadmap — sync from Project board (
c12d4a4) - roadmap — sync from Project board (
33b9d29) - roadmap — sync from Project board (
00345dd) - roadmap — sync from Project board (
2be37aa)
Dependency updates
- chore(deps): Bump MinVer from 7.0.0 to 8.0.0 (
8706d89) - chore(deps): Bump Microsoft.Testing.Extensions.CodeCoverage from 18.10.0 to 18.11.0 (
36ea6b9) - chore(deps): Bump HotChocolate.AspNetCore and HotChocolate.Subscriptions.InMemory (
fda00a0) - chore(deps): Bump the grpc group with 1 update (
78ae294) - chore(actions): bump softprops/action-gh-release from 3.0.2 to 3.0.3 (
17c9f62)
Contributors: Thomas Stegemann
Full diff: v2.6.2...v2.7.0