0005 — An export carries the schema's numbers, not a reader's
- Status: Accepted, 2026-09-02, with one condition attached at acceptance: every field kind a schema can declare has a stated representation, audio blocks included. §5 is that table. Nothing is skipped silently.
- Implementation status: Nothing built. The behaviour this record describes exists in TypeScript, in the applications it replaces.
- Date: 2026-09-02
- Depends on: 0004 §1 — the converter cannot link a vocabulary, so this record has to say what it does instead.
Context
The applications read numbers out of a sample two ways, and call them tiers.
Tier one is a hand-written reader per schema. Six of them, keyed on schema identifier, in one application's channel table and its two copies elsewhere. Each does some of four things no descriptor states:
| It does | Example | Kind |
|---|---|---|
| Flattens a nested message into columns | acceleration.x → ax | naming |
| Nulls a value when a flag says the sample said nothing | gaze x when tracked is false | validity |
| Rescales a unit for a person | storage_free_bytes ÷ 10⁹, labelled GBy | presentation |
| Computes a figure that is not in the sample | audio RMS and peak from decoded PCM | analysis |
Tier two is the descriptor. A describedChannels pass in one of those
applications: every non-repeated numeric scalar field, with its unit
annotation. It reads a schema nobody wrote a reader for.
It also skips every message-typed field, by a stated choice — flattening
"would need a name for the column … and choosing one here would make the
header depend on this file rather than on the schema."
That choice is what makes tier two unusable on its own, and the size of the gap is worth stating rather than gesturing at:
An inertial schema is the worked case. Its five fields, read off the schema as
it stood on 2026-09-02, are Vector3 acceleration, Vector3 angular_velocity,
Quaternion rotation, Vector3 magnetic_field and string frame. The schema
is not in this repository, so the grep that produced that list cannot be run
from this checkout.
Every measurement in an inertial sample is inside a nested message. Tier two
gives an ImuSample zero columns. A GazeSample gets three — tracked,
screen_point_valid, confidence — and none of the gaze.
So the question 0004 §1 leaves is not "generic tier or readers." It is: what does the generic tier have to do so that a converter with no vocabulary produces a file worth having, for every schema — including the ones whose data is a block of bytes?
Decision
An export carries what the schema declares, in the units it declares, under names derived from its structure. Everything a reader adds on top of that is either a declaration the schema should have made, or an analysis that does not belong in an export.
Taking the four kinds in turn, then the table that covers every field kind.
1. Naming — flatten by path, and let the name be overridden
A nested message is walked, and every numeric scalar leaf becomes a column
named by its path with the separator replaced: acceleration.x →
acceleration_x, left.pupil_diameter_mm → left_pupil_diameter_mm. The
unit is the leaf's own annotation.
The objection tier two raised — that a name chosen by the converter makes the header depend on the converter — is answered by making the name a default rather than a decision. A column's name is resolved in order: a name the user set, then the path. The first of those is the column-naming feature this work exists to build; the second is mechanical, derivable by anyone from the descriptor, and the same in every language that implements this rule.
A name the user set lives in the recording, in §13.3's Metadata record,
never in a file beside it — 0004 §1's rule. Setting one is therefore a
rewrite of the container, through ca3 convert, and the next conversion of
that file, by anyone, anywhere, resolves the same name with nothing else in
hand.
2. Validity — honour validity_for, and name the gap it leaves
A field named by a validity_for annotation is written empty — not zero —
when its validity field is false. GazeSample.screen_point_valid gates
screen_point; neuro.v1's valid gates microvolts. That is §5.6 of the
specification, applied by a converter for the first time.
It does not cover EyeSample.tracked or GazeSample.tracked, and the
schema says why in its own comment: the option "names one field; this flag
covers six … What is missing is a message-scoped form of the option."
The gaze schema carries one annotated field — screen_point_valid — and three
comments explaining why the others are not annotated. It is not in this
repository either.
So an export of a gaze stream under this record writes left_tracked as a
column and left_origin_x beside it ungated, where the TypeScript reader
nulls the position. That is a regression against the application and this
record says so rather than hiding it. The fix is not a reader: it is a
message-scoped form of the validity option in the grammar, after which the gaze
schema annotates tracked, and every converter in every language gates
correctly with no code change. Until then the provenance document written beside every export
states, per stream, which columns are gated and which carry a validity column
the reader must apply.
3. Presentation — none
storage_free_bytes exports as bytes, unit By, because that is what the
schema declares. Dividing by 10⁹ is what a screen does for a person, and a
file that did it would carry a unit the schema never stated. The applications
keep the scaling for display.
4. Analysis — none; blocks — always
Audio RMS and peak are computed from decoded PCM and are not in the sample. A figure derived from a block is an analysis, and the converter is not an analysis tool.
The block itself is data, and it is never dropped. This is the condition
attached at acceptance. An AudioFrame.samples field is 320 signed 16-bit
numbers, and a converter that wrote the sample rate beside it and left the
numbers out would have exported the label and not the recording. Three routes
exist, in order of preference, and which one a stream took is written into the
sidecar:
- A declared
tensor_form— extension 50010, declared inproto/ca3/options/v1/— states the element type, byte order, axes and components, and the block is decoded exactly asbindings/python/src/ca3/tensor.pydecodes it.AudioFramedeliberately declares none, because its element type "is whichever of two valuesformatcarries" and a form cannot name a field as its element type's source. - An element type the user states once, and the file keeps. A block
the schema does not describe is a gap in the file, and 0004 §1 says a
gap is closed by writing into the file:
ca3 metarecords the statement —av.v1.AudioFrame.samples = int16le— in theMetadatarecord, and every later conversion decodes the block under it with nothing else in hand. The provenance document beside each export repeats it as a statement a person made rather than one the schema did. A--blockflag onca3 exportmay state it for one run; it does not persist unless written in. - Externalised, undecoded, when neither of the above applies: the bytes
go to a sidecar
<stream>.blocks.binin exactly the length-prefixed layoutca3 rawalready writes, and the row carriessamples_offsetandsamples_leninto it. The data is in the export; what it means is deferred to the reader, and the sidecar says so.
Repeated fields are the same case, one level down: a column holds one number per row and a repeated field holds however many the producer wrote. §5's table says what each shape does with one.
5. Every field kind, and its representation
The condition on acceptance is that this table is complete. A field kind absent from it is a bug in this record, not a case the converter may skip.
| Declared kind | Long (row per value) | Wide / per-stream (column per value) | Reported in the export's provenance document |
|---|---|---|---|
| numeric scalar | one row, value | one column, unit in the name | — |
bool | one row, value 0/1 | one column, 0/1 | — |
enum | one row, text = the enum's name | one text column, the name | the number→name map, so a reader can invert it |
string | one row, text | one text column | — |
| nested message | walked; each leaf per its own kind, named by path | same | — |
oneof | the set member per its kind; the others absent | same, unset members empty | — |
repeated, fixed cardinality = N | one row per element, index column | N columns, name_0 … name_{N-1} | — |
| repeated, variable | one row per element, index column | not representable; column omitted | the field, and that the long shape carries it |
map<K, V> | one row per entry, key column, value per its kind | not representable; column omitted | the field, and that the long shape carries it |
bytes with a tensor_form | one row per element, one index column per axis | one column per component × fixed axis length; variable axes as the repeated case | the form's identifier |
bytes, element type stated at export | as above, under the stated type | as above | the user's statement, verbatim |
bytes, neither | externalised — _offset, _len columns into <stream>.blocks.bin | same | the sidecar file's name and layout |
Two consequences of the table are stated rather than left to be noticed.
A wide export can be lossy where a long one is not, and the sidecar names
every column it omitted so the loss is visible in the output rather than in
the absence of a column nobody expected. bool exports as 0/1, which
is what the TypeScript readers write and what lets a validity channel sit in a
numeric column; a reader wanting true/false maps one to the other, and a
reader wanting a mean of a validity channel does not have to.
What the applications keep
Their tier-one readers, for display. The dashboard gates gaze on tracked
and shows storage in gigabytes; the export writes what the file holds. Those
are two jobs, not two implementations of one, and the difference between them
is stated in the sidecar every export writes.
What they lose is csv.ts, export.ts and the export half of their reader
tables — the fork 0004 exists to end.
Alternatives rejected
A reader hook across the ABI. The caller registers a function per schema
and the converter calls it per sample. Rejected because it is the pipe again:
one boundary crossing per message, which is the cost backend.py measured the
subprocess paying, moved rather than removed. It also makes the file's contents
depend on which binding produced it.
Readers in Rust, beside the vocabulary, with a binary there that links
both. Rejected because it produces two ca3 exports — one that knows no
vocabulary and one that does — and the one a user finds first is the lesser. It
also puts the C ABI where the container is not, or duplicates it, which 0004
rules out.
Keep the TypeScript export. Rejected; it is the fork.
Skip a block the schema does not describe. This was the first draft of §4 and was rejected at acceptance: an export that carries an audio frame's sample rate and not its samples has exported the label and not the recording.
What would change this
- The grammar gaining a message-scoped validity option does not change
this record; it closes the gap §2 names, and the sentence about
trackedis deleted. - The grammar gaining a form that can source its element type from a
field —
§15.3's own reopening condition for
AudioFrame— retires §4's second and third routes for audio. Both stay for any schema that has not declared one. - A consumer who needs a derived figure in the export — RMS in the CSV, not beside it — is asking for an analysis stage between the reader and the writer. That is a different tool, or a different record, and this one should not grow it.
- A field kind appearing that §5 does not name is a defect in this record and is fixed here, in place.
What is open
- The message-scoped validity option, and a
descriptionoption beside it for the column-tooltip work. Both are grammar changes, both allocate a permanent extension number, and both are proposed rather than accepted. Nothing in the grammar changes until they are. - The exact syntax of the element-type statement, and the set of element
types it accepts.
TensorElementTypein the grammar is the obvious list; whether it takes those names or C-style ones is a usability question, not a design one. - The
Metadatarecord's own keys — where a user-set column name, a description, a node description and a stated element type each go, and how a rewrite composes with §11.3's attestation andattest.rs. That is record 0006, and it is the one this record cannot be built without.