Appendix N: Web Binding (Normative)
A SpatialDDS participant joins a DDS domain. A browser cannot, and neither can
most of what people actually build on top of a spatial commons: a dashboard, a
phone, a serverless function, a CDN edge. This appendix defines how the same
typed content reaches them — HTTPS GET for state, a WebSocket for streams,
JSON on the wire — so that a client with fetch and a WebSocket reads
SpatialDDS without a DDS library, a domain, or a discovery handshake.
It is deliberately an honest subset, in the sense §7 already uses for QoS. The binding carries the distinctions a web client can observe and act on, and it does not emulate the ones it cannot. Where the bus leaves something implicit that a web client has no way to infer, the binding makes it an explicit message; where DDS offers something the web has no equivalent for, the binding says so rather than approximating it.
Two deployment shapes, one surface. An endpoint may bridge a DDS domain, or it may be web-native, serving content that never transited a bus. A client MUST NOT be able to tell which, and nothing in this appendix permits an endpoint to advertise which it is. That is not politeness: the moment a conformance test can distinguish the two, the two drift, and the binding becomes two bindings. Every normative requirement below is therefore written so that a web-native endpoint can satisfy it.
This appendix was written last, from two independent implementations and the conformance suite that certifies them, rather than first from intent. Several of its requirements exist because a second implementation could not satisfy the first draft of them.
N.1 Representation: canonical JSON
Every payload is JSON derived mechanically from the IDL. The mapping is a pure function of the type and MUST be implemented as such; a hand-written JSON shape for any type in this specification is non-conformant.
| IDL | JSON |
|---|---|
boolean |
boolean |
int8…int32, uint8…uint32 |
number, width-bounded |
int64, uint64 |
string, decimal, unconditional |
float, double |
number; finite only, NaN and infinities rejected per §2.3 |
string, wstring |
string |
enum |
string, the enumerator name |
struct |
object, members under their verbatim IDL names |
union |
tagged object, {"type": "<ENUMERATOR>", "<member>": value} |
sequence<T,N>, T[N] |
array |
sequence<octet>, octet[N] |
base64 string |
Four of these are easy to get wrong and are therefore stated as requirements.
Sixty-four-bit integers MUST be decimal strings, always, not only when the
value exceeds the safe range. The binding's first-class consumer is a
JavaScript engine, where JSON.parse coerces every JSON number to a double
before application code runs. A value-dependent rule would make a field's
type depend on its value, forcing oneOf: [integer, string] on every
64-bit field and denying consumers a stable schema. One type per field is the
honest choice; this is the binding's one deliberate divergence from DDS-JSON
1.0.
Members MUST appear under their verbatim IDL names. A code generator that
renames a member to satisfy its target language — global and from are
reserved words in several — MUST reverse the rename before publishing. This is
stated because it was violated: eleven generated schemas in this
specification's own tooling published a generator's identifiers for a time, and
additionalProperties: true made the mismatch silent in both directions.
A has_* guard is translated literally. The guard appears as the boolean
it is, and the member it qualifies is always present, because that is what
the wire carries: CDR serializes a guarded member regardless of its guard. A
reader MUST consult the guard before relying on the member. Presence-mapping is
non-conformant: it would emulate an optionality DDS does not provide.
An empty union branch is the discriminator alone. {"type": "COV_NONE"}
and no member, even where the IDL declares a placeholder member for that case
and the wire carries a byte for it. This does not contradict the literal has_*
rule above: a guarded member is a declared member carrying a typed value, which
a reader may need once it has consulted the guard, whereas a union placeholder
is an encoding artifact with no semantic content — consumed when reading the
wire, and represented nowhere. A generator MUST record which branches are empty
in its published tables, because the distinction is not recoverable from the
IDL.
N.1.1 Canonical form, for digests
Wire JSON need not be canonical. Where a digest is taken — an ETag, a content
pin — the input MUST be the canonical form: object keys sorted lexicographically,
strings minimally escaped, no insignificant whitespace, and numbers rendered by
the ECMA-262 Number::toString rule (0.1 → 0.1, 0.1+0.2 →
0.30000000000000004, 1e21 → 1e+21, 1.0 → 1, -0.0 → 0).
"Shortest round-trip" names a property, not a unique string. The ECMA-262 rule is the pin, so that a producer and a browser agree byte for byte and an ETag computed on one side matches the other.
N.1.2 Generated artifacts
An implementation MUST derive its JSON from the published JSON Schemas and
serializer tables for the version it implements, or from the IDL directly.
A serializer table MUST distinguish a fixed array from a bounded sequence —
length against bound — because a sequence is length-prefixed on the wire
and a fixed array is not, and a table that erases the distinction cannot serve
as a decoder's type model. The same applies to an octet payload, where base64
hides the difference entirely.
N.2 Well-known paths
All under the single /.well-known/spatialdds namespace registered per RFC 8615.
| Path | Method | Returns |
|---|---|---|
/.well-known/spatialdds/search |
GET, POST |
service manifests (§3.3.0) |
/.well-known/spatialdds/schemas |
GET |
the schema index |
/.well-known/spatialdds/schemas/{type} |
GET |
one JSON Schema |
/.well-known/spatialdds/bootstrap |
GET |
bootstrap manifest, where offered |
/.well-known/spatialdds/resolver |
GET |
resolver metadata, where offered |
bootstrap is REQUIRED only of an endpoint that offers DDS access. It exists
to hand a client the parameters for joining a domain. A web-native endpoint has
no domain, no peers and no partitions, and MUST return 404 rather than
fabricate them; a 404 from bootstrap is conformant and a conformance suite
MUST accept it. A fabricated domain_id is worse than an absence, because a
client acts on it.
resolver is REQUIRED of an endpoint that is an authority for the
spatialdds:// URIs it can resolve (§7.5.2) — it exposes that authority's
authority / https_base / cache_ttl_sec metadata. A 404 is conformant from
an endpoint that is not such an authority, which includes every endpoint that
holds no manifests, and a conformance suite MUST accept it.
The schema index is a JSON array, one entry per published type:
[{"type": "spatial.core.GeoAnchor",
"url": "https://host/.well-known/spatialdds/schemas/spatial_core_GeoAnchor",
"digest": "sha256:3af2…"}]
{type} is the schema's file basename: the dotted FQN with . replaced by _.
Each schema response MUST carry an ETag that is the digest of its canonical
form, and that digest MUST equal the digest the index advertises. An endpoint
MUST publish a schema for every type it serves, so that a consumer can decode
everything it receives from the published contract alone.
N.3 Latched state is a GET resource
A TRANSIENT_LOCAL instance is a cacheable representation. Its path mirrors the
§3.3.1 topic name one-to-one, with the spatialdds/ prefix supplied by the base
URL:
spatialdds/<domain>/<stream>/<type>/<version> on the bus
{base}/<domain>/<stream>/<type>/<version> the collection
{base}/<domain>/<stream>/<type>/<version>/<key> one instance
<key> is the value of the member the IDL marks @key, and it occupies exactly
one path segment: it MUST be percent-encoded, with / the case that matters —
the spec's own GeoAnchor.anchor_id example is "anchor/4th-and-main", which
unencoded gains a path segment and is unroutable (404). The collection returns
a JSON array of instances; the instance path returns one object, or 404.
A topic with fewer than five segments does not fit the mapping above: the
well-known discovery topics (spatialdds/discovery/announce/v1,
spatialdds/discovery/depart/v1) carry no <domain>. Such a topic is served in
the well-known namespace (N.2) instead, at
{base}/.well-known/spatialdds/<stream>/<type>/<version> — so
spatialdds/discovery/announce/v1 is {base}/.well-known/spatialdds/discovery/announce/v1.
A domain-scoped announce (spatialdds/<domain>/discovery/announce/v1) has the
full five segments and follows the one-to-one mapping.
The resource served at the well-known path behaves exactly as the §N.3 collection
it would be at a domain-scoped path; only its root differs. A GET returns the
latched set with an ETag and 304 on revalidation (per the rules below), an
instance is addressed at
{base}/.well-known/spatialdds/<stream>/<type>/<version>/<key> with the same
percent-encoding, the access class and Cache-Control rules of N.4 apply
unchanged, and the N.5 stream is reached by upgrading the same path. A short
topic is a §N.3 resource rooted in the well-known namespace, not a resource of a
different kind.
Every such response MUST carry an ETag whose value is the digest of the
canonical form of the body's content, and a Cache-Control consistent with the
resource's access class (N.4). An endpoint MUST honour If-None-Match with
304 Not Modified and an empty body on a match. An ETag MUST be a function of
content alone: two endpoints serving the same state MUST produce the same
ETag, and restarting an endpoint MUST NOT change it.
Plain statement: a validator that changes while the content does not is worse than no validator, because a cache will serve a stale body under a fresh one.
N.4 Access classes
Every resource an endpoint serves MUST declare exactly one access class, in the
access field of its manifest topic entry (N.7):
public-cacheable— served without authentication, with a positivemax-age. Shared caches and CDNs MAY store it.operator-scoped— served only against a valid bearer credential, andCache-Control: no-store. A401MUST carryWWW-Authenticate: Bearer.
Auth and cacheability MUST NOT contradict. An operator-scoped resource MUST
NOT be served with any cache lifetime, in either direction, authenticated or
not. A bearer-gated body carrying max-age sits in a shared cache where the
next unauthenticated request can be served it, which defeats the gate entirely.
Where a public-cacheable resource is a manifest carrying its own ttl_sec, that
document-level lifetime (§7.5.2) governs its max-age in place of the access
class's default: where both speak, the document's content wins. This grants no
lifetime to an operator-scoped resource, which stays no-store whatever
ttl_sec it carries.
A credential MUST NOT be accepted in a URL query parameter. Credentials belong
in the Authorization header, for both GET and the WebSocket handshake; a
query parameter writes the credential into proxy logs and Referer headers.
An endpoint SHOULD classify by what a resource describes rather than by who asks: configured geometry — zones, crossing lines, frames, georeferences — is ordinarily public, and observations of people ordinarily are not.
N.5 Streams are a WebSocket
A subscription is a WebSocket opened to a collection URL. Every message is a
JSON object with an event member drawn from a fixed set:
{"event":"sample", "topic":"<topic>", "key":"<key>", "data":{ … }}
{"event":"end_of_replay", "topic":"<topic>", "replayed":<N>, "as_of":{ … }}
{"event":"dispose", "topic":"<topic>", "key":"<key>", "reason":"<text>"}
{"event":"liveliness", "topic":"<topic>", "key":"<key>", "alive":true|false}
{"event":"closing", "topic":"<topic>", "reason":"<text>", "code":"<token>"}
A client MUST ignore an event it does not recognise, which is the APPENDABLE
evolution rule applied to the envelope.
N.5.1 Ordering
On subscribe an endpoint MUST send zero or more sample events — the latched
set, current value per key — then exactly one end_of_replay, then live
events. replayed MUST equal the number of sample events that preceded it.
This marker is the single thing the binding adds that the bus does not have. On a DDS domain "you now hold the whole latched set" is implicit in the QoS and a late joiner simply receives it. Over a WebSocket a client that has received two zones cannot distinguish three zones with one in flight from two zones and a quiet topic. The binding makes the boundary a message and the count checkable.
A second end_of_replay is non-conformant, not merely redundant: it would
restart a client's notion of being caught up.
as_of is OPTIONAL and, when present, is a builtin.Time stating the instant
the replayed set describes. An endpoint serving recorded content SHOULD include
it, because for such an endpoint "now" is not self-evident.
N.5.2 Removal
A deliberate removal MUST be a sample carrying the instance's terminal state,
immediately followed by a dispose for the same key with a human-readable
reason. Not a dispose alone, which tells a client the instance is gone without
saying what it last was; and not a dispose separated from its sample by other
events, after which a client cannot tell which sample was terminal. This is
§2.14 over the web.
N.5.3 Liveliness
Liveliness MUST be an explicit event. A client MUST NOT infer liveliness, or removal, or being caught up, from the WebSocket closing.
Granularity is declared, not assumed. A topic entry MAY carry
liveliness: "per-key" | "per-writer" | "none". An endpoint whose upstream has
no writer-liveliness concept — a translation from a transport that does not
carry one — MUST declare "none" or "per-writer" rather than synthesise
per-instance liveliness it cannot observe. Loss of liveliness is not removal
and MUST NOT be sent as dispose.
N.5.4 Ending a subscription
An endpoint that stops a subscription MUST send closing, with a reason, before
closing the socket, whenever it is able to. The contract forbids a client from
reading meaning into the close, so the close cannot carry the meaning: without
this event an expired credential, a withdrawn topic and a restarting server all
arrive as the same silence. code is a short machine-readable token;
reason is for a human reading a log.
N.6 Commands are a POST
Where an endpoint exposes a command surface, a command is a POST to the
resource it acts on, and the response carries the outcome, including an explicit
decline, per the keyed-command conventions of Appendix M.1.
An endpoint with no command lane — a northbound translation, a recording — has
nothing to accept a verb for. It MUST refuse cleanly, with 405 or 404, and
MUST NOT accept a command it cannot act on. A conformance suite MUST NOT require
a command surface to exist.
N.7 Service manifests
Discovery returns §8.2.3 service manifests. For the web binding, each entry of
service.topics MUST carry:
| Field | Required | Meaning |
|---|---|---|
name |
REQUIRED | the §3.3.1 topic name |
type |
REQUIRED | the registry type segment |
version |
REQUIRED | the version segment |
durability |
REQUIRED | "VOLATILE" or "TRANSIENT_LOCAL", the DDS durability kinds verbatim |
qos_profile |
OPTIONAL | the deployment's QoS profile name, as §3.3.2 already uses it (VIDEO_LIVE, POSE_RT) |
url |
REQUIRED | the absolute URL of that topic's collection resource |
access |
REQUIRED | the access class of N.4 |
liveliness |
OPTIONAL | the granularity of N.5.3 |
durability is a separate member and not a qos_profile value. qos_profile
names a deployment's profile — §3.3.2 uses VIDEO_LIVE, POSE_RT, VPS_REQ —
and is free-form by design, so durability carried there would collide with an
established use and could not be read reliably. The two DDS kinds are used
verbatim, so a reader resolves them from DDS vocabulary and this binding coins
nothing.
url is what makes discovery hypermedia rather than a path-construction
exercise. Without it a client holding a manifest must know the N.3 path mapping
and rebuild each URL by string surgery on a topic name; with it, the client
follows a link. That is the difference between a ten-line client and one that
re-implements the specification.
service.connection remains OPTIONAL per §8.2.3. An endpoint that offers no DDS
access MUST omit it, and a client MUST treat its absence as conformant. §3.3.0's
requirement that a client be able to extract service.connection and join a
domain applies only to results that carry the block.
A manifest is JSON produced by this binding and follows N.1, including stamp,
whose sec is a decimal string.
N.8 Errors
An error response SHOULD use application/problem+json per RFC 7807: type,
title, status, detail, instance. The binding defines no error body of
its own. Aligning with an existing standard is preferred to inventing a shape,
and this specification's earlier habit of an ad-hoc {"error": …} object is
deprecated for new implementations.
Status codes carry the contract: 304 for a validated cache entry, 401 with
WWW-Authenticate for an operator-scoped resource, 404 for a well-known path
an authority does not support, 400 for a malformed query.
N.9 The QoS subset
Carried, and observable: reliability as a per-topic property; durability,
declared in a topic entry's durability member (N.7) and observable as latched
replay terminated by the end_of_replay marker; KEEP_LAST(1) per key, as the
collection returning one instance per key; keyed identity, as the instance path;
removal, as terminal sample plus dispose; liveliness, as an event.
Not carried, and not emulated: deadline, ownership and ownership strength, time-based filter, partitions, transport priority, history depth beyond one. An endpoint MUST NOT advertise these in a manifest or approximate them in the envelope. A client needing them belongs on the bus.
N.10 Conformance
An endpoint is conformant when it satisfies this appendix and the suite in
web-binding/conformance/ passes against it. The suite takes a base URL and an
optional bearer token and nothing else: no flag selects a deployment shape, and
any check that would need to know which shape it is talking to is a defect in
the check.
Certification requires the same suite, unchanged, passing against both a bridged and a web-native endpoint. Either alone demonstrates that one implementation works; both together demonstrate that the binding is one binding.
Why this belongs in the standard (Informative)
A bus that only bus participants can read is a smaller commons than it looks. Most of the clients that would consume a spatial commons cannot join a DDS domain, and the usual answer — a bespoke REST façade per deployment — gives every deployment its own dialect, so a client written for one works nowhere else. That is the failure this appendix exists to prevent: not an absence of HTTP access, but an abundance of incompatible HTTP access.
Making the binding normative also forces the honesty the bus gets for free. A
DDS reader knows from the QoS when it holds a complete latched set; a web client
only knows if someone decided to tell it. Writing that down as end_of_replay,
with a count, is the kind of requirement that only becomes visible when a second
implementation tries to use the first one's output and finds it cannot tell a
slow replay from a quiet topic.