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

  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

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.

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.