Frame semantics framework
Status: design (v1.3.0 candidate). Not yet shipped. Supersedes the
earlier map-widget.md ADR — that doc proposed an IPositionExtractor
contract on each protocol plugin; this one moves the entire mechanism
to a transport-agnostic schema layer and treats the map widget as the
first consumer rather than the framework itself.
Why this is not "the map widget"
The visible feature is "show coordinates on a map". The underlying question is broader: how does Bowire decide which viewer or editor to mount for a given field in a payload? A few use cases queue up behind the map case:
- A field carries PNG bytes → image viewer.
- A field carries a WAV / Opus chunk → audio player.
- A field carries an array of timestamped scalars → chart.
- A field carries an array of records with consistent shape → grid.
- A request field is
latitude+longitudepaired → map editor (drag a pin, the request fields fill in).
A protocol plugin like gRPC, REST, GraphQL, SignalR, SocketIO, MQTT or
WebSocket is fundamentally content-agnostic — it carries opaque
payloads defined by user schemas. A IPositionExtractor per plugin
would either force the plugin to know every user's domain schema
(impossible) or ship blank for every transport that doesn't carry
something inherently geographic (most of them). Neither scales.
The pattern that does scale is the one pgAdmin uses: inspect the
schema, recognise a known semantic (geometry column → map view), and
offer the matching viewer. Bowire generalises that to "any field
with a known semantic gets a matching viewer/editor." The plugin
contract stays at the transport layer.
Core model: tagged schema annotations
The single data model the whole framework runs on is a set of semantic annotations on schema fields. Each annotation says "this field, in this method, in this message type, carries semantic X." Detectors propose annotations; users edit them; viewers and editors consume them.
Annotation key
key = (service-id, method-id, message-type-discriminator, json-path)
Four-dimensional, not three. The discriminator dimension matters because a single transport channel can carry multiple message types:
| Transport | Single channel, multiple shapes |
|---|---|
| DIS | One UDP/multicast channel — PDU types EntityState, Fire, Detonation, Emission, Radio, … |
| MQTT | One topic — envelope-tagged { "type": "PortCallScheduled" } |
| SignalR | One hub stream — internal tagged-union per eventType |
| Kafka | One topic, multiple registered Avro/Protobuf schemas |
| Protobuf | A oneof field — same message, different sub-shape per tag |
| WebSocket | One socket, frames are { kind, payload } |
message-type-discriminator is either
*— Wildcard. Default for single-type methods (classical REST GET, most gRPC unary calls).- A concrete value — e.g.
"EntityStatePdu",1(DIS PDU-type byte),"PortCallScheduled"(envelopetypefield value).
Annotation value (semantic)
A short namespaced string identifying what the field means:
coordinate.latitude
coordinate.longitude
coordinate.ecef.x
coordinate.ecef.y
coordinate.ecef.z
image.bytes
image.mime-type
audio.bytes
audio.sample-rate
timeseries.timestamp
timeseries.value
table.row-array
none # explicit suppression — overrides upstream proposals
none is a regular annotation value, not a separate suppression
mechanism. The resolution rule is "highest-authority annotation
wins", and a user-supplied none wins over an auto-detector's
coordinate.latitude.
Discriminator declaration
The discriminator itself is an annotation on the schema. Examples:
# DIS plugin shipping a Discovery-Descriptor extension:
discriminator:
wirePath: byte[1] # before JSON decode, on the wire bytes
typeRegistry: dis.PduType # { 1: EntityStatePdu, 2: FirePdu, … }
# Envelope-style protocol (MQTT / WebSocket / SignalR):
discriminator:
jsonPath: $.type # after JSON decode
# Protobuf oneof:
discriminator:
oneof: payload # name of the oneof group
When no discriminator is declared and the user hasn't marked one, the
method is treated as single-type — discriminator value *.
Annotation sources and resolution priority
Annotations can come from four sources. When two sources disagree on the same key, the higher-priority source wins:
| Priority | Source | Where it lives |
|---|---|---|
| 1 (highest) | User manual edit | Right-click on a field in the response tree |
| 2 | Plugin schema hints | Plugin emits them in the Discovery-Descriptor |
| 3 | Auto-detector proposals | Bowire core scans sample frames, writes proposals |
| 4 (lowest) | (no annotation) | Field is just scalar.number or similar — no viewer triggered |
The user is ultimate ground truth. A semantic: "none" from the user
suppresses everything below.
Auto-detector heuristics (built-in)
The auto-detector is not a per-frame runtime mechanism. It runs once per discriminator-value, on a small sample of frames, and writes proposed annotations to the schema. From then on, viewer routing reads from the schema, not from the detector.
Default detectors shipped in Bowire core:
- WGS84 coordinate: paired fields whose names match
lat(itude)?andlo?ng(itude)?(case-insensitive), with numeric ranges[-90, 90]and[-180, 180]respectively, at the same parent path. - GeoJSON Point: an object with
type: "Point"andcoordinates: [number, number]. - Image bytes: a byte array whose first bytes match a known image
magic (
89 50 4E 47for PNG,FF D8 FFfor JPEG, …). - Audio bytes: byte array with RIFF / OggS / fLaC headers.
- Timestamp: numeric or string fields with
*timestamp*/*time*/*at*in the name and an ISO-8601-or-epoch shape.
The list is conservative. Cases the heuristic misses are exactly the cases the user resolves with one right-click.
Plugin schema hints
Plugins can opt in by emitting annotations in their Discovery descriptor. Optional — a plugin that doesn't ship any hints is fully supported by the framework, the user just does more clicking.
Plugins that ship hints out of the box (eventual):
- Kuestenlogik.Bowire.Protocol.Dis — discriminator declaration (PDU type byte), plus per-PDU-type field annotations covering ECEF coordinates, entity ids, etc.
- Kuestenlogik.Bowire.Protocol.TacticalApi — discriminator and WGS84 location annotations for situation objects.
User manual annotation — shipped in v1.3.0 Phase 4
The right-click menu on any field in the response tree:
$.position.x Auto-detected: coordinate.latitude
✓ Accept
↪ Reinterpret as ▸ coordinate.longitude
coordinate.ecef.x
pixel.y
scalar.number
(more…)
✗ Suppress ("not a coordinate")
📌 Persist for ▸ this session
this user
this project (bowire.schema-hints.json)
Scope (for this and future frames):
● Just EntityStatePdu in this method [default]
○ All message types in this method where this path exists
○ All message types in this method, all matching path names
Default scope is the narrowest one (current discriminator value only). Cross-type propagation is opt-in.
UI shape — popup menu, not modal dialog. The menu floats over the
response pane via position: fixed, with a viewport-clamping step that
reads getBoundingClientRect() once after first render and shifts the
menu inward when its right or bottom edge would otherwise leave the
viewport. A modal would interrupt the user's reading flow on every
right-click; the popup keeps the response tree visible and dismisses on
Escape, click-outside, or after a successful action. Long-press on
touch devices opens the same menu (600 ms — below that the gesture
conflicts with native scroll/select).
Companion-field suggestion — follow-up toast, not menu-internal
step. Marking a field as coordinate.latitude triggers an
asynchronous walk of sibling fields under the same parent path; the
framework picks the top two numeric candidates and offers them as
single-click buttons inside an info toast: "Pair with a longitude?
[field_a] [field_b] [None]". Click → writes the companion
annotation under the chosen sibling at the same tier as the source
mark. Symmetric for coordinate.longitude, coordinate.ecef.{x,y,z},
image.bytes ↔ image.mime-type, audio.bytes ↔ audio.sample-rate.
The suggester never blocks the menu close — failure or absence of
candidates is silent.
Badge palette — neutral / accent-tint / accent. Every annotated
leaf in the response tree carries a .bowire-semantics-badge showing
kind (source). Colour-coded by source tier:
(auto) → neutral grey, matches the rest of the tree's secondary text;
(plugin) → accent-subtle background with accent foreground, marking
plugin-supplied hints; (user) → solid accent background, the
user-pinned annotation. Clicking a badge opens the same menu the
contextmenu event opens (same bowireOpenSemanticsMenu entry point).
Unannotated leaves carry no badge — clean visual.
Persistence sticky default. "Persist for ▸" picks the storage
tier; the choice persists across the rest of the session in
localStorage.bowire_semantics_persist_default (allowed values
session / user / project) so a power user who consistently
promotes to the project file doesn't re-pick "project" every time.
First-time use defaults to session.
Scope picker — bounded by the seen-discriminators catalogue. The
"All message types in this method where this path exists" and "all
matching path names" options would in principle be quadratic over the
schema universe. In practice the framework only writes to discriminator
values + path leaves the workbench has already streamed through — the
bowire:stream-message event handler updates a write-only catalogue
that the scope expander reads. A new method with a single
discriminator value seen so far translates "all message types" into
"the one type" — no work amplification on cold methods.
HTTP endpoints. POST /api/semantics/annotation writes one
annotation at the named tier and returns the resulting effective tag
(so the UI badge can update atomically). DELETE removes it from the
named tier and returns the new effective tag — important for the
cross-tier-survival semantics: deleting a user-tier none reveals the
auto-detector's coordinate.latitude beneath, and the badge changes
from "user/suppressed" to "auto/latitude" without a refresh round-trip.
Tier-disabled deployments (empty SchemaHintsPath, missing project
file) return 404 on the unavailable tier — graceful degrade to the
remaining tiers.
Persistence
Three storage layers, with explicit escalation rather than implicit sync:
| Layer | Path | Lifetime |
|---|---|---|
| Session | in-memory | Until tab close. Default — zero friction. |
| User | ~/.bowire/schema-hints.json |
Survives restart. User clicks "Remember." |
| Project | bowire.schema-hints.json in repo root |
Team-shared, version-controlled. Explicit Export. |
Wire format (project file, identical shape for the user-local one):
{
"version": 1,
"schemas": [
{
"service": "dis.LiveExercise",
"method": "Subscribe",
"discriminator": {
"wirePath": "byte[1]",
"registry": "dis.PduType"
},
"types": {
"EntityStatePdu": {
"$.entityLocation.x": "coordinate.ecef.x",
"$.entityLocation.y": "coordinate.ecef.y",
"$.entityLocation.z": "coordinate.ecef.z"
},
"FirePdu": {
"$.locationInWorldCoords.x": "coordinate.ecef.x",
"$.locationInWorldCoords.y": "coordinate.ecef.y",
"$.locationInWorldCoords.z": "coordinate.ecef.z"
}
}
},
{
"service": "harbor.HarborService",
"method": "WatchCrane",
"types": {
"*": {
"$.position.lat": "coordinate.latitude",
"$.position.lon": "coordinate.longitude"
}
}
}
]
}
"*" is the literal wildcard for methods with a single message type.
Viewer / editor symmetry
A semantic annotation is bidirectional. The same
coordinate.latitude annotation drives:
| Direction | Behaviour |
|---|---|
| Response side | Map viewer mounts as a tab. Streaming frames render as points; their colour can be driven by the discriminator value (e.g. EntityStatePdu blue, FirePdu red). |
| Request side | Map editor replaces the two number-input fields. Dragging the pin updates both fields in the request payload before submit. |
This is the architectural reason "field annotations" is the right framing, not "detector output." A detector is a one-way concept. An annotation on a schema is two-way.
Pairing requirement
A widget can require multiple paired annotations to mount. The map widget needs:
required:
- semantic: coordinate.latitude
- semantic: coordinate.longitude
scope: same parent object # $.position.lat and $.position.lon,
# not $.a.lat and $.b.lon
If only one annotation of the pair exists, the widget shows a hint
("Latitude marked, longitude needed — pick a companion field?")
rather than mounting with degraded state. Image-viewer pairs
image.bytes with optional image.mime-type. Audio-player pairs
audio.bytes with audio.sample-rate.
Layout manager
How a viewer gets mounted is the workbench's call, not the widget's. The widget contract above only says "here is what mounts when a kind is present"; the layout decision — tab in the response pane, side-by-side split, future floating window — lives one layer up so the workbench can keep the surface evolving without forcing widget authors through a breaking-contract migration every time a new layout mode lands.
Per-kind defaults live in wwwroot/js/layout.js → defaultLayoutForKind:
coordinate.wgs84→split-horizontal(list-on-left, map-on-right). The map needs to react to multi-selected frames in real time, which only works if the streaming-frames list stays visible alongside it.- every other kind →
tab(unchanged behaviour — the response pane carries the viewer next to "Response" and "Response Metadata").
The user can override the default per-(service, method, widget) tuple.
The choice persists to localStorage under
bowire_widget_layout:${serviceId}:${methodId}:${widgetId} with a
forward-compatible shape: { mode: 'tab' | 'split-horizontal' | 'split-vertical' | 'floating', ratio?: number }. v1.3.1 ships
tab and split-horizontal from the toggle UI only; the other two
modes are accepted by the persistence layer so a future minor version
can light them up without a migration. floating (pop-out window) is
reserved for v1.x and explicitly out of scope for 3.1.
The split-pane primitive itself (layout.js → createSplitPane) is
deliberately widget-agnostic — vanilla JS, two named slots, one
draggable divider, ratio persisted under a caller-supplied
localStorage key, mobile-fallback to a vertical stack below 720px.
The divider drag handler attaches mousemove / mouseup to the
document only for the duration of a single drag and detaches both
on mouseup, so a render() that tears down and rebuilds the split
pane never leaks handlers across recreates.
Layer behaviour for mixed discriminators
When several discriminator values in the same method produce frames
matching the same widget (e.g. DIS stream emitting both EntityState
and Detonation, both annotated with coordinate.ecef.*), the
default rendering is one widget, one layer per discriminator
value. Layer-toggle in the widget UI lets the user hide individual
types. Two separate tabs is the explicit alternative when the
visualisations are semantically very different.
Lifecycle of a binding
1. User opens a service+method for the first time.
2. Bowire probes (in order):
- existing user-supplied annotations (from session/user/project)
- plugin-supplied schema hints from Discovery descriptor
- auto-detector against a small sample of frames
3. Each newly-classified field appears in the response tree with a
semantic badge: "coordinate.latitude (auto)" or "(plugin)".
4. User can accept / redirect / suppress per field.
5. Effective schema is settled. Widget(s) mount if pairing
requirements are met.
6. All subsequent frames + request edits route through the fixed
binding — no re-detection per frame.
7. New message-type discriminator seen → auto-detector runs once
for that new type, repeats from step 3 for new (type, path)
pairs. Already-classified pairs are NOT re-evaluated.
8. Session-end or explicit "Reset annotations" clears the
in-memory layer. Persisted layers (user / project) survive.
Step 7 — "learn as new types arrive" — is the key correctness
property for sparse streams. A FirePdu that arrives once per minute
gets classified the first time it shows up, not at initial subscribe.
Map widget — the first viewer/editor
The framework's first concrete consumer. Ships in its own NuGet package
Kuestenlogik.Bowire.Map (Phase 3-R — extracted out
of core in the v1.3.0 pre-release refactor so users who never invoke a
coordinate-carrying method don't pay the ~870 KB bundle cost). The
package follows the standard extension model — [BowireExtension]
descriptor + embedded JS/CSS/vendor assets — and is the dogfood proof
that the same shape third parties write against is the shape Bowire's
own widgets use. Install via dotnet add package Kuestenlogik.Bowire.Map; the workbench's
/api/ui/extensions enumeration auto-discovers it at boot, dynamic-
loads the bundle via bowireLoadExternalExtensions, and registers the
viewer + editor against coordinate.wgs84. Implementation choices:
Map library: MapLibre GL JS
maplibre-gl@4.x, BSD-3-Clause. Not Leaflet. The earlier draft
defaulted to Leaflet on a "smallest dependency" reflex; in context
that was wrong.
- Performance: WebGL renderer handles ~10⁴ features without fighting the DOM. Leaflet's SVG/DOM model needs Canvas-plugin workarounds past ~10³. DIS at 30 Hz × 100 entities = ~3000 feature updates per second — exactly the regime where Leaflet forces an architecture detour.
- Vector tiles are first-class. The 2026 baseline for any
serious tile source (Protomaps, MapTiler, OSM-vector) is vector.
Leaflet needs
Leaflet.VectorGridfor the same thing. - Symbology: sprite-atlas is first-class — when DIS / TacticalAPI layers want ship-vs-aircraft-vs-ground distinctions, MapLibre has the data model for it without per-icon URL juggling.
- Bundle: ~200 KB gz vs. Leaflet's ~40 KB. For a developer tool that loads once per session, this is a non-trade-off.
- License: BSD-3-Clause, compatible with Bowire's Apache-2.0.
Default tile source: Protomaps (free, OSM-based, single-file
.pmtiles self-host-friendly). Online mode pulls from
https://api.protomaps.com/tiles/v3/{z}/{x}/{y}.mvt. Offline mode
(no Bowire:MapTileUrl configured) renders pins on a blank grid
background — same offline-safe behaviour as the earlier draft.
Subscription wire
No separate /api/invoke/stream/map endpoint. The existing
/api/invoke/stream SSE channel is enriched: each frame event
already carries the decoded JSON; when the schema has annotations
that downstream widgets care about, the same event additionally
carries an interpretations field:
event: frame
data: {
"frame": { ...the decoded payload as today... },
"discriminator": "EntityStatePdu",
"interpretations": [
{ "kind": "coordinate.wgs84",
"path": "$.position",
"lat": 53.5478, "lon": 9.9925 }
]
}
One SSE connection per method (as today), interpretations are an
additive side-channel. Recordings persist the interpretations
field alongside frames; replay re-emits them at the original cadence
without re-running detection.
Offline mode
Bowire's no-network guarantee survives:
- MapLibre JS + CSS ship as embedded resources of the
Kuestenlogik.Bowire.MapNuGet package (Phase 3-R — moved out of core for the bandwidth win). Same-origin served via/api/ui/extensions/kuestenlogik.maplibre/{name}. Users who install the package pay the ~870 KB asset cost once at first map mount; users who don't install it never see the bundle at all. - Default behaviour without
Bowire:MapTileUrl: blank grid + pins on top. Renders correctly with zero outbound HTTP. - Sample / docs reference a local Protomaps single-file
(
tileserver-glstyle) for the "with tiles" demo.
Glyph / sprite lockdown (Phase 3-R). The MapLibre style declared by
the widget intentionally omits both the glyphs URL and the sprite
URL — regardless of whether Bowire:MapTileUrl is set. MapLibre only
fetches glyph PBFs when a symbol layer with a text-field paint
property is mounted, and only fetches sprite atlases when an
icon-image layer is mounted; the widget renders pins through the
circle layer primitive (per-discriminator colour, multi-select
restyle) which needs neither. A regex-over-bundle CI test pins the
absence of both fields plus the absence of text-field / icon-image
references — any future style tweak that re-introduces an outbound
glyph or sprite fetch fails the build before the no-network guarantee
leaks. When the user does set a tile URL, the tile fetch is the only
external request the workbench ever makes against that origin.
The framework itself has no network dependency — tile downloads are an optional enhancement, not a requirement.
Recording / replay integration — shipped in v1.3.0 Phase 5
The recording-step schema gains two additive, optional fields:
{
"step": 42,
"request": { ... },
"response": { ... },
"responseBinary": "...",
"discriminator": "EntityStatePdu",
"interpretations": [
{ "kind": "coordinate.wgs84",
"path": "$.position",
"payload": { "lat": 53.5478, "lon": 9.9925,
"latPath": "$.position.lat",
"lonPath": "$.position.lon" }
}
]
}
Recordings made under earlier Bowire versions (no discriminator,
no interpretations) replay unchanged — the framework re-runs
detection on load. Recordings made with v1.3+ replay deterministically
even if detector heuristics drift between versions.
RecordedInterpretation.Payload shape conventions. The payload is
deliberately a free-form JsonElement so the framework doesn't freeze
itself into one widget's shape this early. For built-in kinds:
coordinate.wgs84—{ "lat": number, "lon": number, "latPath": "...", "lonPath": "..." }. Lat/lon inlined as numbers so the map widget reads directly without re-resolving JSONPaths; the per-axis paths round-trip for round-trip-fidelity (the editor uses them to write back into the request).image.bytes—{ "data": "<base64>", "mimeType": "image/png"?, "bytesPath": "...", "mimePath": "..."? }. ThemimeTypeandmimePathkeys appear only when animage.mime-typecompanion annotation pairs with the bytes at the same parent.audio.bytes—{ "data": "<base64>", "sampleRate": 44100?, "bytesPath": "...", "ratePath": "..."? }. Same companion-optional shape.
Unknown / unhandled semantic kinds are silently skipped at capture time — they round-trip through the schema sidecar (see below) but don't emit per-frame interpretations until a future phase wires their payload extraction.
Effective-schema sidecar. A frozen snapshot of the effective annotation set at record-time lives at the top of the recording file alongside the existing metadata:
{
"recordingFormatVersion": 2,
"recordedAt": "...",
"schemaSnapshot": {
"annotations": [
{ "service": "harbor.HarborService",
"method": "WatchCrane",
"messageType": "*",
"jsonPath": "$.position.lat",
"semantic": "coordinate.latitude" },
{ "service": "harbor.HarborService",
"method": "WatchCrane",
"messageType": "*",
"jsonPath": "$.position.lon",
"semantic": "coordinate.longitude" }
]
},
"steps": [ ... ]
}
The sidecar is what the workbench uses to mount widgets when a recording
is opened — so a recording made against one user's annotations renders
identically for another user whose local annotations differ. v1-vs-v2
file detection is by presence of schemaSnapshot, not by bumping the
file-format version field. recordingFormatVersion stays at 2
(set by the Phase-1b gRPC-binary work); the loader treats the field's
absence as "ask the live annotation store at replay time" — strictly
backwards-compatible with every v1 / v2-pre-Phase-5 recording on disk.
Explicit "none" suppression entries round-trip unchanged so a
user-tier none replayed against a different store still suppresses
the lower-tier proposal.
RecordingReplayInterpretationResolver — short-circuit semantics.
The replay path consults this helper per emitted frame. When the step
carries interpretations the helper hands the list back verbatim and
skips the prober entirely — captured payloads win regardless of how
detector heuristics have drifted since record-time. When the step
lacks the field (pre-Phase-5 recordings), the helper runs the live
IFrameProber and RecordingInterpretationBuilder as a fall-back, so
older recordings replay through detection exactly as v1.2 did. The
prober's ProbedTripleCount is the test seam that verifies the
short-circuit: a v2 recording (interpretations captured) never ticks
the counter on replay.
Sample. Bowire.Samples/SchemaSemantics — a deliberately
plain-vanilla gRPC server-streaming method that emits
{ ship, lat, lon, status } frames. No IBowireSchemaHints
implementation, no plugin awareness, no manual user annotation file
shipped — the pgAdmin proof: the framework finds the coordinates by
content alone and the map mounts automatically.
Out of scope for v1.3
- Cross-method propagation: marking
$.coords.latin method A does not automatically apply to method B. Strict scope is(service, method, type, jsonPath). A future "promote to project-wide pattern" UI is a v1.4 candidate if real usage shows the need. - 3D map: pitch/bearing are MapLibre primitives but the v1 widget renders 2D only. Adding 3D is a styling change, not an architectural one.
- Drawing tools: measure, polygon, draw — not in v1.3.
- Custom tile-source UI:
Bowire:MapTileUrlconfig knob only. - Audio / image / chart / grid viewers: framework supports them, but only the map viewer ships in v1.3.0. The first additional viewer follows in v1.4.
Extension framework
The framework above only solves the built-in viewer/editor case.
The companion problem is: how does a third party ship a new viewer
or editor without forking Bowire core? Map / image / audio / chart /
grid are not exhaustive — users will eventually want a MIL-STD-2525
symbol viewer, a hex viewer for embedded-systems debugging, a
Mermaid-diagram renderer for text/mermaid payloads, a Protobuf-wire-
bytes inspector, a 3D-point-cloud viewer, … . Baking all of those into
core is the wrong direction; the extension surface is.
Bowire already has an extension model that works: protocol plugins ship as separate NuGet packages, auto-discover via assembly scan, and register against a stable contract. Viewer/editor/detector extensions get the same model — only a different package type.
Packaging and loading
NuGet package
└── Kuestenlogik.Bowire.Map
├── csproj with <PackageType>BowireExtension</PackageType>
├── BowireMapLibreExtension.cs — C# registration + metadata
└── EmbeddedResource: bundle.js + bundle.css
— JS implementation, served
from the local Bowire host
Loading sequence at workbench startup:
- Host assembly-scans for types attributed with
[BowireExtension](mirroring[BowireProtocol]discovery today). - Workbench calls
GET /api/ui/extensions→ JSON list of{ id, version, bowireApi, kinds, capabilities, bundleUrl, stylesUrl? }. - Per extension, the workbench dynamically imports the bundle URL. The URL serves the embedded resource from the local Bowire host — never from a third-party CDN. Offline-safe by construction.
- The bundle calls
window.BowireExtensions.register({...})with its registration record (see JS contract below). - The annotation → widget routing now treats the new
kindas mountable. Existing screens (response panes, request forms) pick up the new viewers/editors without any core code change.
The built-in viewers (map, image, audio, table) ship as regular
BowireExtension packages — not bundled with Bowire core. After
Phase 3-R the only thing core ships is the framework itself
(extensions.js, the /api/ui/extensions endpoint, the placeholder-
tab path); every viewer/editor — including the MapLibre map — is a
separate NuGet package the user explicitly installs. Same code path
third-party widget authors write against; gap in the API surfaces
immediately during Bowire's own development.
Placeholder tab for unregistered kinds (Phase 3-R). When the
workbench sees an annotation kind for which no extension has registered
— for example coordinate.latitude from the auto-detector but
Kuestenlogik.Bowire.Map is not installed — the
framework mounts a generic placeholder card in the viewer slot:
Install Kuestenlogik.Bowire.Map to render \coordinate.latitude` annotations on a map.The package id is copy-to-clipboard-able next to the message; Bowire core cannot install NuGet packages from the workbench, so the card is informational only. The placeholder is generic across kinds: akind → packageIdsuggestion table inextensions.jsplugs new entries in additively as new built-in extensions ship. The card suppresses itself when the kind is covered by an already-registered extension'spairing.requiredlist (so MapLibre being installed makes the companion-kind cards disappear), and dedupes by suggestion id so a method that surfaces bothcoordinate.latitudeandcoordinate.longitude` doesn't double-render the MapLibre card.
C# contracts
[BowireExtension]
public sealed class MapLibreExtension : IBowireUiExtension
{
public string Id => "kuestenlogik.maplibre";
public string BowireApiRange => "1.x"; // semver range
public string[] Kinds => ["coordinate.wgs84"];
public ExtensionCapabilities Capabilities
=> ExtensionCapabilities.Viewer | ExtensionCapabilities.Editor;
// Embedded resources, served at /api/ui/extensions/{Id}/{name}:
public string BundleResourceName => "bundle.js";
public string? StylesResourceName => "bundle.css";
// Future, post-v1.0: required permissions, declared kinds,
// dependent extensions. Empty in v1.0 — most extensions need none.
}
Server-side detectors register through a sibling contract — same attribute, different interface:
[BowireExtension]
public sealed class MilSymbolDetector : IBowireFieldDetector
{
public string Id => "kuestenlogik.milsymbol-detector";
public string BowireApiRange => "1.x";
public DetectionResult? Detect(FieldContext ctx) =>
ctx.Name.EndsWith("Sidc", StringComparison.Ordinal) &&
ctx.Value is string s && Sidc.IsValid(s)
? DetectionResult.Propose("mil.symbol-code")
: null;
}
A single NuGet package can ship multiple [BowireExtension] types
together — the MapLibre extension ships its UI extension; a future
MIL-symbol package would ship a detector and a viewer in the same
nupkg.
JS contract — BowireExtensions.register
This is the API that becomes a public commitment once published. v1.0 is deliberately tight; everything that doesn't need to ship in v1.0 waits for v1.1 to avoid baking premature decisions into a contract we can't break.
v1.0 ctx surface (minimal, evolves additively):
window.BowireExtensions.register({
id: 'kuestenlogik.maplibre',
bowireApi: '1.x',
kind: 'coordinate.wgs84',
pairing: {
required: ['coordinate.latitude', 'coordinate.longitude'],
scope: 'same-parent' // 'any' | 'same-parent' | 'same-object'
},
viewer: {
label: 'Map',
icon: 'map-pin',
selectionMode: 'multi', // Phase 3.2 — 'single' (default) | 'multi'
mount(container, ctx) {
// ctx.frames$ — async iterable of { frame, interpretations,
// discriminator } events on the response stream
// ctx.selection$ — async iterable of { selectedFrameIds }
// SNAPSHOTS (Phase 3.1, see below). Filtered
// to [lastSelected] when selectionMode is
// 'single' (Phase 3.2).
// ctx.theme — { mode: 'light' | 'dark', accent, font }
// ctx.viewport — { width, height, on(event, cb) → unsubscribe }
// ctx.host — { subscribeSse(url), fetch(url, init) }
// fetch uses Bowire's auth + CSP context;
// subscribeSse closes itself on unmount.
// returns: () => void — unmount cleanup
}
},
editor: {
label: 'Pick on map',
selectionMode: 'single', // Phase 3.2 — default
mount(container, ctx) {
// ctx.value — { 'coordinate.latitude': 53.5, … }
// (paired annotation kinds → current values)
// ctx.onChange(p) — patches the request payload with `p`
// ctx.disabled — boolean (e.g. during request execution)
// ctx.theme / ctx.viewport / ctx.host — same as viewer side
// returns: () => void
}
}
});
selection$ — snapshot stream, not deltas (Phase 3.1). The workbench
broadcasts the current set of user-selected frames every time the
Streaming-Frames pane's selection changes. Each event the widget pulls
from ctx.selection$ carries the complete { selectedFrameIds: ReadonlyArray<string|number> } snapshot — never a "+id" / "-id" delta.
This means a viewer never has to accumulate state to know what's
selected; the first awaited value IS the current selection, and every
subsequent value IS the new authoritative state. As a corollary, a
widget that mounts AFTER the user already made a selection (e.g. they
selected three frames, then switched layout from tab to split,
spinning up a fresh map) gets the current snapshot on its first
await — no manual rehydration needed. The transport is a custom
bowire:frames-selection-changed DOM event the workbench dispatches
exactly once per logical change (so N selected frames produce one
N-entry snapshot, never N delta events). Frame ids on the snapshot are
the same ${service}/${method}#${index} keys the workbench mints when
each frame arrives over the SSE stream.
selectionMode — per-block capability declaration (Phase 3.2). A
widget that can render only one selected item (a key-value detail view,
a single-coordinate editor) declares selectionMode: 'single' on its
viewer / editor block; widgets that can sensibly render N selected
items (the map, a future timeseries chart) declare 'multi'. This is
a capability declaration, not a user-facing setting — the user
still controls which items end up selected; the widget controls
whether the framework lets it see the full set. When a 'single'
widget is mounted, the framework wraps ctx.selection$ so each yielded
snapshot is truncated to [lastSelected] (the most-recently-added id
vs. the previous snapshot; on multi-add events like select-all, the
last entry in array order); empty snapshots stay empty. 'multi'
widgets receive the snapshot unfiltered. Default is 'single' —
conservative for new widget authors who haven't yet thought about
multi-select. The field is per-block so a viewer can be 'multi'
while the same widget's editor is 'single'; per-ctx filter state
keeps two widgets on the same method independent. Built-in defaults:
Map viewer → 'multi' (the existing fitBounds-the-selected-set
camera + restyle logic ships in Phase 3.1), Map editor → 'single'
(one coordinate pair at a time). Validation at register({...}) time
rejects any value other than 'single' / 'multi' with a
console.warn and drops the offending block — no silent coercion.
Deliberately not in v1.0:
- Recording playback controls (
currentStep,setStep,totalSteps). Recordings still display, but the scrub-control surface is added in v1.1 once we know what extensions actually need. - Cross-extension messaging. A v1.1 concern if real demand emerges.
- Persistence helpers for per-extension state. Extensions can use
localStoragefor now; first-class store comes later if needed.
Everything in ctx is frozen for the v1.x lifetime. Adding
fields is allowed in minor versions; removing or repurposing
existing fields is a v2.x change.
Versioning and compatibility
- Extension declares
bowireApi: '1.x'(semver range). - Workbench checks compatibility on load. Incompatible major → the extension is skipped with a UI badge "needs Bowire 2.x" in the Extensions marketplace tab, no silent failure.
- Bowire core MUST remain backwards-compatible within a major. That
discipline is the reason
ctxships tight in v1.0 — every field added to the contract is something we can't take back without a breaking release.
Permissions model
Categorical for v1.0, with an explicit user opt-in moment:
- Default: extension runs inside the workbench origin, can render
arbitrary DOM, can subscribe to Bowire-internal streams via
ctx.host.subscribeSse. Cannot make outbound HTTP fetches outside the Bowire origin (CSP-blocked at the response level). - An extension that needs an outbound fetch (e.g. a tile-server
consumer for the map widget) declares it in its registration:
permissions: ['fetch:protomaps.com/*'] - On first load of such an extension, the workbench prompts the user
once: "Extension kuestenlogik.maplibre wants to fetch tiles from
api.protomaps.com. Allow? [Just this session / Always / Never]".
The choice is persisted per-extension in
~/.bowire/extensions.json.
Two-class model: zero-permission extensions install silently, network- hungry extensions go through a one-time consent. Granular per-resource permissions (e.g. read-only vs. read-write tile cache) are a v1.x additive surface if the categorical model proves too coarse — but shipping coarse-first means most extensions never trigger a prompt, which is what users actually want.
Extension conflict resolution
Two extensions can register for the same kind. Resolution rule:
- Default: the built-in extension wins. The user's installed third-party extension still appears in the Extensions tab, but isn't auto-mounted.
- Per-binding override: the response pane has a viewer-picker
dropdown when more than one extension is registered for the
active
kind. Choosing a non-default extension persists the choice in the samebowire.schema-hints.jsonfile that carries the annotation, under aviewercompanion field:{ "service": "harbor.HarborService", "method": "WatchCrane", "types": { "*": { "$.position.lat": "coordinate.latitude", "$.position.lon": "coordinate.longitude" } }, "viewers": { "coordinate.wgs84": "acme.super-map" } } - The viewer override has the same three persistence tiers as the annotations themselves (session / user / project). Default scope is the narrowest one.
No registry-wide priority ordering, no automatic version-based preference — explicit user choice every time. Avoids the "why is this random extension rendering my data?" surprise; matches the pattern users already learn from the annotation system itself.
Cross-extension dependencies
An extension can introduce a new kind that other extensions consume. Bowire maintains a runtime kind registry:
- Built-in kinds (
coordinate.wgs84,coordinate.ecef,image.bytes,audio.bytes,timeseries.value,table.row-array, …) are registered by the core. - Extension kinds are declared at
register(…)time:declareKinds: ['mil.symbol-code', 'mil.echelon'] - Dependent extensions list their required kinds. On load, the
workbench checks the registry; if a required kind is missing, the
extension shows a disabled state in the Extensions tab with
"requires
acme.utm-detector". Lazy resolution — when a missing kind later becomes available (another extension loaded), the disabled extension is rebound without restart.
Marketplace UI
A dedicated tab in the workbench:
- Installed: built-ins (map, image, audio, table) on top, third-party below. Per-row: id, version, status (active / blocked-permission / blocked-bowire-version / blocked-dependency), link to source repo.
- Discover: nuget.org query with
packageType:BowireExtension. Click → install:- Embedded mode: writes a
<PackageReference>to the host project's csproj, runsdotnet restore, prompts for an app restart. - Standalone mode: downloads the nupkg to
~/.bowire/extensions/{id}/, the assembly is loaded on the next bowire start.
- Embedded mode: writes a
- Permissions: per-extension toggle for any granted permissions. Revoking is immediate; the extension is re-prompted on next use.
Scaffolding
dotnet new bowire-extension --kind audio.opus --name acme.opus-player
creates:
- csproj with
<PackageType>BowireExtension</PackageType>and the correct<EmbeddedResource>wiring for the JS bundle. - C# registration class implementing
IBowireUiExtensionwith pre-filledId/Kinds/Capabilities. - JS bundle stub with
register({...})skeleton and a TypeScript.d.tsfor the v1.0ctxAPI surface. - README with the publish-to-nuget.org checklist.
- GitHub Action template covering build → pack → push, matching the release pipeline already used by Bowire's own plugin repos.
Without scaffolding the boilerplate-cost is high enough to deter authors. The template is what turns "you could write an extension" into "you'll spend an evening on it."
Plugin author tax for protocol plugins
Protocol plugins (gRPC, REST, MQTT, …) keep their existing contract unchanged. The framework above only adds optional surfaces — none of them are mandatory:
- Schema annotations in Discovery descriptor: optional, lets the
plugin pre-fill annotations for known field shapes (e.g.
TacticalAPI tagging location fields as
coordinate.wgs84, DIS declaring its PDU-type discriminator). Skipping it means users do more right-clicking, nothing breaks. - Bundled viewer/editor: optional. A protocol plugin can ship its
own
[BowireExtension]if its payloads have a non-generic shape worth a dedicated visualisation. Most won't. - Custom detector: optional, server-side rule that proposes annotations the built-in heuristic can't reach.
The tax for "my protocol now supports the map widget" is zero if the schema uses conventional field names, one schema-hint file entry if it doesn't.
Risks and open questions
- Discriminator unknown at first: many real protocols don't
declare their discriminator in any machine-readable way. Bowire
falls back to single-type (
*) until the user marks a discriminator field. The right-click menu offers "this is the type discriminator (cardinality looks low)". - Pair-without-parent:
$.latand$.lonat root, but the protocol nests them as$.geometry.latand$.coords.lon. The pair-at-same-parent heuristic misses it. User resolves by marking both manually. - Annotation file conflicts: project-shared
bowire.schema-hints.jsonversioned alongside code; users with diverging user-local hints get a deterministic merge (project wins for any key where both sides have a value, otherwise user-local fills in). No interactive merge UI in v1.3. - Large annotation files for chatty schemas: a service with 200 methods × 5 types × 10 fields = 10,000 entries. The file is still small (sub-megabyte JSON) and is loaded once at startup.
- Detector versioning vs. live streams: tightening heuristics in v1.4 could re-classify a live stream differently from how v1.3 did. Persisted recordings are immune (they carry their own interpretations). Live behaviour can shift between major versions; documented as a breaking-change class.
Phasing
| Phase | Scope | Rough effort |
|---|---|---|
| 1 | Annotation data model + storage (session/user/project) + Discovery-Descriptor extension for plugin hints + resolution priority resolver | 4–5 days |
| 2 | Built-in detectors (WGS84 coordinate, GeoJSON Point, image magic, audio header, timestamp) + sample-frame probe + learn-as-new-types loop | 3–4 days |
| 3 | MapLibre bundle + map viewer registered on coordinate.wgs84 + offline-safe blank-tile fallback + pairing logic |
4–5 days |
| 4 | Manual user override UI (right-click → mark / redirect / suppress) + companion-field suggestion + scope picker | 4–5 days |
| 5 | Recording interpretations + replay determinism + sample-site demo (Bowire.Samples/SchemaSemantics) + docs |
2–3 days |
~3 weeks total for v1.3.0. Each phase is independently mergeable; phase 1+2 (the data model and detectors) can ship without phase 3 if the map widget needs to lag.
Sample
Bowire.Samples/SchemaSemantics — a deliberately plain-vanilla
gRPC service that streams { ship, lat, lon, status } frames. No
position-extractor, no plugin awareness, no custom viewer. Demonstrates
that the framework finds the coordinates by content, the map mounts
automatically, and the user has no extra work to do. The pgAdmin
proof.
A second variant within the same sample exposes a oneof payload
with PositionUpdate and StatusBroadcast cases — exercises the
discriminator path with protobuf-native semantics.
Cross-references
- gRPC-Web (v1.2.0) — orthogonal; the framework operates on decoded frames and doesn't know which transport delivered them.
Kuestenlogik.Bowire.Protocol.TacticalApiv1.0.0 ships the position-extractor schema hint alongside typed CRUD + the server-streaming track pump.Kuestenlogik.Bowire.Protocol.Disv1.x will ship the PDU-type-discriminator declaration and ECEF coordinate annotations.