ADR-0016: JSONL is the interchange, not a store¶
Status¶
Accepted. Amends the JSONL row of ADR-0006.
Date¶
2026-09-03
Context¶
ADR-0006 stages the stores and names the failure each one answers. Its JSONL row reads: arrives when appending one record means rewriting the file.
That break never came. JsonCatalogueStore writes one file per witness. The
1907 witness arrived in v0.3.0 and rewrote nothing of the 1909 file. Two
witnesses are two snapshots, and nobody appends inside a witness. Within a
witness the whole file is rewritten, and that is a cost, not a break. The
same release paid two other costs. The source id rename rewrote every 1909
record to change one field. saucier diff loads two whole files to compare
anything. Neither is an append, and JSONL answers neither. The first is
history, and the second is a query.
The break the JSON snapshot does show is a different one. The two files are
complete object snapshots, and the only way to consume one preparation is to
learn the nested Catalogue document first. A SQLite loader, an analytical
scan, or a graph projection has to reproduce the Python object model first.
So does a second implementation in another language.
The project has one reusable boundary, and it is a set of dataclasses.
Every later store on the ladder is a projection of the same records. The ladder needs a representation those projections can share and that none of them owns.
Decision¶
A serialization and a database are different decisions. JSONL is the interchange. It carries catalogues out of this process and into any other, one record per line, and it claims nothing else. It does not index, it does not mutate, it does not keep history, and it does not walk a graph. SQLite earns those jobs when a named operation needs one of them.
The JSON snapshot stays the working store. JsonCatalogueStore still
backs parse, show, tree, and diff. The interchange is a second
representation beside it, not a replacement. A v0.4.0 data/ directory
works unchanged.
Two commands prove the round trip. saucier export writes every stored
catalogue to standard output as JSONL and writes nothing else there. saucier
import --check reads JSONL from standard input, rebuilds every catalogue in
memory, and prints the census. The flag is mandatory in this release, so the
verb cannot imply a write the command does not perform. A stream that
carries no catalogue fails the check. Without pipefail a pipeline returns
the last command's status, and an empty stream is what a failed export
leaves behind.
The codec is a driven adapter behind a port of its own. CatalogueStore
describes a place. The interchange is a representation, so
CatalogueInterchange describes it separately, with encode and decode.
The domain does not change. JSON syntax stays outside the hexagon.
The record¶
Every record carries an envelope of three fields: schema, type, and id.
The schema is saucier/1. There are two record types.
A catalogue record carries the catalogue apart from its preparations. That
is the witness fields, which are the work, the edition as the front matter
states it, the origin, and the fidelity. Then the sorted mothers, how many
preparations follow, and entries_read. Its id is the catalogue's source
id. The count is what lets the reader refuse a stream cut at a line
boundary, because such a stream is otherwise complete JSON.
{"schema":"saucier/1","type":"catalogue","id":"escoffier-1909","work":"escoffier","edition":{"statement":"New and Revised Edition, January 1909","stated_year":1909,"impression":"January 1920","copyright_year":1907},"origin":"Project Gutenberg 71395","fidelity":"transcription","mothers":["bechamel","espagnole","hollandaise","tomato","veloute"],"preparations":151,"entries_read":2963}
A preparation record names its catalogue and carries the title, the terms with their language tags, the concept, the parent, the reference, and the body. Its id is the catalogue id and the heading line, joined.
{"schema":"saucier/1","type":"preparation","id":"escoffier-1909:line:38713","catalogue":"escoffier-1909","title":"STRAWBERRY SAUCE","terms":[{"surface":"STRAWBERRY SAUCE","language":"en","concept":"strawberry-sauce"}],"concept":"strawberry-sauce","parent":null,"ref":{"entry":2417,"line":38713,"fidelity":"transcription"},"body":"Proceed as for No. 2416."}
Six rules govern the record.
An id is a source-local address, never a resolution. A preparation id derives from the catalogue id and the heading line. It says where a reader can check the claim. It does not say that two catalogues hold the same sauce. The entry number is not identity, because the scan repeats numbers. The domain does not forbid two preparations on one line, so the writer refuses such a catalogue rather than emit a stream its reader rejects. Separating record, entity, mention, and claim identity is later work, and none of it arrives here.
A catalogue id is a source id, not a witness id. Witness.source_id is
the work and the edition year. A second scan or a second transcription of
one edition would share it. Their preparation ids would collide wherever a
heading line coincides. The record carries the witness
fields, and that does not make its id a witness id. So version one carries
one catalogue per source id, and the reader rejects a second as a
duplicate. Collating two texts of one printing needs that separation of
identities and a later schema version. This record makes no claim that the
current identifiers support it.
A null parent means unresolved. It never means the preparation has no
parent. ADR-0002 governs the interchange as it governs the domain.
Surface forms survive as written. The writer emits UTF-8 with no ASCII
escaping, so VELOUTÉ is seven characters on disk. Every term keeps its
language tag. The two commands set UTF-8 on their own streams rather than
trusting the locale, because the stream is UTF-8 by contract.
Derived fields are written for the reader and verified on the way back. A catalogue record carries its id, and a preparation record carries its concept. Both derive from other fields. The reader recomputes each one and rejects a record where the two disagree.
Identical catalogues produce identical bytes. The writer emits catalogue records first, in the order given, then each catalogue's preparations in source order. Keys are emitted in one fixed order, with no whitespace, and every line ends in one newline. No timestamp appears, because a timestamp changes the bytes without adding evidence.
The reader¶
The reader consumes one line at a time, as UTF-8, and never reads the stream whole. It rejects, with the line number, any of the following:
- a line that is not UTF-8, or not one JSON object,
- an object that repeats a key, because
jsonwould keep the last value and say nothing, - a catalogue that receives a different number of preparations than its record states,
- a schema other than
saucier/1, - a type other than
catalogueorpreparation, - a missing or blank id, or an id seen on an earlier line,
- a field the schema does not name, or a named field that is absent,
- a value of the wrong type, including a blank parent,
- a derived field that disagrees with what it derives from,
- a preparation whose catalogue the stream never carries,
- a preparation that cites another fidelity than its catalogue,
- any rebuilt catalogue the domain refuses.
Records may arrive in any order, and a preparation may precede the
catalogue record it names. The reader rebuilds catalogues in the order
their catalogue records arrived, and each catalogue's preparations in the
order their records arrived. The writer emits them in the catalogue's own
order, so what was written reads back equal. The reader does not sort by
line, because the domain admits a catalogue whose lines do not ascend, and
sorting would rebuild a different one. Two complete exports cannot be joined,
because saucier export writes every configured catalogue and the second
copy repeats every id. The reader rejects the first repeat. The reader
holds the preparations it has read until the stream ends. A catalogue is
validated whole, and the domain holds every preparation in one tuple. What
streams is the text. A consumer that wants one preparation at a time reads
records and does not rebuild the catalogue.
What version one stops before¶
No claim records. Preparation.parent is one field, and it stays one field
until a later record changes the domain. No entity, activity, evidence, or
agent records, which are later work. No JSON-LD keywords. The ids are
stable strings and the records are typed, so a JSON-LD context can be laid
over them later without rewriting a byte. No difference rows, because the
domain does not own them. No opaque identifiers, because every record already
has an address a reader can open.
Consequences¶
Positive¶
- One command emits a stream that a shell, DuckDB, or a loader in another language reads with no import of this package.
- The reader is strict, so a hand-edited stream that lies about a concept or a catalogue is reported at its line rather than loaded. So is one that repeats a key, or one that was cut after its catalogue records.
- An empty stream fails
import --check, so a pipeline whose export failed cannot end in a zero. - A later SQLite projection consumes this stream and never imports extraction. Delete the database, replay the stream, and obtain the same answers.
- The JSON snapshot is untouched, so no published tag is stranded.
Negative¶
- ADR-0006's JSONL row predicted a break that never arrived, and this record says so. The table stays as written, with a note pointing here.
- The same preparation is rendered by two adapters in two shapes. The two formats have two jobs and are expected to diverge, but for now each renders the witness and the edition on its own.
- The reader checks the form of a parent and not its referent. A parent
that names nothing in its catalogue is carried as written. The domain
permits it, and the reader adds no rule the domain does not hold.
treewill never show such a preparation under a root. exportreads the corpus as well as the store. The configured witnesses supply the ids, and each witness reads its id from its front matter. A store that could list what it holds is a later port change.- A catalogue id names an edition, not a text of it. Two scans of one printing would collide on every id, so this schema carries one catalogue per source id. Lifting that limit needs that separation of identities and a new schema version, and this release does not claim to support collation.
- Two complete exports cannot be joined. Each carries every configured catalogue, and an export that selects one is a later feature.
- Rebuilding a catalogue holds every preparation of that catalogue in memory. That is the domain's shape, not a limit of the format, and this record does not hide it behind the word stream.
importperforms no import. The mandatory flag makes that visible at the cost of a verb that promises more than the command does.- A field added to the schema needs a new schema version, because the reader rejects unknown fields. The alternative is a reader that silently ignores what it does not understand, and this project does not guess.