Specification scope: Core with the Units and Semantic Annotations companion specifications.
Two schemas can agree that a member is a double in metres, pass their
type and unit checks, and still describe different quantities. The
JSON Structure: Semantic and Reference-System Annotations Internet-Draft puts
the missing fact in the schema.
Why it matters. The fact that would have stopped the bad arithmetic lives in a PDF, in a field name somebody hopes will be read the right way, or in the head of an engineer who has since moved teams. JSON Structure Core and its Units companion can state the numeric type and unit, but they do not identify a datum, reference frame, observed property, or weighting curve. A processor limited to those declarations cannot check that distinction.
The short version. Most reference-style keywords use the same shape: a
reference that identifies a definition, and a kind that names the
model that definition belongs to. That one shape binds an EPSG coordinate
system, an ICC profile, an ITU-R weighting curve, an ICAO register, a Landsat
band set, and a spacecraft vector frame alike. Validation is unchanged. Bytes on
the wire are unchanged.
Skip ahead: the keywords · why nothing else covers this · worked samples · what the measurements say · links and status
The failure this exists to stop
Two teams publish telemetry. Both schemas declare a member double. Both,
because these are careful people who use JSON Structure Units, declare the unit
m. A validator checking those declarations accepts both values. Somebody
joins the two streams and subtracts.
One of them was water level above a tide-gauge datum. The other was height above the WGS-84 ellipsoid. They differ by tens of metres, and by how much depends on where you are standing. The type and unit checks did their job; they did not have the datum needed to decide whether the subtraction was meaningful.
The schema had nowhere to put the one fact that would have caught it. The draft gives it somewhere to go.
The keywords
It extends JSON Structure Core with optional annotations, in four groups.
Bind a node to a published term. concepts and observedProperty attach a
type or a member to a definition somebody else maintains — QUDT, the CF standard
names, a SKOS scheme, a domain catalogue — so two systems calling one thing by
two names can establish that they mean the same thing.
Say what the record observes. semanticRole separates the result from the
property observed, the feature it belongs to, the procedure that produced it,
and the several distinct times one record can carry. derivation, statistic,
phenomenonTimeRelation, and cadence record what has already been done to the
value: measured, modelled, calculated; a mean, a maximum, a fourth-highest; an
instant, an interval, an accumulation; every minute, or on change.
Name the reference system. temporalReferenceSystem,
coordinateReferenceSystem, linearReferenceSystem, vectorReferenceFrames,
tensorReferenceFrames, and frameTransforms say what a position, a direction,
or an orientation is read against. They also state which of your members
supplies which axis.
Resolve compound values. colorSpaces, audioChannels, and spectralBands
map a set of members onto the channels or bands that give them meaning.
codedValues binds a short code to the register that assigns it a meaning.
measurementConditioning carries the frequency weighting, time weighting, and
level reference that a conditioned measurement already has baked in.
Nearly all of them are the same shape: an object with a reference that
identifies a definition and a kind that names the model that definition
belongs to. Learn that recurring shape once and the other bindings are familiar.
The draft states one selection rule for these keywords. A quality of a value earns a keyword when a consumer needs it to decide whether two values may be combined, and when it holds for the type rather than varying per record. Axis order, weighting curve, and the register behind a code pass that rule. Licensing and retention do not change what may be computed. A calibration value that changes per observation belongs in the payload instead.
The draft publishes no vocabulary, no reference system, no color space, no code list. Established bodies do that, and an annotation points at one. What is here is the form of the pointer, and the rules by which a processor checks that your members agree with what you pointed at.
Hasn’t somebody solved this?
Existing systems carry parts of this information at different layers. The comparison below concerns the named mechanisms, not every extension or application built around them.
Type systems describe representation and structure. JSON Schema, Avro,
Protocol Buffers, Thrift, Table Schema, Parquet, and Iceberg define data shape
through their own type and constraint systems. Their core type declarations do
not provide the cross-domain reference-and-component binding defined by this
draft. A JSON Schema description can explain the distinction to a reader,
but prose does not provide the ordered member binding used here.
Graph vocabularies bind data to identified terms. RDF and JSON-LD can identify properties and concepts with Internationalized Resource Identifiers (IRIs). The draft adds a schema-level shape for bindings that need ordered member lists, such as vector components or coordinate axes. That is the specific mechanism compared here.
Catalogs describe datasets. DCAT and ISO 19115 describe dataset metadata such as distribution, coverage, and lineage. This draft binds members inside a declared type, for example to say that the third number is an earthward vector component. The two layers serve different purposes.
Domain standards define domain-specific meaning. CF conventions in netCDF, SensorML and Observations & Measurements in OGC SWE, ICC profiles in color, ITU weighting curves in audio, DICOM in medical imaging, CCSDS in spaceflight, and SDMX in official statistics define relevant semantics in their domains. When data is projected into another format, those semantics survive only if the projection carries them. These annotations can point back to the domain definitions.
Semantic conventions name fields. OpenTelemetry maintains a registry that
defines http.request.method. That naming agreement does not define the
reference-and-component model in this draft.
The recommendation is to keep stable semantic bindings in the schema and point at registries maintained by the relevant domain community. That adds no members to conforming instance documents. A projection still has to preserve the annotations; the schema cannot make a lossy conversion lossless.
The design contribution is one common shape across these domains.
The same reference-and-kind pair binds an EPSG coordinate system, an ICC
profile, an ITU-R weighting curve, an ICAO register, a Landsat band set, a
spacecraft vector frame, or a leap-second-free ordinal clock. Under these
keywords, the registry and kind determine how a consumer interprets the
reference. Support for one reference-and-kind object does not by itself give
the consumer domain knowledge about every registry.
Sample: three letters that lie
In the JSON that follows, annotation keywords are marked like this, and the names that bind the frame to the members it governs are marked like this. Everything else is ordinary JSON Structure.
The hazard: three components whose names invite three wrong readings.
The GOES spacecraft magnetometers publish three components named hp, he, and
hn. In the EPN frame, hp is northward, he is earthward, and hn is
eastward; the n is for normal.
They are one vector resolved in a spacecraft-local frame that no register serves, so the frame is written out in the schema as a meta-type and cited by pointer. The sample carries a description on every member; all but one are stripped here:
{ "$schema": "https://json-structure.org/meta/semantic-annotations/v0/#", "$id": "https://schemas.example.org/semantic-annotations/real-world/20-goes-magnetometer", "$uses": ["JSONStructureSemanticAnnotations"], "name": "GoesMagnetometer", "type": "object", "observedProperty": { "reference": "https://catalog.example.org/observable-properties/geomagnetic-field-vector-at-spacecraft/v1", "kind": "example-catalog" }, "properties": { "time_tag": { "type": "datetime", "semanticRole": "phenomenonTime", "cadence": { "kind": "fixed", "period": "PT1M" } }, "satellite": { "type": "int32", "semanticRole": "observingProcedure" }, "hp": { "type": "double", "unit": "nT", "semanticRole": "observationValue", "derivation": "measured", "phenomenonTimeRelation": "instant", "observedProperty": { "reference": "http://qudt.org/vocab/quantitykind/MagneticFluxDensity", "kind": "qudt-quantity-kind" } }, "hn": { "type": "double", "unit": "nT", "semanticRole": "observationValue", "derivation": "measured", "phenomenonTimeRelation": "instant", "observedProperty": { "reference": "http://qudt.org/vocab/quantitykind/MagneticFluxDensity", "kind": "qudt-quantity-kind" } }, "he": { "type": "double", "unit": "nT", "semanticRole": "observationValue", "derivation": "measured", "phenomenonTimeRelation": "instant", "observedProperty": { "reference": "http://qudt.org/vocab/quantitykind/MagneticFluxDensity", "kind": "qudt-quantity-kind" } }, "total": { "type": "double", "unit": "nT", "minimum": 0, "description": "Magnitude of the field vector, from the `total` field, computed by the publisher as the root of the sum of the squares of `hp`, `he` and `hn`. It is not an independent reading and adds no information to the three components, but it is frame-invariant where they are not, so it is the member to compare across spacecraft. Quiet-time values at geostationary altitude lie between roughly 100 and 120 nT.", "semanticRole": "observationValue", "derivation": "calculated", "phenomenonTimeRelation": "instant", "observedProperty": { "reference": "http://qudt.org/vocab/quantitykind/MagneticFluxDensity", "kind": "qudt-quantity-kind" } }, "arcjet_flag": { "type": "boolean", "semanticRole": "resultQuality" } }, "vectorReferenceFrames": [ { "reference": { "$ref": "#/definitions/GoesEpnFrame" }, "kind": "type", "components": ["hp", "he", "hn"] } ], "required": ["time_tag", "satellite", "arcjet_flag"], "additionalProperties": false, "definitions": { "GoesEpnFrame": { "name": "GoesEpnFrame", "type": "tuple", "properties": { "p": { "type": "double", "description": "Perpendicular to the orbital plane, positive northward." }, "e": { "type": "double", "description": "Perpendicular to p, positive earthward." }, "n": { "type": "double", "description": "Perpendicular to p and e, positive eastward. The name is normal, not north." } }, "tuple": ["p", "e", "n"] } } }
A record off that feed:
{ "time_tag": "2026-07-31T05:12:00Z", "satellite": 19, "hp": 103.42, "hn": -18.77, "he": 6.05, "total": 105.31, "arcjet_flag": false }
Seven numbers and a boolean. Nothing in there tells you that hn being negative
means eighteen nanotesla westward, or that hp from satellite 19 must not go
into the same average as hp from satellite 18. The instance never carries
that. Only the schema can.
Look at what falls out. total is the magnitude, and derivation: "calculated"
says it was produced by deterministic arithmetic that no named summary covers —
not measured, not estimated, not one of minimum, maximum or mean, which
would have taken derivation: "statistic" instead. So it adds no information
the three components do not already carry.
But look at where it is not. total is absent from the components array of
vectorReferenceFrames, and that absence is the annotation. hp, he and hn
are resolved in a spacecraft-local frame, so their numbers mean nothing outside
it. A magnitude is frame-invariant. total is therefore the member that may
be compared across two spacecraft, and hp is not. The field names alone do
not encode that distinction.
And arcjet_flag is resultQuality. When the electric thrusters fire they
generate a field at the sensor that looks exactly like a geophysical signal.
Flagged records get thrown away, not corrected.
Sample: a decibel is not a number
The hazard: a unit that means nothing until you know what was done to the signal before it was written down.
A citizen sensor node reports three sound levels, all in dB. A decibel means
nothing until you know the weighting it was taken under and the reference it
stands against. A-weighted and unweighted over the same sound are different
numbers. Relative to twenty micropascals and relative to digital full scale are
different numbers again.
"noise_laeq_db": { "type": "double", "unit": "dB", "semanticRole": "observationValue", "measurementConditioning": { "weighting": "a", "levelReference": "soundPressure" } }, "noise_la_min_db": { "type": "double", "unit": "dB", "semanticRole": "observationValue", "derivation": "statistic", "statistic": "minimum", "measurementConditioning": { "weighting": "a", "levelReference": "soundPressure" } }
And a record:
{ "sensor_id": 28483, "observed_at": "2026-08-02 14:35:00", "noise_laeq_db": 58.4, "noise_la_min_db": 47.1, "noise_la_max_db": 79.6, "pm2_5_ug_m3": 12.3, "temperature_celsius": 24.7 }
58.4. Against what, weighted how? The record does not say and cannot. Put it
in a bucket with an unweighted 58.4 from a professional meter two streets over
and you have averaged two different quantities that print the same.
Two details in there matter more than the keyword. The feed names the weighting
but not the time constant, so timeWeighting is left out rather than guessed
at. And the particulate and temperature channels in the same record carry a unit
and no conditioning at all, because nothing is hidden in them. An annotation you
do not need is an annotation you do not write.
Sample: a code is not its meaning
The hazard: an opaque string that validates forever while telling a join planner nothing.
B77W is an aircraft type because ICAO Doc 8643 says so. EGLL is an aerodrome
because Doc 7910 says so. Both are strings. Both validate against
"type": "string" forever without anybody learning anything.
"aircraft_short": { "type": "string", "codedValues": { "reference": "https://www.icao.int/operational-safety/doc-8643-aircraft-type-designators", "kind": "icao" } }, "departure": { "type": "string", "codedValues": { "reference": "https://store.icao.int/en/location-indicators-doc-7910", "kind": "icao" } }
The record it describes:
{ "callsign": "BAW117", "cid": 1002345, "aircraft_short": "B77W", "departure": "EGLL", "arrival": "KJFK", "latitude": 51.4775, "longitude": -0.4614, "altitude": 37000, "last_updated": "2026-08-02T14:35:07Z" }
Four opaque strings. A human who flies knows three of them on sight. A join
planner knows none, until codedValues tells it that two of them resolve
against tables it can go and fetch.
Both fields carry kind: "icao", because ICAO is the register model behind them.
But they draw from two different lists, and it is reference that says which.
kind identifies the registry model, while reference identifies the specific
list. This sample shows both fields because each carries a separate part of the
contract.
Sample: which number is the latitude?
The hazard: two identical numbers in two different orders, and no record of which is which.
CRS84 is longitude-first. EPSG:4326 is latitude-first. Same points, same ellipsoid, opposite axis order. A consumer that assumes one order while reading the other swaps latitude and longitude.
"crs84Position": { "type": "tuple", "properties": { "c1": { "type": "double" }, "c2": { "type": "double" } }, "tuple": ["c1", "c2"], "coordinateReferenceSystem": { "reference": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", "kind": "ogc-crs", "coordinates": ["c1", "c2"] } }, "epsg4326Position": { "type": "tuple", "properties": { "c1": { "type": "double" }, "c2": { "type": "double" } }, "tuple": ["c1", "c2"], "coordinateReferenceSystem": { "reference": "http://www.opengis.net/def/crs/EPSG/0/4326", "kind": "ogc-crs", "coordinates": ["c1", "c2"] } } // and on the enclosing record, the vertical datum: "coordinateReferenceSystem": { "reference": "http://www.opengis.net/def/crs/EPSG/0/5703", // NAVD88 "kind": "ogc-crs", "coordinates": ["benchmarkElevation"] }
Now the instance:
{ "stationId": "USGS-12142000", "reportedAt": "2026-07-28T14:00:00Z", "benchmarkElevation": 137.4, "crs84Position": [-121.5498, 47.8232], "epsg4326Position": [47.8232, -121.5498] }
Four numbers, one point, no names anywhere. Nothing in the instance says which
value is the latitude, and nothing in the type says it either. c1 and c2
are positions in an array and mean nothing on their own.
The word latitude never appears in this schema. It does not need to. The CRS
registry already says that axis 1 of CRS84 is longitude and axis 1 of EPSG:4326
is latitude. What was missing from the schema is the join: which of your
members supplies axis 1. That is what coordinates supplies, and it is why the keyword
binds members rather than only naming a CRS. Swap the two references and type
validation can still succeed while a consumer reads the axes incorrectly.
And 137.4 is metres above NAVD88 — which is where this post came in.
Can a machine read one of these cold?
A machine can. The interesting question is which layer of the schema made the difference, and the answer is not the flattering one.
That second question is the one the evaluation/ harness answers: a
mechanically derived rubric, four cumulative arms (bare, prose, annotated,
spec), a blinded supervisor grading claim by claim with a quote required per
verdict, and a second task where the subject must write executable Azure Stream
Analytics SQL — so a claim is scored against what the query does rather than
what the prose recites. Average a quantity the rubric forbids averaging and it
is marked wrong however well the surrounding sentence quotes the rule. Run it
with --transport none and it writes every prompt without calling a model, so
the method can be audited before any number out of it is trusted. That is a
feature.
Four things need defining before the tables mean anything.
The four arms are the same schema with layers removed. Each sample is run four times, cumulatively, so the difference between two adjacent rows is attributable to the one layer that separates them.
| arm | what the reader was given |
|---|---|
bare |
member names and types, nothing else |
prose |
bare plus every description |
annotated |
prose plus the annotation keywords, specification withheld |
spec |
annotated plus the specification text |
The four verdicts separate being wrong from being silent. A claim is
correct if the transcript asserts it and stands behind it, wrong if it
asserts an incompatible reading, declined if it raises the matter and
explicitly refuses to settle it, and untouched if it never engages at all.
Stating the right answer but flagging it as a guess counts as declined, not
correct — without that rule a model’s prior knowledge of METARs would swamp
the comparison. Declining is not failing. An unannotated schema does not
determine the reference frame, and saying so is the correct behaviour.
Accuracy is correct / (correct + wrong) — of the matters a reader
committed on, how often it was right. coverage is how much of the rubric it
engaged at all. hazard divides wrong answers by every claim, so silence is
free; haz/ans divides them by the claims the reader actually committed on,
which is the rate someone relying on it would meet in practice.
Wrong answers are the number to watch, not right ones. A silent schema reader is a nuisance. One that confidently states the wrong reference frame is the failure this whole document exists to prevent.
Two runs are under version control. The comprehension run — seed 23, thirteen samples, 285 scoreable claims, 1140 verdicts:
| arm | correct | wrong | declined | untouched | coverage | accuracy | hazard | haz/ans |
|---|---|---|---|---|---|---|---|---|
bare |
49 | 28 | 123 | 85 | 0.7018 | 0.6364 | 0.0982 | 0.3636 |
prose |
177 | 9 | 11 | 88 | 0.6912 | 0.9516 | 0.0316 | 0.0484 |
annotated |
190 | 5 | 6 | 84 | 0.7053 | 0.9744 | 0.0175 | 0.0256 |
spec |
202 | 6 | 4 | 73 | 0.7439 | 0.9712 | 0.0211 | 0.0288 |
Look at the declined column, not the accuracy column. The bare arm
declines 123 claims of 285 — it reads a schema of names and types, cannot tell
what the numbers mean, and correctly says so. Adding descriptions takes that to
- That is the unremarkable result, and it is worth stating plainly: a description tells a reader enough to answer a question in words.
The query run is the one that matters, because there the reader has to commit. Seed 31, six samples, 188 scoreable claims, 752 verdicts, scored against executable SQL rather than against prose:
| arm | correct | wrong | declined | untouched | coverage | accuracy | hazard | haz/ans |
|---|---|---|---|---|---|---|---|---|
bare |
30 | 61 | 12 | 85 | 0.5479 | 0.3297 | 0.3245 | 0.6703 |
prose |
80 | 31 | 6 | 71 | 0.6223 | 0.7207 | 0.1649 | 0.2793 |
annotated |
84 | 5 | 3 | 96 | 0.4894 | 0.9438 | 0.0266 | 0.0562 |
spec |
91 | 22 | 5 | 70 | 0.6277 | 0.8053 | 0.1170 | 0.1947 |
In this query run, prose did not reduce the count of distinct wrong
decisions. Accuracy on bare falls from 0.6364 in the comprehension run to
0.3297 in the query run. Of the claims its query touches, 61 of 91 are violated.
Collapse the violation counts to distinct decisions — the rubric emits one
claim per annotated member, so a single bad division is scored once for every
member it touches — and bare and prose are level at eleven wrong decisions
each, while the two annotation-bearing arms make three. This observation is
limited to the recorded run, subject model, supervisor, samples, and rubric.
The same collapsing rescues the spec arm, which read raw looks like a
regression at 22 violations. Three decisions produce them, one of which — turning
megawatts into megawatt-hours by dividing by the gap between records — is counted
twenty times because seventeen fuel members carry the same annotations.
The supportPeriod claims show the same shape more sharply. Restricted to the 25
claims those annotations force, the annotated arm gets 6 right and 0 wrong; the
spec arm gets 7 right and 10 wrong, deriving the period from record spacing
anyway. Same annotations, plus the specification, opposite behaviour.
In this run, adding the specification did not improve the query result. The
annotated and spec arms each made three distinct wrong decisions, although
their claim-level counts differ because one decision can affect several claims.
The harness cannot confirm that the subject read the specification, so the run
does not establish why the two arms differ.
That is an observation about one recorded context, not a finding about the specification’s quality or model attention. The practical recommendation is to give automated readers the annotations they need directly in the schema. Keep the specification available to implementers; this experiment does not support using specification prose as a substitute for the annotations.
Two further things belong next to any number quoted above. The grader is a model and it is not stable: identical transcripts graded twice differ by 58 claims on one arm, so no gap narrower than that means anything. And the rubric is derived from the annotations, which grades the annotated arm against its own inputs — the open question is not whether the annotations are stated but whether they are the right things to state. Both caveats, and several more, are written down in the harness’s own README rather than left for a reader to find.
What no harness can tell you is whether an analysis is right for its domain. That needs OGC, ICC, ITU, and the people who publish these feeds. That is the review this draft needs.
Where to look
Start with the samples, not the draft. Fifteen teaching samples introduce one idea at a time. Twenty-eight real-world samples are transcribed from feeds that actually exist: AIS vessel positions, METARs, lightning strokes, grid carbon intensity, transit telemetry, USGS instantaneous values, Mode S aircraft reports, GCMT moment tensors, CCSDS attitude quaternions, KITTI sensor alignment, FOGRA characterization patches, MODIS fire detections, broadcast audio frames.
Each one exists because of a specific hazard, and each root description names
the hazard. Read half a dozen and you have the argument for the draft without
reading the draft.
- The Internet-Draft on the Datatracker
- The specification repository
— also holds
EVALUATION.md, the harness inevaluation/, and a longQ-A.mdof objections and answers - All 43 samples
Status
Individual Internet-Draft, draft-vasters-json-structure-sem-ann-00. Not a
working group document. Discussion is on the
json-structure mailing list.
The most useful response is not agreement. Name a keyword in here that fails the test above — something a consumer does not need in order to decide whether two values may be combined. Better still, bring a data set whose incompatibility hazard none of these keywords can express. The second one is worth a lot more than the first.