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

  1. 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.

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.