Skip to main content

Module stream

Module stream 

Source
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 3 emitted three correctly projected records and then exit 2 on the fourth line. The summary carries namespace, not name, 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 export did the mirror of that in SILENCE, exit 0: the key resolved, so the summary was projected down to {"namespace":…} and lost summary: 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_surface record — measured over 200 lines — restating one fact about the PROCESS once per memory, absolute database path included. At the default --limit 100000 that is ~27.8 MB, written into the file docs/AGENTS.md recommends creating with export > backup.ndjson.

§The contract

  • A record line carries the record and nothing else. No agent_surface, no truncated. This is the invariant super’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. --select and --truncate-content are stateless per record and mean the same thing whether a record arrives alone or in a stream. crate::output::stream named 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 by super::gate::evaluate_stream BEFORE 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§

StreamState
What one stream resolved before its first line, and what it did after.

Constants§

SAMPLE_RECORDS
How many records open needs 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.