Two schemas can agree that a member is a double in metres, pass every
validator, and still be describing different quantities. JSON Structure:
Semantic and Reference-System Annotations is a new IETF Internet-Draft that
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. No mainstream schema language has a slot for it, so nothing downstream can check it — and every value still validates.
The short version. Nine optional keywords, nearly all of them 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. Every validator on earth is happy. 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. Nothing validated wrong. Nothing threw. The result is simply not a number about anything.
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 — and, this is the part nothing else does,
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 the shape once and you have nine keywords.
One test decided every one of them. A quality of a value earns a keyword when a consumer must know 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 a code came from all pass: get one wrong and the arithmetic is wrong while every value still validates. Licensing and retention fail, because they change nothing about what may be computed. A per-observation calibration record fails, because it belongs in the payload.
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?
Several people have, and each of them solved it somewhere that does not travel.
Type systems describe shape. JSON Schema, Avro, Protobuf, Thrift, Table
Schema, Parquet, Iceberg — every one of them will tell you a member is a 64-bit
float and none of them has a place to record what it is a float of. That is
not an oversight. Shape is what they were built for, and a description string
is where everything else has been going for twenty years.
Graph vocabularies carry meaning on the instance. RDF, JSON-LD, schema.org bind a property to an IRI, which answers what is this. They do not answer what is this resolved against. A JSON-LD context has no way to say that three members are the components of one vector in one frame, or that two members are the axes of a CRS and here is their order. And the binding rides with the document — a mechanism priced for a catalog page, not for a telemetry topic doing a hundred thousand records a second.
Catalogs describe datasets. DCAT, ISO 19115, DataHub, OpenMetadata, Unity Catalog: ownership, lineage, coverage, licensing, freshness. Exactly right for governance, and the wrong altitude for this. A catalog entry sits beside the stream and never reaches into a record to say the third number is earthward.
Domain standards nailed it, inside one domain. 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. SDMX in official statistics. These are decades of careful work by people who understood the hazard perfectly well. None of them is a schema language for JSON, and none of them survives contact with the boundary. Take a netCDF file apart into a Kafka topic and the CF attributes are simply gone.
Semantic conventions name fields. OpenTelemetry maintains a registry saying
what http.request.method means, and it works. But it is a naming agreement,
not a reference-system mechanism: nothing in it can express axis order or a
weighting curve, and it only governs the fields the registry has reached.
So: type-level, so it costs nothing per record. In the schema that already ships with the data, so it crosses the boundary the domain standards do not. Pointing at the registries those communities already maintain, so it competes with none of them.
And then the part with no precedent anywhere on that list — it is one shape.
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, a leap-second-free ordinal clock. Those communities
have never shared a mechanism and have had no reason to look for one. Under
these keywords the difference between a color space and a coordinate reference
system is which registry the pointer resolves against, and a consumer that
learned to check one has learned to check all of them.
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. Read those letters the obvious way and you get all three wrong. hp is
northward. he is earthward. hn is eastward, and 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. Try guessing that from the
field names.
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.
Confusing the two is the easiest mistake to make with this keyword, which is why
this sample exists.
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 order, and this has been quietly corrupting geospatial pipelines for twenty years.
"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 — that fact has been published for decades. What was missing is the
join: which of your members supplies axis 1. That is all coordinates does,
and it is why the keyword binds members rather than just naming a CRS. Swap the
two references and the same two arrays describe a point in Kazakhstan, with
every validator still green.
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 |
Prose stops helping the moment the answer has to run. Accuracy on bare
falls from 0.6364 to 0.3297 — of the claims its query touches at all, two thirds
are violated. Prose lets a reader hedge and SQL does not. 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. A description is enough to describe the
feed correctly and not enough to make the reader write different SQL.
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.
The most plausible reading is that the specification text is not helping this
reader. It is written for a human implementer deciding what to emit. Handed to
a small model that already has the annotation in front of it, it is several
thousand words of prose competing for attention with the schema — and attention
is the scarce resource. The arm that wins is annotated, which has the fact and
nothing else. Every jump worth having in this run happens between prose and
annotated, and the layer above it costs rather than pays.
That is a claim about context, not about the specification’s quality, and one run on one model does not settle it. But it is the reading the numbers support, and it has a practical consequence: ship the annotations to the reader, not the document that explains them.
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.