Expand description
GAP-SG-215: the NDJSON stream contract, decided.
super is defined over one complete envelope. export and ingest emit
something else — N self-contained records followed by a summary — and until
v1.2.8 they reached that envelope machinery once per LINE, through
crate::output::emit_json_compact. Three defects followed, all measured:
--select name export --limit 3emitted three correctly projected records and thenexit 2on the fourth line. The summary carriesnamespace, notname, so the projection that resolved for every record failed on the one line that is not a record — after stdout had already been written to.--select namespace exportdid the mirror of that in SILENCE,exit 0: the key resolved, so the summary was projected down to{"namespace":…}and lostsummary: true, the only end-of-stream signal a consumer has. A truncated export then looks exactly like a complete one.- With NO knob at all, every line carried a 278-byte
agent_surfacerecord — measured over 200 lines — restating one fact about the PROCESS once per memory, absolute database path included. At the default--limit 100000that is ~27.8 MB, written into the filedocs/AGENTS.mdrecommends creating withexport > backup.ndjson.
§The contract
- A record line carries the record and nothing else. No
agent_surface, notruncated. This is the invariantsuper’s module docs have declared since GAP-SG-142 — “NDJSON streams bypass the surface” — restored to being true. The NDJSON specification is explicit that the format carries no per-line header, metadata or schema; a stream is data, and the frame around it belongs somewhere else. - Only per-record knobs act.
--selectand--truncate-contentare stateless per record and mean the same thing whether a record arrives alone or in a stream.crate::output::streamnamed exactly that pair as the safe extension and asked for a contract decision before wiring it; this module is that decision. Everything else is refused bysuper::gate::evaluate_streamBEFORE the first byte, so a refusal never leaves a half-written stream. - The trailer is never shaped and carries the one record. The summary line is already about the stream rather than about a memory, so the resolved target, the query ceiling and the projection findings ride there — once.
The published schemas allow all three: docs/schemas/export-memory-line and
export-summary both declare agent_surface OPTIONAL, so dropping it from
the record and keeping it on the summary breaks no contract. What the old
behaviour did break was export-summary’s required list, every time a
projection deleted summary, exported or elapsed_ms.
§Why the state is a cell and the decisions are not
One process runs one subcommand and emits one stream, so a process-wide cell
is the single fact about that stream rather than ambient state — the same
reasoning super::universe documents. But GAP-SG-201 shipped a refusal no
test could reach precisely because the DECISION read the cell from inside
itself. So every function here that decides anything takes its premises as
arguments, and the cell is read at exactly one place: the emitters in
crate::output::stream.
Structs§
- Stream
State - What one stream resolved before its first line, and what it did after.
Constants§
- SAMPLE_
RECORDS - How many records
openneeds to see to resolve a projection.
Functions§
- get
- The open stream, or an inert one when the command never opened it.
- open
- Opens the process’s stream. First call wins.
- open_
with - Resolves a stream’s request against its records, before anything is emitted.
- shape_
record_ with - Applies the per-record knobs to one line. Never annotates it.
- trailer_
with - Annotates the trailer with the one record for the whole stream.