Bridges and Channels — design notes
Working design notes, not a user manual: they carry the reasoning, the wrong turns and the open questions, because those are what stop a settled decision from being re-argued. Draft 2026-05-22, revised 2026-05-25, 2026-08-26, 2026-08-27, 2026-08-29. Not implemented.
Revision 2026-05-25 — direction change. HTTP stays inline in the broker, not in a plugin. The earlier "HTTP refactor first as a no-op move into
http.so" recipe is dropped. The bridge family is now specifically for non-HTTP transports of broker-to-CSR traffic. What the bridge work actually adds inside the broker is a transport-neutral request/response shape (BridgeRequest/BridgeResponse) and a single inbound entry point (serveDistOp(BridgeRequest)) that both the HTTP listener and any bridge plugin's inbound thread converge on. HTTP is then "the one inline implementor of the bridge interface" — it's just not packaged as a.so. See §1 and §9 for the updated framing.Revision 2026-08-26 — the CSR-based mapping convention of §3 is dropped. Bridge endpoints are no longer Context Source Registrations. The mapping becomes two objects of its own — a Bridge (the transport instance) and a Channel (one foreign endpoint tied to one entity attribute) — because a registration makes a claim about the world that a bridge does not make. The endpoint URI scheme convention is still shared across registrations, subscriptions and bridges; only the object is not. §9.1 also widens: DDS services and actions are in scope after all, client-side only, and §10 changes from "defer them" to "adopt a provisional convention and quarantine it". See §3, §9.1, §10.
Revision 2026-08-27 — the model of §3 was derived from DDS, and DDS is unusually well-behaved. Tested against the other protocols a south bridge must carry, it holds for addressed endpoints (OPC-UA nodes, LWM2M resources, Modbus registers) and breaks for bundle sources (UltraLight, JSON, CSV, LoRaWAN, Sigfox, Kafka), where one arriving message sets many attributes on an entity it names itself. Consequences: a Channel gains a codec, device provisioning turns out to be a shape of Channel rather than a layer above it, and a Bridge may be a listener as well as a client. See §3.8. Also corrected: the Bridge resource is not read-only. The bridge kind is startup-fixed; the instance is a stored object with full CRUD, a 409 on a duplicate id, and an orphan case to answer — §3.5a. §3.5b settles how a Bridge names its plugin (short name, never a path in the payload), why the broker does not auto-create one from a plugin it finds, that both objects carry an
idand atypelike every other stored body, and that a Channel names its Bridge explicitly — the endpoint scheme stops being a selector as soon as two Bridges of a kind exist.Revision 2026-08-29 — the object called a Binding is now a Channel. "Binding" is taken in TC DATA at document-title level (TS 104 176 "NGSI-LD API Bindings", TS 104 243 "MQTT Notification Binding"), where it means how API operations are conveyed over a protocol; ours means how a value is carried to and from a foreign endpoint. The reasoning, and why Map is worse, is in §3. Also settled: the three clocks that the lifecycle question kept conflating (§3), a
statuson both objects so the degraded states are observable rather than silent (§3.3a), no runtime plugin loading (§3.5c), and the apparent contradiction between §4c's "fail loudly" and §3.5b's "boot anyway", which are different cases (§4c). Source layout answered too: one library per bridge, sibling to the other Cor-Libs, with the contract headers in a smallcorBridgeof their own (§4a).
1. Goal
The broker must be able to forward distops and probe CSRs over protocols other than HTTP — first TLV (wire format in the binary-IPC notes), then WS, DDS, OPC-UA, etc. — and must be able to receive those same operations on the inbound side when a peer broker sends them in a non-HTTP transport. HTTP stays always-supported and inline (it's also the NGSI-LD REST API surface; ripping it out would buy nothing).
Concretely, three pieces of work:
- Transport-neutral types inside the broker:
BridgeRequest/BridgeResponse(semantic shape — op kind, tenant, entity payload, etc.). Every broker-to-CSR outbound call site builds aBridgeRequest, then callsbridgeSend(scheme, req) → resp. HTTP becomes the inline implementor forhttp:///https://schemes. - Transport-neutral inbound entry point:
serveDistOp(req). The HTTP listener decodes a REST request into aBridgeRequestand callsserveDistOp. A TLV listener (whentlv.sois loaded) does the same with a TLV frame. The fan-out to actual service routines happens afterserveDistOp, not before. - Bridge plugins (the .so files) — one per non-HTTP transport,
loaded on demand, implementing both outbound marshalling and an
inbound listener thread that calls
serveDistOp. First plugin:tlv.so.
This unifies three things that today live in different places:
- Inter-broker over HTTP: the existing distop forwarding path in
corNgsild/ldDistOp.c. Both peers speak NGSI-LD; transport is HTTP. Stays inline — HTTP is the inline implementor of the newbridgeSendinterface, not a plugin. - Inter-broker over binary (new): same NGSI-LD peers, faster wire format. See the binary-IPC notes.
- Inter-broker over WebSocket (new): same NGSI-LD peers, persistent bidirectional channel.
- Industrial bridges: peer is not an NGSI-LD broker. DDS, OPC-UA, Modbus. Plugin translates between NGSI-LD and the foreign protocol. DDS is the first non-HTTP-peer concrete bridge, and on the order of 8 kLOC of translation work.
Multiple bridge plugins can be loaded simultaneously — unlike currentState
or TRoE (one active backend), the broker keeps a table of transports
keyed on URI scheme. HTTP occupies one slot in that table inline; every
other scheme is filled by a loaded .so.
2. Why a new family
The existing three plugin families are all "south/east-bound" — the broker calls into the plugin.
| Family | Direction | Cardinality | Examples |
|---|---|---|---|
| currentState DB | broker → plugin | one active | mongoc, corDB |
| TRoE | broker → plugin | one active | timescale, ramdb, none |
| API | HTTP → plugin handler | many | admin |
| bridge (new) | bidirectional | many, scheme-keyed (non-HTTP) | tlv, ws, dds, opcua, … (HTTP is inline, not a plugin) |
A bridge plugin is the first one with bidirectional flow:
- Outbound (broker → plugin): a local entity write or a forwarded distop fires the bridge to send on the relevant transport (HTTP POST, TLV REQUEST frame, DDS topic publish, OPC-UA write, …).
- Inbound (plugin → broker): an external event arrives on a plugin- owned thread (HTTP response, TLV PUSH frame, DDS sample, OPC-UA notification) and must turn into a broker-side action — completing a distop future, updating an entity, firing a subscription.
This inverts the call direction. It requires a stable broker API surface that plugins call back into — something the existing families have never needed.
3. Endpoint-mapping convention: Bridge and Channel
Supersedes the CSR-based convention of the 2026-05-25 revision. That version said "every bridge endpoint is a Context Source Registration with a protocol-specific endpoint URI". It isn't, and §3.1 is why.
Orion-LD configures DDS endpoints via a static JSON file listing
topics / services / actions, each mapped to
(entityType, entityId, attribute). We are not adopting that as the
model either — but see §3.6, because we do have to keep reading that
file.
The model is two objects:
| Object | What it is | How many | Lifecycle |
|---|---|---|---|
| Bridge | A transport instance. For DDS: the participant — domain, QoS defaults, thread count, types directory. | Very few. | The kind is startup-fixed (--bridges); the instance is a stored object with full CRUD — §3.5a. |
| Channel | One foreign endpoint tied to one entity attribute: dds://rt/pose ↔ (urn:ngsi-ld:robot:1, pose), plus direction, retention and kind. |
More — one per topic per entity. | Created, patched and deleted at runtime. |
Three things change on three different clocks, and conflating them is what made the lifecycle hard to pin down:
| What it is | When it changes | |
|---|---|---|
The .so |
Infrastructure. --bridges dds,opcua names which are loaded. |
Startup. |
| The Bridge | Configuration. A DDS participant on domain 7 with this QoS. | Runtime, full CRUD. |
| The Channel | This topic ↔ this attribute. | Runtime, full CRUD. |
The relationship between the first two is one-to-many: two DDS participants on
different domains are two Bridges sharing one dds.so, both "plugin": "dds".
An earlier draft made the Bridge read-only because the kind is startup-fixed
(§3.5a) — that was the first row's property applied to the second.
Why "Channel" and not "Binding"
The 2026-08-26 draft called this object a Binding. That name is taken, at document-title level: TS 104 176 is "NGSI-LD API Bindings" and TS 104 243 is "NGSI-LD MQTT Notification Binding", and clause 5, 8 and 9 of TS 104 175 say "binding-specific" throughout. In the spec a binding is how API operations are conveyed over a protocol. Ours is how a value is carried to and from a foreign endpoint. Both involve a protocol, which is what makes the collision confusing rather than harmlessly homonymous — a reader meets "the DDS Binding" and reasonably expects a third API binding beside HTTP and MQTT.
Map was considered and is worse. Clause 3 already defines two: NGSI-LD Entity Map (Entity ids to registrations) and NGSI-LD Map (the JSON-LD language map that LanguageProperty is built on). The second is a datatype name used in normative prose. Worse, "map" names a static correspondence — which is precisely what makes it sound like a registration, the thing §3.1 exists to distinguish it from. This object also declares direction and retention: it says what happens, not only what corresponds to what.
Channel is unclaimed — two occurrences across TS 104 175/176/243, both
informal prose ("set up the communication channel", in the notifierInfo
row). It carries direction natively, it carries flow natively, and flow is
exactly the line against a registration: a Channel carries the value, a
registration delegates it. Bridge and Channel also hold together as a
pair; a bridge carries channels.
3.1 Why not a registration
A registration says: another NGSI-LD context source holds this data; forward NGSI-LD operations there. A bridge says: this attribute is carried by a foreign transport; translate at the boundary. The two have nearly the same shape — an endpoint plus a set of entities and attributes — and make different claims. That similarity is the trap, not the argument: a new concept expressed through an existing mechanism ends up sharing its code path, its counters and its status word, and renaming afterwards does not separate them.
The decisive detail is where authority sits. The broker retains the
value in both directions. An arriving sample is stored. An incoming
PATCH on a bound attribute is also stored — and published on the
topic. That is not forwarding: the body is not handed to a peer that
then owns it. The broker keeps the value and puts a copy on a wire.
No registration mode describes that:
exclusive— the source holds it, the broker must not.redirect— the client is sent elsewhere.auxiliary— the source complements what the broker has.inclusive— the closest, but it still means the broker queries the source and merges the answer. A DDS topic is never queried.
Nothing in the registration vocabulary says the peer is a wire.
3.1a Scope of that argument — pull is a registration
The paragraph above is about values that flow. It does not cover
values that are fetched on demand, and those exist: an attribute read
only when someone asks for it, because reading it costs something. The
canonical case is a device's battery level, where polling it is what
drains the thing you are measuring. Today that is expressible only
through a registration, and rightly so — "the value lives out there, ask
when asked" is precisely what a registration means, and exclusive is
precisely the mode for it.
So the two objects divide on flow versus delegation, not on NGSI-LD-peer versus foreign-peer:
| The value… | Object | Direction of control |
|---|---|---|
| flows through the broker, in either direction | Channel | the transport pushes, or the broker pushes |
| lives at the peer and is fetched when read | Registration with a bridge-scheme endpoint | the broker pulls |
A registration whose endpoint names a bridge scheme reuses the
forwarding path, the CSR cache and the existing mode semantics, and the
plugin supplies the transport. Nothing new is needed in the registration
model — only a read method on the driver (§4), because an
LdForwardRequest is HTTP-shaped and a foreign peer cannot answer one.
This is not a coincidence, and it is not our invention either. It is the same split the spec already documents. TS 104 175, Annex G — "Suggested actuation workflows" — names two communication models for what it calls a Context Adapter (§G.4.1):
- Subscription/notification (§G.4.2, implemented in §G.5). The adapter uses subscriptions to have command requests delivered to it and pushes results back, "acting as a Context Source as well as a Context Consumer". It holds no store of its own. That is a Channel.
- Forwarding (§G.4.3, implemented in §G.6). The adapter registers "I am responsible for command property X" in the Context Registry; the broker forwards the request to it. The adapter acts "as a Context Storage as well as a Context Producer" — it keeps the state, possibly on the device itself. That is a registration.
So both objects have a home in the existing spec vocabulary, and a deployment picking one over the other is picking a documented workflow rather than an implementation detail of ours.
⚠ Annex G is informative, and it is a suggested workflow rather than a mechanism the API provides. That is the gap Service Execution closes (§10) — and when it does, this section changes with it. What should survive the change is the distinction itself: flow versus delegation is a fact about where a value lives, not a convention.
One protocol can want both. OPC-UA monitored items push; OPC-UA reads pull. LWM2M observes and reads. The model should let a deployment choose per attribute rather than per protocol, and this does.
⚠ Lazy-over-notification — sending the peer a "go and read this" message
and waiting for it to write the value back — is what an agent must do
when it has committed to the push convention and has no registration. It
works, and it is strictly worse for a read: the client's GET waits on
a round trip through a notification and a subsequent write, which is what
registration forwarding already does in one hop. Do not model lazy that
way here because agents have to.
Two further consequences fall out. The §11 question about endpoint
having to be a reachable URI dissolves — dds://rt/pose is not a
registration's endpoint any more. And discovery stays honest: a
federation peer doing GET /csourceRegistrations gets context sources
it can use, not DDS topics it cannot.
3.2 What is shared: the URI scheme registry
The endpoint URI carries protocol and address, and that vocabulary is
common to registrations, subscriptions and bridges. One registry, read
the same way everywhere. The scheme list of the previous revision
stands unchanged (ngsild-bin://, ws://, mqtt://, dds://,
opcua://, modbus://, with http(s):// inline).
Share the URI convention. Do not share the object.
3.3 Channel fields
Beyond the endpoint and the entity/attribute selector, a Channel carries two orthogonal axes that a registration has no vocabulary for:
- direction —
in(the peer writes the attribute),out(a write reaches the peer),both(today's DDS topics, with loop protection). - retention —
mirror: store locally and put it on the wire, which is what Orion-LD does today; orrelay: put it on the wire and keep nothing. Relay is not hypothetical — a 100 Hz pose stream that should feed subscriptions without persisting is exactly that, and so is an actuation command there is no reason to keep. There is deliberately no thirdlazyvalue: an attribute the broker does not hold and fetches on read is a registration, not a Channel (§3.1a).
Plus kind (topic / service / action, see §9.1), a codec where the
transport does not determine how to read the payload (§3.8), and any
per-channel transport overrides the plugin declares.
Defaults for anything created from the config file (§3.6): both and
mirror, which is Orion-LD's behaviour.
3.3a Status — the degraded states have to be observable
Both objects have a state in which they exist and do nothing: a Bridge whose plugin was not loaded, and a Channel whose Bridge is unavailable (§3.5b). Those states are the whole reason a stored object never vetoes a boot, and they are useless if the only way to discover them is to notice that no data is arriving.
So both carry a status, read-only, computed rather than stored:
| Value | Meaning |
|---|---|
available |
the transport is up; a Channel is carrying |
unavailable |
Bridge: the plugin named by plugin is not loaded |
dormant |
Channel: it is well-formed, but its Bridge is not available |
plus a statusReason string where the value alone is not enough —
"plugin 'dds' not loaded; not named on --bridges" turns "why is nothing
arriving from DDS?" from a log-hunt into a GET.
Deliberately not an error field: an unavailable Bridge is not a broken one. The configuration is valid and the infrastructure has not caught up with it, which is a routine ordering fact (§3.5b) and is fixed by a restart with the plugin named, not by editing the object.
3.4 Overlap with registrations
A Channel and a registration can both claim the same
(entity, attribute). The rule is not "refuse any overlap" — it turns
on retention, and the predicate already exists.
ldRegCacheLocalWriteConflict() (ldRegCache.c) answers exactly the
question "may the broker write this attribute locally?". It walks the
registration cache filtering on
if (itemP->mode != LdRegModeExclusive && itemP->mode != LdRegModeRedirect)
continue;
— precisely the two modes that forbid the broker holding the data
itself — and matches on entity id, idPattern, type vector, scope and
attribute IRIs. Its overlap rules are already settled: an attrs-only
claim covers any entity; a claim with neither propertyNames nor
relationshipNames covers the whole entity; an attribute-scoped claim
conflicts only when the write carries one of its attributes.
A mirroring Channel is a standing declaration of intent to write locally. So:
- Creating a mirroring Channel runs that check. Conflict → refuse.
- Creating an
exclusiveorredirectregistration runs the mirror image against the channel set. Conflict → 409. - A
relayChannel writes nothing and conflicts with nothing. inclusiveandauxiliaryregistrations never conflict with a Channel; they do not forbid a local write.
Runtime enforcement is already wired: the check is called from
putEntityAttr, replaceEntity, patchEntity, postEntityBatchCreate
and postEntityBatchUpsert — every local write path. Only the two
creation-time checks are new.
3.5 Why two objects rather than one
The alternative considered was a single Bridge object created without
its mapping and PATCHed later to add the case-specific part. That is
the same split, collapsed into one object with an incomplete state that
then has to be validated, rejected on and reasoned about.
Split, the incomplete state disappears — a Bridge is complete when
created because it is a transport, and a Channel is complete when
created too. Everything else follows: the conflict check attaches to
Channels, which is where overlap lives (a DDS participant overlaps
nothing); deleting a Channel does not tear down a participant, while
deleting a Bridge cascades to its Channels; and PATCH still does the
job it was wanted for, on Channels.
3.5a Both objects are persisted, and both have real CRUD
An earlier draft made the Bridge resource read-only, on the grounds that
bridges are not created at runtime. That conflated two things. The bridge
kind is startup-fixed — a new one is a shared library and roughly a
month of work, and --bridges names which are loaded. A bridge
instance is not: nothing stops a second DDS bridge on a different
domain being created while the broker runs, and the plugin is perfectly
able to bring up another participant.
So a Bridge is an ordinary broker-held object, in the database beside entities, subscriptions and registrations, with the lifecycle that implies:
- First startup: somebody
POSTs the Bridge. It is stored. - Every startup after: it is read from the database and its transport is brought up. Nobody posts anything.
- Modification:
PATCH. - Re-
POSTof the same id: 409, "already exists" — exactly whatpostCsourceRegistrationalready answers for a duplicate registration id. Same rule, same status, no new concept for anyone to learn.
Two levels of conflict, and they belong in different places:
| Conflict | Detected by | Because |
|---|---|---|
| Same Bridge id | the broker | generic, and identical to every other stored object |
| Same transport — one participant per domain, one client id per MQTT broker | the plugin | only the driver knows whether a second instance of its transport is legitimate. Some allow it, some cannot. |
The orphan case follows directly and needs an answer. A Bridge in
the database whose plugin was not loaded — someone posts a dds bridge,
then restarts without --bridges dds. Do not refuse to start: mark the
Bridge unavailable, leave its Channels dormant, and say so in its status.
That is how the broker already treats a registration whose endpoint does
not answer, and a stored object should not be able to prevent a boot.
⚠ PATCH on a Bridge is disruptive and should say so. Changing a
domain, a QoS default or a types directory means tearing the transport
down and building it again, which drops and re-establishes every reader
and writer its Channels own. That is bridgeUpdate on the driver (§4).
Patching a Channel is cheap; patching a Bridge is not, and the
difference should be documented rather than discovered.
3.5b Which plugin, and what if it is not there
A Bridge is configuration; the .so is infrastructure. They are deployed by
different people at different times, which is what makes this feel awkward —
but it is an ordering fact, not a modelling problem. Three questions, and
the broker already answers all three for its other plugin families.
How does a Bridge name its plugin? By short name — "plugin": "dds"
— resolved exactly as --database, --troe and --apiPlugins resolve
theirs: against the plugin base directory, with a full path accepted only
when the argument contains a /.
⛔ Not a path in the POST body. A filesystem path submitted over the API
means the API can make the broker dlopen an arbitrary file — a remote-
code-execution primitive, and one that persists in the database across
restarts. It also welds a stored object to a filesystem layout: move the
installation and every Bridge is broken. The short name is indirection
through a directory the operator controls, which is the whole point of the
existing convention.
⛔ And the broker does not auto-create a Bridge because it found a
plugin. The reason generalises: the presence of a plugin is a capability;
a Bridge is a decision, and a capability appearing must never enact a
decision. Installing dds.so would otherwise make the broker join a DDS
domain by itself — on which domain, with which QoS, against which types
directory? Domain 0 is a guess that puts the broker on a network. It also
inverts §3.5a: there a stored object must not dictate the boot; here a
file would be creating stored state, and deleting the resulting Bridge
would only see it return on the next boot.
So the answer is an error — at the right moment, and a different one at each:
| Moment | Plugin not loaded | Why |
|---|---|---|
POST of a Bridge |
4xx, reject | somebody is there to be told, and to fix it |
| Startup, loading from the DB | mark unavailable, boot anyway (§3.5a) | nobody is there to tell, and a stored object must not prevent a boot |
That asymmetry is deliberate: reject what can be rejected interactively, degrade what cannot.
"Must somebody really POST one at first startup?" No — that is what §3.6's loader is for. It exists to read the frozen configuration format, but it is the right vehicle for any declarative "here are my bridges, no API call needed" deployment: a small edge installation gets zero-configuration startup from a file, not from a directory scan.
Both objects carry an id and a type, as an NGSI-LD payload does.
That is the shape everything stored in this broker already has — an Entity,
a Subscription, a ContextSourceRegistration all arrive as a body with those
two members — and there is no reason for these to look different:
{
"id": "urn:ngsi-ld:Bridge:dds-domain0",
"type": "Bridge",
"plugin": "dds", // short name, resolved as §3.5b
"config": { "domain": 0, "qos": { … }, "typesDirectory": "…" }
}
{
"id": "urn:ngsi-ld:Channel:robot1-pose",
"type": "Channel",
"bridge": "urn:ngsi-ld:Bridge:dds-domain0",
"endpoint": "dds://rt/pose",
"entity": { "id": "urn:ngsi-ld:robot:1", "type": "Robot" },
"attribute": "pose",
"direction": "both",
"retention": "mirror"
}
The id is a URI and follows the same identifier rules as everything else,
which is what makes §3.5a's 409 precise: it is an id conflict, detected the
way the broker already detects one. It also gives the Channel something
stable to reference.
Decided: they are JSON-LD. The trajectory is standardisation — the
registration field of §3.5b is to be pursued in ETSI, and a Bridge put
alongside Service Execution would have to be a JSON-LD object with a real
@context — so Bridge, Channel, plugin, direction and retention
are terms in a namespace we mint and publish. Plain JSON would buy a little
work now and pay for it with a breaking migration later, in exactly the
deployments worth keeping.
⚠ Some members must carry @type: @json, and the rule is about keys, not
values. JSON-LD expands keys; a string value is left alone unless its
term is @type: @id. So "endpoint": "dds://rt/pose" needs nothing. The
hazard is every place a foreign name appears as a key:
config— theddsmodulehalf is passed verbatim and its keys are eProsima's vocabulary. Expandinghistory-depthinto an IRI in our namespace is meaningless, and a future key outside § 4.6.2's name grammar would make a valid configuration fail as a bad NGSI-LD name (ldVocabNameCheckrejects it before@vocabever applies).- QoS maps and codec parameters — same shape, same argument.
- Anything mirroring the frozen file's structure, where a topic is a key:
topics: { "rt/pose": … }.rt/posecontains/, which that grammar does not allow, so such a body would be rejected outright.
So: @type: @json on any member whose keys belong to somebody else. A
rule, not a judgement to be made field by field.
A Channel names its Bridge. Explicitly, by Bridge id, as a field of its
own. The endpoint scheme is not enough once two Bridges of the same kind
exist — two DDS participants on different domains both answer to dds:// —
so the scheme becomes a consistency check rather than the selector: a
mqtt:// address on a dds Bridge is a 4xx, not a lookup.
That gives referential integrity, and the rules follow the ones already
settled. A Channel referencing a Bridge that does not exist is rejected at
POST — somebody is there to be told (§3.5b). A Channel whose Bridge is
present but unavailable, because its plugin was not loaded, stays dormant
and does not prevent the boot (§3.5a). Deleting a Bridge cascades to its
Channels, which is no longer a convention but a consequence of the
reference.
⚠ Registrations cannot do the same thing, and that asymmetry has to be
decided rather than discovered. The pull case (§3.1a) uses an ordinary
NGSI-LD registration, whose members are fixed by the specification — there
is no room for a bridge field, and inventing one would be exactly the
overloading §3.1 argues against. Three ways out:
| Option | How | Cost |
|---|---|---|
contextSourceInfo entry |
{"key": "bridge", "value": "urn:bridge:dds:1"} |
none — this is the member the specification provides for exactly this, and MQTT already carries MQTT-QoS and MQTT-Version through the equivalent notifierInfo |
| Encode in the endpoint | dds://<bridge-id>/<address> |
overloads the URI authority, and the address grammar then differs between a Channel and a registration |
| Unique-or-error | resolve by scheme; 4xx when two Bridges match | no new syntax, but a second Bridge retroactively breaks working registrations |
Take the first, and keep the third as the convenience: resolve by scheme when exactly one Bridge of that kind exists, require the entry when more than one does. But take it knowing it is the wrong bucket, because that matters for how it is implemented and for how it is undone.
⚠ contextSourceInfo is the receiverInfo analogue, not the
notifierInfo one. A Subscription has two of these: notifierInfo,
which configures our own end (MQTT-QoS, MQTT-Version), and
receiverInfo, which is headers to send to the receiver. A registration has
only contextSourceInfo, and it is the second kind — it becomes HTTP
headers on the outgoing forward. A Bridge reference is emphatically the
first kind: it says how our end behaves, and it is nobody else's business.
Registrations are simply missing the field, and the right fix is to add it
in ETSI rather than to keep borrowing the wrong one.
Until then, the borrowed key must never reach the wire. That is not new
machinery: buildHeaders in ldDistOp.c already consumes well-known
contextSourceInfo keys instead of forwarding them — contentType,
accept, jsonldContext are handled, and Content-Length, Host and
NGSILD-Tenant are banned outright. A Bridge reference joins that list. Six
precedents, one line.
Decided: reserved keys take a sigil that cannot be an HTTP field-name —
@bridge, not bridge. A field-name is a token and @ is not one of its
permitted characters, so such a key could never have been a legitimate
header. The skip in buildHeaders becomes total and provable instead of a
deny-list that has to grow every time a well-known key is invented. The six
existing bare-named entries stay as they are; the sigil is the rule for new
ones.
And the borrowing is temporary by intent. A registration should have the
field a Subscription has — the notifierInfo counterpart, configuring our
own end rather than the receiver's headers. That is a specification change
to pursue, not a gap to live with, and @bridge in contextSourceInfo is
what holds the place until it lands.
⚠ And one limitation this creates for §3.6: the frozen configuration format
has no way to name a Bridge, so the loader creates one Bridge per plugin
from the ddsmodule half and binds everything to it. A file-loaded
deployment therefore has exactly one Bridge per plugin. That is not a
problem to solve — the format is frozen and one participant is what it
always described — but it should be written down before somebody expects a
second domain to appear from a file.
3.5c Decided: no runtime plugin loading
The remaining form of the question is "the plugin was not there at startup — may it be added while the broker runs?". No, and it is worth saying so rather than leaving it unasked.
dlopen at runtime would be a one-way door. A bridge plugin spawns threads and
joins a transport in init() — a DDS participant starts discovering peers — and
dlclose on a library with live threads is undefined behaviour. So loading
would be possible and unloading would not: capabilities accumulating in a
process with no way back, and no way to test the way back either.
It would also make bridges the only plugin family that works this way.
--database, --troe and --apiPlugins are all startup-fixed, and a bridge is
the least casual of the four — it puts the broker on somebody's network.
Whatever triggered the load would reintroduce the argument already refused for
paths in a POST body (§3.5b): the decision to execute new code arriving over
the API.
⭐ And it is not needed, because the degraded path is already the graceful
one. A stored Bridge whose plugin is absent is unavailable, its Channels
dormant, and the boot proceeds (§3.3a, §3.5b). The operator adds the name to
--bridges and restarts; the Bridge comes up with its Channels intact, nothing
lost and nothing re-entered. A restart is a second on the edge installation this
would matter for. The whole of runtime loading buys the difference between that
and nothing.
3.6 The config-file loader
Orion-LD's DDS configuration file is not ours to change. dds.ddsmodule
is eProsima's and is passed verbatim to the enabler; dds.ngsild is
ours but is published and in use, so it is frozen in practice.
It maps onto the two objects one-to-one, which is a good sign the split is right:
| File half | Object |
|---|---|
dds.ddsmodule — domain, allowlist, QoS defaults, threads, logging |
Bridge |
dds.ngsild.topics / .services / .actions — { name → (entityType, entityId, attribute) } |
Channels |
The format is a strict subset of what the model expresses: three keys
per entry plus two globals (typesDirectory, syncTimeoutMs), and
nothing about direction, retention or lifecycle. That is the good case
for an interface that cannot change — the loader stays small, and
everything the file cannot say is free to be designed on the other side
of it.
So the loader's real work is defaults: everything the file does not say must default to Orion-LD's behaviour. Which gives a testable acceptance criterion:
A Channel created from a config-file entry, with defaults, behaves identically to Orion-LD given the same file.
That is a functest, not a judgement call.
Keep the loader. An earlier framing had it as scaffolding to delete when the project that motivated it ends. If the format is published and in use, deleting it strands whoever runs that file. It is the compatibility path for exactly those deployments: the file stays frozen at today's expressiveness, the Channel API grows past it, and nothing breaks for the file to stop being the only way in.
3.7 What it costs
- A collection, a cache, validation and lifecycle for Channels — the machinery a registration would have donated free. This is the real price of the separate concept. It is work, not overhead.
- Something CSR-subscription-shaped, if clients should be able to watch the channel set change.
- Two creation-time conflict checks (§3.4).
- A URI scheme registry that must be defined and documented before any bridge ships; once published, schemes are forever.
- The outbound and inbound refactors of §9 steps 1 and 2, unchanged — they are needed whatever the mapping model is.
Not on the list: lookup cost. Registration matching is ~1 665 lines
across 10 match functions resolving id, idPattern, type vector, scope,
property and relationship names and mode, over a linear scan of a set
that can hold one entry per entity id. A Channel lookup is an exact
match on one or two strings over a handful of entries. Only the outbound
path consults both; an arriving sample keys on the endpoint name
straight into the channel cache and never touches the registration
cache.
3.8 Beyond DDS — where the model holds, and where it does not
§3 was derived from DDS. Checked against the protocols a device-facing broker would have to carry, DDS turns out to be the easy case: a topic carries one typed sample, so one endpoint really is one attribute. Two families emerge.
| Family | Protocols | One message carries | Channel as defined |
|---|---|---|---|
| Addressed endpoint | DDS topic, OPC-UA node, LWM2M resource, Modbus register | one value | fits exactly |
| Bundle source | UltraLight / JSON over MQTT or HTTP, CSV, LoRaWAN, Sigfox, Kafka, ISOXML | many attributes, often many entities | does not fit |
Why the second family breaks it. UltraLight over MQTT is the most widely
deployed of them. Measures arrive on /<protocol>/<api-key>/<device-id>/attrs
with a payload like t|15|h|40, and # separates groups that each become a
separate NGSI request. So one subscription serves many devices — the
device is a path segment, not a topic — and each message sets many
attributes.
Enumerating a Channel per attribute per device is impossible before the devices
speak and absurd afterwards: hundreds of objects describing one subscription.
LoRaWAN and Sigfox fail the same way and harder: the peer is a network server rather than a device, the payload is opaque binary needing a per-device-type decoder, and one uplink decodes to a whole set of attributes.
⭐ The conclusion is about provisioning. Device provisioning is not a convenience layer over Channels — it is the form a Channel takes for a bundle source:
- a direct Channel names an endpoint and an attribute;
- a provisioned device names a source — a topic pattern, a webhook path, a Kafka topic — plus a codec and a rule for extracting the entity id and the attribute names from what arrives.
Same concept one level up. It does not bind a value; it says how to derive channels from traffic. Which explains what otherwise looks like a coincidence: DDS integrations need no provisioning API and every device-protocol agent has one. That was never a maturity difference. It is whether the endpoint addresses a value or a stream of them.
Two smaller consequences:
- Codec becomes a Channel field (§3.3). The transport does not determine how to read the payload — an MQTT topic may carry UltraLight or JSON. DDS hid this because there the type comes from the wire.
- A Bridge may be a listener, not only a client. A DDS participant, an OPC-UA client and an MQTT subscriber reach out; an HTTP or CoAP endpoint that devices post to accepts connections. The object covers both; §8's lifecycle wording should stop assuming the first.
And a reminder of the boundary: the NGSI-LD-speaking transports — TLV, WebSocket between brokers — are not Channels at all. They are registrations and forwarding, as originally designed. The model has three lanes: forwarding to a peer that speaks NGSI-LD, a registration for a value fetched on demand (§3.1a), and a Channel for a value that flows.
4. Plugin contract
Same shape as the existing families: a single registration symbol
populates a vtable struct. Working name BridgeDriver.
// corBridge/BridgeDriver.h
typedef struct BridgeDriver
{
int version; // header-ABI version. Broker refuses
// to load if major doesn't match. New
// pointers below are always added at
// the end; never reorder or remove.
// (The 2026-08-26 revision reshapes
// this struct rather than appending.
// Nothing is implemented yet, so there
// is no ABI to break — the rule starts
// applying at the first release.)
const char* alias; // "dds" / "opcua" / …
const char* pluginVersion; // free-form plugin build version
KArg* args; // plugin-specific CLI args
// URI schemes this plugin handles. The dispatcher matches an endpoint
// by scheme prefix — a CSR's endpoint for an NGSI-LD peer, a
// Channel's for a foreign one. NULL-terminated.
const char** uriSchemes;
// Lifecycle. init runs once at broker startup after kargsParse, and
// brings the transport up — it receives the Bridge config (domain,
// QoS, threads, typesDirectory for DDS). close runs at shutdown.
// Plugin owns its own threads between.
int (*init)(LdBridgeConfig* config);
void (*close)(void);
// Channel lifecycle hooks (revised 2026-08-26; were csrCreate/Update/
// Delete). Called when a Channel owned by this plugin is created,
// patched or deleted. The plugin opens / reconfigures / closes the
// underlying protocol resource — DDS reader or writer, OPC-UA
// subscription — according to the Channel's direction and kind.
int (*channelCreate)(LdChannel* channel);
int (*channelUpdate)(LdChannel* channel); // before+after both in cache
int (*channelDelete)(LdChannel* channel);
// CSR lifecycle hooks — the same three, for bridges whose peer speaks
// NGSI-LD (tlv, ws) and is therefore addressed by registration rather
// than by Channel. A plugin implements one pair or the other, not both.
int (*csrCreate)(LdRegCacheItem* csr);
int (*csrUpdate)(LdRegCacheItem* csr);
int (*csrDelete)(LdRegCacheItem* csr);
// Outbound to an NGSI-LD peer — called by the distop dispatcher when
// an operation routes to a CSR owned by this plugin. The same shape as
// ldDistOpSendReceive's HTTP path, minus the URL.
int (*sendOne)(LdRegCacheItem* csr, LdBridgeOp* op,
LdBridgeResult* result);
// Pull, for a registration whose endpoint names this plugin's scheme
// (§3.1a) — a lazy attribute read on demand. Not a Channel: the broker
// holds nothing and asks. Separate from sendOne because sendOne carries
// an LdForwardRequest, which is HTTP-shaped, and a foreign peer cannot
// answer one.
int (*attributeRead)(LdRegCacheItem* csr, const char* entityId,
const char* attrName, KjNode** valueOut,
LdBridgeResult* result);
// Outbound to a foreign peer — a bound attribute was written. The
// plugin puts it on the wire; whether the broker also kept it is the
// Channel's retention (§3.3) and is decided before this is called.
int (*channelWrite)(LdChannel* channel, KjNode* value,
LdBridgeResult* result);
// Service and action invocation, foreign peers, broker as client
// (§9.1, §10). invoke is request/reply under a timeout. goalSend
// returns a goal handle; feedback, status and result arrive later via
// the upcall surface of §5b, and goalCancel stops one.
// Provisional: the NGSI-LD representation these produce is the v0
// convention of §10 and is expected to change with Service Execution.
int (*invoke)(LdChannel* channel, KjNode* request,
int timeoutMs, LdBridgeResult* result);
int (*goalSend)(LdChannel* channel, KjNode* goal,
LdGoalHandle* handleOut);
int (*goalCancel)(LdChannel* channel, LdGoalHandle handle);
} BridgeDriver;
typedef void (*BridgeRegisterFunc)(BridgeDriver* driverP);
// Plugin must export: void bridgeRegister(BridgeDriver* driverP)
The BridgeDriver.h and BridgeBroker.h headers themselves live in a
corBridge Cor-Lib, not in the broker repo. Plugins link against the
headers there; the broker provides the implementations of the upcall
functions declared in BridgeBroker.h. See § 4a for the source-tree
layout decisions that follow from this.
4a. Source-tree layout
Bridge plugins do not live in the broker repo (unlike mongoc / corDB today). One library each:
| Plugin | External lib | Location |
|---|---|---|
(http) |
— | inline in the broker — not a plugin |
tlv |
none (custom codec) | ~/git/corTlvBridge/ |
ws |
none (WS frame parser in-tree) | ~/git/corWsBridge/ |
mqtt |
mosquitto (already a broker dep for notifications) | ~/git/corMqttBridge/ |
dds |
Fast-DDS + FIWARE-DDS-Enabler (heavy) | ~/git/corDdsBridge/ |
opcua |
open62541 or similar (heavy) | ~/git/corOpcuaBridge/ |
modbus |
libmodbus or custom | ~/git/corModbusBridge/ |
External-dependency weight no longer decides where a plugin lives, only what it drags in. Somebody building the stack should not need Fast-DDS installed just to get a broker, and with one repo each that falls out for free: a heavy plugin is opt-in because nobody clones it.
Every bridge is its own library, sibling to the other Cor-Libs.
~/git/corTlvBridge/, ~/git/corDdsBridge/ and so on, beside corRest,
corJsonld, corPlugin and corNgsild. corLibs is the umbrella — it drives
the siblings and holds no library of its own — so it is not a home for them,
and the old "thin ones stay as sub-libs in the umbrella" rule has nothing left
to attach to. Dependency weight stops deciding where a plugin lives and goes
back to deciding only what it drags in: uniform layout, and a heavy plugin is
opt-in because nobody clones it, not because it sits somewhere different.
The contract is a Cor-Lib of its own — corBridge. BridgeDriver.h,
BridgeBroker.h and BridgeRequest.h are what every bridge library compiles
against, and corPlugin is the precedent for exactly this shape: a small,
dependency-light library that exists only to serve the plugin system, sitting
in the libs tier beside the rest. corBridge is the same kind of thing. One
holds the mechanism for loading a .so, the other the contract a bridge .so
must satisfy.
The alternative — putting the headers in the broker repo, since the broker is
the sole implementor of BridgeBroker.h — costs two concrete things. The build
order is k-libs, then the Cor-Libs, then the broker last; contract headers in
the broker put six plugin repos in a tier after it, and point a library at an
application. And the broker publishes no headers today — install copies the
binary and its plugins to /opt/seamware — so it would grow a header-install
target and become a build-time dependency of six external repos.
The ownership argument for the broker repo — that a separate header can drift from the code implementing it — is already answered by §4b: additive evolution, same major number means it loads and works. That policy exists because the plugins are external, and it covers the header for the same reason.
⚠ corBridge is not part of corPlugin, however similar the two look.
corPlugin is a generic dlopen/dlsym loader — name to path, symbol lookup,
handle tracking — and its README is explicit that it "knows nothing about" the
categories it loads. The database and TRoE families keep their contracts out of
it too. A bridge contract inside it would be the first thing to make the loader
know what it is loading. Same tier and same shape; different kind.
Layout confirmed 2026-08-29. The one thing still hedged is cor-agent, which is expected to be a conditionally-compiled build of the broker in this repo rather than a repo of its own — but nothing here depends on that, because the contract's consumers are the plugin libraries, not a second broker.
Naming: cor<Protocol>Bridge for source (repo / dir), short protocol
name for the runtime artifact:
| Source | Built artifact |
|---|---|
~/git/corDdsBridge/ |
/opt/seamware/plugins/bridge/dds.so |
~/git/corOpcuaBridge/ |
/opt/seamware/plugins/bridge/opcua.so |
The Bridge suffix is the source convention only; the .so name is
the short scheme that matches the endpoint URI prefix.
4b. BridgeDriver header evolution
Once BridgeDriver.h and BridgeBroker.h live in corBridge and
get pulled into separate plugin repos, ABI breakage gets expensive
fast. The rule:
- Evolve additively. New function pointers always go at the end of the struct. Existing pointers never change signature.
- New pointers are nullable. The broker probes for
NULLbefore calling — old plugins that pre-date the new slot keep working unchanged. - Removing a pointer is a major version bump. The struct gains
an explicit
int versionfield at offset 0 so the broker can refuse to load a plugin compiled against an incompatible header.
This means we don't pin plugin repos to a specific corBridge git tag.
Plugins built against any corBridge version with the same major number
load and work — only optional features become unavailable for older
plugins.
Reconsider this policy when there are >3 external-dep plugin repos and additive cruft starts to dominate the header.
4c. Discovery & CLI
Revised 2026-08-26. Auto-discovery from the persisted regCache is dropped along with the CSR-based mapping of §3.
Install path: /opt/seamware/plugins/bridge/<name>.so.
CLI: --bridges tlv,dds,opcua, named explicitly, exactly as
--database, --troe and --apiPlugins already work. HTTP needs no
loading (always inline).
Named rather than inferred, for three reasons. A bridge brings up a
transport at init() — a DDS participant joins a domain and starts
discovering peers — and that is not something to do as a side effect of
a persisted object appearing in a cache. Startup then fails loudly when
a named plugin is missing, instead of silently not bridging until
someone notices. And a Channel can be created before its transport
exists only if the transport's existence is a startup decision; inferring
it from the channel set makes creation order load-bearing.
⚠ This is not the same case as §3.5b's "boot anyway", though it reads like it. The two differ in who is asserting what, and when:
| What is missing | Where the claim came from | Startup |
|---|---|---|
a plugin named on --bridges |
the operator, in this invocation | fail, loudly |
| the plugin a stored Bridge names | a decision recorded at some earlier time | unavailable, boot anyway (§3.3a) |
A command line is an assertion being made right now, and an unsatisfiable one should stop the broker before it half-starts. A stored object is a record from the past, and the world may legitimately have moved on since. Reject what can be rejected while somebody is watching; degrade what cannot.
The cost is one flag in deployments that would otherwise have been implicit. Bridge kinds change roughly never — a new one is a shared
HTTP stays inline
HTTP is the always-supported transport — it's also the NGSI-LD REST
API surface, so the broker can't ever ship without an HTTP path. The
existing distop forwarding code in {sw,fw}Ngsild/ldDistOp.c and the
direct-forwarding sites stays in-tree and becomes the implementation
behind bridgeSend(scheme="http", req). What changes is the shape
of how it's called:
- Outbound call sites stop building HTTP requests directly; they
build a
BridgeRequestand callbridgeSend. The HTTP implementor readsBridgeRequestfields and constructs the HTTP request it used to build directly. - Inbound HTTP requests (
httpRequestTreat) stop fanning out to NGSI-LD service routines directly; they build aBridgeRequestand callserveDistOp, which does the fan-out.
This is not a packaging change for HTTP — no new .so, no
dlopen. It IS a structural change inside {sw,fw}Ngsild to push the
HTTP-specific marshalling out of the call sites and behind a
transport-neutral interface. Wire compatibility is unchanged; what
the HTTP listener sends and receives over the network is identical
to today. Only the boundary between "transport" and "semantic op" moves.
The first non-HTTP transport (tlv.so) then plugs in on the same
boundary without touching the HTTP path.
5. Broker API surface
Two distinct things live here:
- Transport-neutral request / response types — used by every broker-to-CSR call site, regardless of which transport carries the call. HTTP listener and bridge plugins both build / consume these.
- Upcall surface for plugins — the small stable header that
plugin
.sofiles link against to push samples into the broker from their own threads. This is the only sanctioned plugin → broker call surface.
5a. Transport-neutral request / response
// corBridge/BridgeRequest.h
//
// A semantic representation of one NGSI-LD operation traveling
// between two NGSI-LD peers (or, for foreign-peer bridges, the
// translated semantic op). NO transport-specific fields — no HTTP
// status, no URL, no TLV correlation id, no MQTT topic. Those live
// in the transport-specific carriers that wrap a BridgeRequest at
// the boundary.
//
typedef enum BridgeOpKind
{
BRIDGE_OP_CREATE_ENTITY,
BRIDGE_OP_UPDATE_ENTITY, // partial update (PATCH)
BRIDGE_OP_REPLACE_ENTITY, // full replace (PUT)
BRIDGE_OP_APPEND_ATTRS,
BRIDGE_OP_MERGE_ENTITY,
BRIDGE_OP_DELETE_ENTITY,
BRIDGE_OP_DELETE_ATTRS,
BRIDGE_OP_RETRIEVE_ENTITY,
BRIDGE_OP_QUERY_ENTITIES,
BRIDGE_OP_BATCH_CREATE,
BRIDGE_OP_BATCH_UPSERT,
BRIDGE_OP_BATCH_UPDATE,
BRIDGE_OP_BATCH_DELETE,
BRIDGE_OP_BATCH_MERGE,
BRIDGE_OP_TEMPORAL_CREATE,
BRIDGE_OP_TEMPORAL_UPDATE,
BRIDGE_OP_TEMPORAL_RETRIEVE,
BRIDGE_OP_PROBE // CSR probe — info/sourceIdentity
// (subscription / notification ops added later if MQTT/WS bridges
// extend scope to notifications — see §11)
} BridgeOpKind;
typedef struct BridgeRequest
{
BridgeOpKind op;
Tenant* tenant;
const char* entityId; // single-entity ops; NULL otherwise
const char* attrName; // attr-level ops; NULL otherwise
KjNode* body; // entity / fragment / batch array
KjNode* ldContext; // expanded @context (parsed)
const char* via; // NGSILD-Via header value (loop-detection)
const char* path; // NGSILD-Path header value
KjNode* params; // URL params as KjObject (limit, type, q, …)
bool sysAttrs;
bool local; // ?local=true on retrieve / query
// Future: orderBy, lang, geo* — extend as call sites need them
} BridgeRequest;
typedef struct BridgeResponse
{
int status; // semantic outcome, mapped from
// HTTP status when carried over HTTP,
// from TLV result codes when carried
// over TLV. Same numeric range as
// HTTP for ease of code reuse.
KjNode* body; // ProblemDetails on error, entity/array
// on success
const char* contentType; // for retrieve; transport-neutral
// media-type string
// Headers semantically: Location (id on Create), Content-Range
// (truncation), NGSILD-Via, NGSILD-Path. NOT raw HTTP headers.
const char* locationId;
const char* contentRange;
const char* viaOut;
const char* pathOut;
} BridgeResponse;
bridgeSend(scheme, request) → response is the outbound dispatch
function. Implemented in the broker; reads the scheme prefix, picks
the inline HTTP implementor for http:// / https://, looks up the
loaded BridgeDriver for any other scheme, calls
driver->sendOne(csr, request, response). See §6.
serveDistOp(BridgeRequest*) is the inbound dispatch function. Called
by the HTTP listener after it decodes a REST request, and by every
bridge plugin's inbound thread after it decodes its wire format. Fans
out to NGSI-LD service routines. See §7.
5b. Plugin upcall surface
// corBridge/BridgeBroker.h
//
// Functions a bridge plugin may call on its own thread. Each call
// initialises the per-thread broker state (kalloc / FaAlloc, http
// allocator, tenant pointer) before running through the normal hooks,
// so subscriptions / TRoE / metrics fire as if the update had arrived
// over REST.
//
// Headers live in corBridge (NOT the broker repo) so that
// plugins built in separate repos (corDdsBridge, corOpcuaBridge, …)
// can link against the contract without depending on the broker
// source tree. The broker provides the implementations of these
// functions; the headers expose only the declarations.
//
// Per-thread allocator + tenant init. MUST be called once by every
// plugin-created thread before any other broker API call.
// The broker-wide rule for any new pthread: thread-locals are set up
// by the thread itself, never inherited.
int bridgeThreadInit(const char* pluginNameP);
// The transport-neutral inbound entry point. A plugin decodes its
// wire format into a BridgeRequest and calls this. Returns once the
// request has been served (or queued, for async transports).
int serveDistOp(BridgeRequest* req, BridgeResponse* resp);
// Convenience upcalls for foreign-peer bridges (DDS, OPC-UA, Modbus)
// that arrive with a single attribute sample rather than a full
// NGSI-LD operation. These build a BridgeRequest internally and
// call serveDistOp.
// Upsert an entity from a bridge sample. Replaces if exists, creates
// if not — the semantics an arriving sample needs, since a sample is a
// complete statement of the attribute's value rather than a patch.
int bridgeEntityUpsert(Tenant* tenant, KjNode* entity);
// Update one attribute on an existing entity (no-op if entity doesn't
// exist — caller usually pairs with bridgeEntityUpsert first).
int bridgeAttributeUpdate(Tenant* tenant, const char* entityId,
const char* attrName, KjNode* attrValue);
Implementation notes:
- Every call sets
corRest.bridgeSource = bridgeNameP(or equivalent scoped flag) so the outbound dispatcher can skip the originating bridge. A sample that arrives on one transport and is stored must not be published straight back out of the transport it came from; the flag is what makes that decidable at the dispatcher. - Threads created by plugins MUST call a
bridgeThreadInit(pluginP)before any other broker API. Initialises kalloc, http allocator, tenant lookup. This is the broker-wide rule for any new pthread, not something the bridge family invents. The init function is wired through the broker API header.
6. Outbound flow
Every broker-to-CSR outbound call site goes through the same path:
call site bridgeSend(scheme, req)
│ │
├── build BridgeRequest from local data ├── extract scheme prefix from
├── identify target CSR │ csr.endpoint URI
├── extract csr.endpoint scheme ├── if scheme in {http, https}:
└── call bridgeSend(scheme, req) ────────►│ call inline HTTP impl
│ else:
│ look up BridgeDriver
│ by scheme; call
│ driver->sendOne(csr,
│ req,
│ resp)
│
└── return BridgeResponse
to call site
Call sites that need to update today:
ldDistOpSendMultiin{sw,fw}Ngsild/ldDistOp.c— the central distop dispatcher. Stops building HTTP requests; builds aBridgeRequestper outbound leg and callsbridgeSend.- Direct-forward sites that don't go through
ldDistOpSendMulti:postEntities.c,postEntityBatchCreate.c,postEntityBatchUpsert.c,postEntityBatchUpdate.c,postEntityBatchDelete.c,postEntityBatchMerge.c,patchEntity.c,postEntityAttrs.c,patchEntityAttrs.c,postEntityTemporal.c,postEntityTemporalAttrs.c, etc. - The CSR probe call in
ldRegCache.c(the<endpoint>/info/sourceIdentityprobe issued at CSR-register time and on refresh).
The inline HTTP implementor (bridgeSendHttp, conceptually) lives in
{sw,fw}Ngsild alongside the existing distop code — it's where the
HTTP-specific URL construction, header marshalling, corRestClientRun
invocation, and HTTP-status-to-BridgeResponse-status mapping live.
This is the only HTTP-specific code path; everything outbound above
it is transport-neutral.
The bridge plugins implement the same contract via their
BridgeDriver::sendOne vtable slot — they translate a BridgeRequest
into their wire format, send it, and fill in BridgeResponse on the
other side.
7. Inbound flow
Symmetric with §6. Today, the inbound HTTP listener
(httpRequestTreat and its callees) routes a parsed REST request
straight to one of the NGSI-LD service routines (postEntities,
patchEntity, getEntities, …) via the URL+method dispatcher in
ldRestService.c / equivalent. That coupling has to be unwound so
the same set of service routines can be reached from a non-HTTP
inbound path.
HTTP listener (existing) TLV listener (plugin thread)
│ │
├── parse REST request ├── decode TLV frame
│ (URL, method, headers, body) │ (op kind, tenant, body, …)
├── build BridgeRequest from ├── build BridgeRequest from
│ request │ frame fields
│ │
└─────────────┐ ┌───────────┘
▼ ▼
serveDistOp(BridgeRequest)
│
├── select service routine by req.op
├── invoke service routine
│ (entry hook → service body → exit hook)
├── translate service result into
│ BridgeResponse
└── return BridgeResponse
│
┌───────────────────┴─────────────┐
▼ ▼
HTTP listener: TLV listener:
encode BridgeResponse encode BridgeResponse
as HTTP response as TLV reply frame
(status, headers, body) (msg type, correlation,
status, body)
Concretely the inbound HTTP refactor is:
- Identify the single point in the existing HTTP pipeline where the request has been fully parsed (URL parsed, body parsed, @context resolved, tenant looked up, params validated) and the service routine is about to be called. That's the boundary.
- Carve a new function
serveDistOp(BridgeRequest* req, BridgeResponse* resp)that takes theBridgeRequest, switches onreq->op, calls the correct service routine with the same arguments the HTTP path would have used, and fillsBridgeResponsefrom the service routine's outcome. - HTTP path: at the boundary, build the
BridgeRequestfrom the parsed HTTP request, callserveDistOp, then translateBridgeResponseback into an HTTP response (status, headers, body). This is the only HTTP-specific code on the inbound path after the carve. - Bridge plugin inbound threads: decode their wire format into a
BridgeRequest, callserveDistOp, encodeBridgeResponseback into their wire format.
serveDistOp is also called by the upcall helpers
(bridgeEntityUpsert, bridgeAttributeUpdate) declared in §5b —
those are convenience wrappers for foreign-peer bridges (DDS / OPC-UA)
that arrive with a single attribute sample rather than a full
NGSI-LD operation.
8. Lifecycle
Revised 2026-08-26 for the Bridge / Channel split of §3. Plugins are no longer discovered from the schemes present in the registration cache — they are named at startup, like every other plugin family.
broker boot
├── currentState plugin init (mongoc, …)
├── for each plugin named in --bridges (e.g. dds,opcua):
│ dlopen("/opt/.../plugins/bridge/<name>.so")
│ bridgeRegister() into the bridge registry
├── bridgeCache load from DB # the Bridge OBJECTS, posted earlier
│ for each Bridge:
│ plugin loaded? driver->init(bridge) # transport comes up
│ plugin absent? mark unavailable # do NOT fail the boot (§3.5a)
├── channelCache load from DB
├── config-file loader (§3.6), if a file was given:
│ ddsmodule half → the Bridge's config
│ ngsild half → Channels, with direction/retention defaults
├── for each Channel:
│ driver->channelCreate(channel) # reader/writer, subscribe, advertise
├── regCache load from DB
├── REST listener up
└── plugin-owned threads running
broker shutdown
└── for each loaded bridge:
driver->close() # plugin tears down its threads
Two ordering constraints worth stating, because both are silent when
wrong. driver->init() must precede any channelCreate — a Channel is
meaningless without its transport. And the loader must run before
channelCreate so that file-declared and API-declared Channels take the
same path; a loader that opens DDS resources itself would be a second
implementation of the thing it is feeding.
POST / PATCH / DELETE of a Channel fires
channelCreate / channelUpdate / channelDelete on the owning driver,
so it can open, reconfigure or close the protocol resource.
Bridges fire the same three (§3.5a) — they are ordinary stored objects,
not startup-only. What differs is the cost: bridgeUpdate tears the
transport down and builds it again, so every reader and writer belonging
to its Channels is dropped and re-established. A duplicate POST never
reaches the driver; the broker answers 409 first.
Deleting a Bridge — which only happens at shutdown — cascades to its Channels. Deleting a Channel never disturbs the transport.
9. Concrete bridges — roadmap
Recommended order of landing:
- Transport-neutral types + outbound refactor. Define
BridgeRequest/BridgeResponseincorBridge. Refactor every broker-to-CSR outbound call site (ldDistOpSendMulti, direct-forward sites, CSR probe) to buildBridgeRequestand callbridgeSend(scheme, req). HTTP becomes the inline implementor; no new transport yet. No wire change visible externally. Full functest suite green before commit. - Inbound transport-neutral entry point. Carve
serveDistOp(BridgeRequest)out of the HTTP request-dispatcher; HTTP listener gets an adapter at the same boundary. Still no new transport. Full functest suite green before commit. tlv(first plugin, NGSI-LD peer) — binary IPC per the binary-IPC notes. Both ends under our control, easiest to test (sw ↔ sw, fw ↔ fw, sw ↔ fw). Validates theBridgeDrivervtable, theserveDistOpcall surface from a plugin thread, thedlopenload mechanism (--bridges, §4c), and thebridgeThreadInitupcall. First concrete usage of every part of the bridge family.ws(NGSI-LD peer) — WebSocket. Four pieces: the HTTP upgrade handshake, a connection registry, message dispatch, and notification delivery over the open socket. The transport is well-understood and the work is mostly binding those to the bridge contract.mqtt(NGSI-LD peer) — outbound notification path already exists (mosquitto integration); the bridge work unifies that with new inbound MQTT support for distops over a request/reply topic convention. Semantics need pinning (see §11) but the library is already a broker dep, so the .so should be thin.dds(translation, foreign peer) — topics only for the first cut. See §9.1 below.opcua,modbus, ... — further industrial peers, each a separate commit, all using the same contract.
9.1 DDS — topics, services and actions, client-side only
Revised 2026-08-26. The previous scope was "topics only for the first cut", on the assumption that service/action semantics could wait for the spec. They cannot: the project driving this needs all three.
Implemented against BridgeDriver / BridgeBroker.h. Scope:
- Topics (pub/sub). One topic ↔ one attribute,
valueas JSON on the wire. Bidirectional with loop protection. - Services (request/reply), broker as client. Invoking is a write to the bound attribute; the reply comes back into it.
- Actions (long-running goals), broker as client. Send a goal, receive feedback / status / result over time, be able to cancel.
Client-only is a real boundary, not a first-cut limitation. There is no mode in which the broker answers a DDS request or executes a goal. A context broker knows about entities, subscriptions and registrations, and has no extension point for the business logic that would compute a reply or run a goal — that belongs to an application, which is why every NGSI-LD/DDS integration lands on the same boundary. It bounds the driver contract usefully: one initiator, and for actions several arrivals over time plus a cancel.
Weight, for planning — a working implementation of this scope runs to roughly 5 kLOC, split about 42% topics and shared core, 25% services, 33% actions. So services and actions are the larger part, and they are also the part whose NGSI-LD representation is provisional (§10).
Two operational consequences of services and actions, both absent for topics:
- No type discovery. There is no peer to learn the IDL from before
the first send, so pre-built
.bintype files must be on disk. ThetypesDirectorybecomes a Bridge field. - A synchronous timeout for service invocation —
syncTimeoutMs, a Bridge default overridable per Channel.
Port the DDS mechanics — type loading, .bin handling, goal
correlation, cancel, feedback/status/result arrival. That is transport
work which has to exist in any design and is where the value of those
5 243 lines actually sits. What gets rebuilt is where it lands on the
NGSI-LD side.
⚠ The anti-pattern to avoid, because it is the obvious shortcut and
it is wrong here: fabricating per-request state on the inbound path —
filling in the request struct, setting the URL wildcards, calling a
service routine directly and then invoking the request-completed hook by
hand, all from a transport callback thread. Per-request state belongs in
the per-connection struct, and the one-thread-per-connection invariant
holds. The inbound path goes through serveDistOp() — step 2 of §9 —
which is why that step is not optional for this bridge.
10. Service Execution — a provisional convention, quarantined
Revised 2026-08-26. Was "deferred". Deferral is no longer available: the DDS bridge needs services and actions now, and they need some NGSI-LD representation.
The next NGSI-LD release will introduce a first-class Service Execution concept, and it will settle how an invocation, a reply, feedback, a result and a status appear in the model. It supersedes TS 104 175 Annex G, which today describes actuation as two suggested workflows (§G.5 subscription-based, §G.6 registration-based) built out of existing primitives, informatively — not as anything the API itself offers. Until then something has to be chosen, because a client that invokes a DDS service has to read the answer somewhere.
The question worth optimising is therefore not how to avoid choosing — that option is gone — but how cheap the choice is to replace.
So:
- Adopt the convention already in field use, explicitly provisional.
ddsServiceReply/ddsActionFeedbacksub-Property envelopes, per-goal instances keyed bydatasetId. The reason is interoperability: deployments and tooling exist that already read that shape, and matching them is the point. Inventing a better one now would buy nothing and would be harder to abandon later. - Model the kind from day one. A Channel declares
kind: topic | service | action(§3.3) whether or not the semantics are final. The model is then complete and the driver vtable knows what it will eventually need. - Quarantine the convention behind the driver. Nothing outside the bridge plugin should encode what a feedback envelope looks like. When Service Execution lands, what changes is a translation layer, not the model — and not eleven service routines.
- Write down that it is provisional, in the plugin's own docs and in whatever the API returns, so nobody builds on it believing otherwise.
What is and is not exposed, since "an external consumer might parse it" is too broad to act on. It splits three ways and only one carries risk:
| Mode | What a consumer reads | Exposed to the change? |
|---|---|---|
| Topic | attribute.value — the sample itself |
No. That is the application's own payload travelling over the wire, not a convention of ours, and Service Execution does not touch it. |
| Service | the reply, merged back into the attribute | Only if it reads a named sub-Property rather than the value. |
| Action | per-goal instances plus feedback / result / status sub-Properties | Yes — all of it is convention. |
On the action convention's use of datasetId — a fairer reading than
"it overloads a mechanism", which an earlier draft of this section claimed.
datasetId distinguishes multiple instances of one attribute, and N
concurrent goals genuinely are N instances of one attribute. That is a case
the definition covers rather than a mechanism bent out of shape, the
urn:goal: prefix namespaces it so ordinary datasetIds keep their meaning
alongside, and it buys storage, query and temporal handling for nothing. It
was chosen deliberately, and as provisional from the start.
⚠ What it does not buy is notification scoping, and that is the part to
watch. A subscription on an action attribute fires on every change, but
the notification body carries the attribute's default instance rather than
the changed goal's feedback, result or status — there is no datasetId-scoped
projection on the notification path. So the one thing a goal most needs,
tell me when this goal progresses, is exactly what the borrowed mechanism
cannot do; a consumer falls back to GET …?datasetId=urn:goal:<uuid> or the
temporal API. The lifecycle is unusual too: the attribute disappears when
the last goal finishes and returns with the next, which no ordinary multi-
instance attribute does.
Being provisional by intent, the migration when Service Execution lands is closer to a rename than a re-architecture — which is why quarantining behind the driver is enough, and no stronger claim is needed to justify it.
Which makes the question to ask a deployment much more specific than whether anything parses the envelopes: does it track a goal while the goal runs — reading feedback or status — or does it only read the final value once the goal is done? If the latter, the convention is invisible to it and can be replaced at will.
When Service Execution does land, the BridgeDriver contract grows a
parallel vtable section for invocation, mirroring the spec API. OPC-UA's
Call services map onto the same abstraction.
11. Open questions
- URI scheme registry: do we publish a
coraine URI scheme conventionsdoc, or borrow from existing IANA / industry conventions where they exist (opc.tcp://,modbus://, etc.)? - ~~CSR
endpointsemantics for bridges~~ — answered 2026-08-26 by §3.1. A bridge endpoint is no longer a registration'sendpoint, so the "URI a broker can forward to" reading never applies to it. Nothing to exempt and no sentinel needed. - Tenant scope: a Channel is tenant-scoped like any other broker-held object. Does each tenant get its own DDS participant, or is there one participant per broker serving all tenants with topic-name prefixing? Probably per-tenant for isolation, but the cost of N participants needs measuring.
- Outbound loop prevention across multiple bridges: if a bridge
receives a sample, upserts the entity, and the upsert fans out to
other bridges via distops, do we want it to? Preventing only the
SAME-bridge loop is the minimum and is what existing integrations do;
per-scheme allow-/block-listing on the Channel might be needed. Note
that
retention: relay(§3.3) changes the shape of this — a relay Channel stores nothing, so there is no local write to fan out from in the first place. - MQTT pub/sub vs request/reply for distops: NGSI-LD distop semantics are request/reply (peer broker returns a response body
- status). MQTT is fundamentally pub/sub. Two options:
(a) One-shot topic pair per request: requester publishes to
<prefix>/req/<correlation-id>, subscribes to<prefix>/resp/<correlation-id>, unsubscribes when response arrives. Cheap on the wire, expensive on the broker subscription table for high-volume use. (b) Per-broker request/response topic pair: every broker has one inbound topic and one outbound topic, correlation handled by a correlation id inside the payload. Cheaper subscription table, requires a side-table of pending requests on the requester. Probably (b). Pin before any MQTT bridge code lands. - Distop dispatcher refactor: how invasive is the change to
ldDistOpSendMultito add thebridgeSend(scheme, req)dispatch? The transport-neutral refactor (step 1 in §9) is large enough on its own — landing it as a self-contained commit before any new transport plugin keeps the bridge PR small. - Channel conflict beyond registrations (new 2026-08-26): two
mirroring Channels claiming the same
(entity, attribute)from different transports. Refuse, or allow and define ordering? Refusing is the conservative default and can be relaxed later; allowing cannot be tightened later without breaking deployments. - Does anything external parse the v0 service/action envelopes?
(new 2026-08-26) — §10 rests on the answer. If a partner's code reads
ddsActionFeedbackdirectly, it is an interface and the migration to Service Execution stops being free. - Notification transport unification: the broker's current MQTT support is for outbound subscription notifications (separate from the distop / CSR path). Bringing notifications under the bridge family would unify "broker sends a thing to a remote endpoint" but would also expand the bridge scope beyond CSR-bound traffic. For the first cut the bridge family is CSR-bound only; notifications stay in the existing notification path. Re-evaluate after WS / MQTT bridges are working — both protocols naturally carry both kinds of traffic on the same connection.
Naming: this document was written before the 2026-08-19 rename and moved
here after it. Paths and identifiers now use the current names throughout —
coraine, corLibs, corNgsild, corRest, corDB.
Related docs:
- Speaking to devices directly — the south bridge, and why it is this same
family rather than a second one: device-protocols.md
- What FIWARE's IoT Agents actually do, and how they map onto Bridge /
Channel / registration: iot-agents.md
- The plugin architecture the bridge family sits beside:
plugin-architecture.md
The wire format for the binary IPC plugin, and the original candidate analysis that preceded it, are in a separate working document not published here.