Structured records - typed pipes (PowerShell/nushell-style)
Status: Built + QEMU-verified (
osdev test shell,osdev test files): theTablemodel, eleven shell-side record producers (wifi listthe eleventh;wifi hardware,hardware's tables,askand the reports ofgodspeedhave joined since) plus therosterservice,where/select/sort,to json/to yaml/ the grid,from json, the binary wire codec, and the unified byte-or-record pipeline. What is NOT built is listed under What's built vs next. Non-normative - does not amendCLAUDE.md.
Why
POSIX pipes carry text. So dir flattens its structured data - name, size, type, date - to
a formatted string, and then grep/awk/cut/sed exist to re-parse that string and claw
the structure back out by counting columns. The whole awk/sed/cut zoo is compensation for
a lossy serialize-to-text step.
PowerShell's insight was to not throw the structure away - pipes carry objects, and you filter
on real fields (Where-Object { $_.Length -gt 1MB }). GodspeedOS does the same idea, but shaped
by invariant 2 (no shared mutable memory): a live object cannot cross a service boundary by
reference, so the structured value must be a thing that can also be serialized. The model here
is therefore a typed value, with text/JSON as renderings of it - never the model itself.
(Closest prior art: nushell - Rust, structured pipelines, table-by-default, to json.)
The three representations
- In-memory model - a Rust value, the
Table(typed columns + rows). The "language between utilities", and it is reached through the standard library (gs::record) so any service can build, filter, and render records - not just the shell. Between same-address-space stages it is passed by value, never serialized. - Wire codec - a compact, bounded binary encoding, used when a record crosses a service
boundary as records. Built (
Table::encode/decodein the SDK): magic + dims + column names + tagged cells, all bounded by the table's own limits, loud on a truncated/oversized buffer. It is emphatically not JSON - it is theTableitself on the wire. A record service (examples/roster) emits this; the shell decodes it straight into aTablewith nofrom json. (JSON is still available for human/interop output viato json, andfrom jsonfor parsing external JSON text.) - Edge renderers -
to json,to yaml, the default aligned grid (all built, in the SDK). These live at the edge: terminal output, or export for interop. A record never is JSON; JSON is one way to print it.
The model - a bounded Table
The canonical value is a table: static column names + rows of typed Value
(Str interned in a byte arena, Int unsigned, Signed, Empty). Signed arrived on 2026-10-02 for
wifi list's dbm, a reading that is always below zero: the magnitude would be a different number and a
string sorts by bytes. It is its own variant rather than a change to Int, so every count and size kept
its type; sort, where and the reducers compare the two kinds numerically, JSON writes it as a number,
and the wire codec carries it as cell tag 3 (Empty 0, Int 1, Str 2). from json now reads a
negative integer as Signed - it used to arrive as null. Most introspection output is naturally tabular
(status, dir, find, caps are all uniform rows), so a table covers the realistic cases and
is simpler than arbitrary records. It is bounded (§26.6): REC_MAX_COLS, REC_MAX_ROWS, a
fixed REC_ARENA - all on the stack, no heap, loud on overflow. Heterogeneous (differently
shaped) records are a future generalization.
The grammar (compact, not English)
Structured pipes don't want match (substring on a line) - they want field operations. The
grammar is deliberately terse and code-like, not an English sentence:
- Predicates are one token, operator attached:
where mem>0,where state=BlockRecv,where name!=shell,where core>=1. The parser finds the operator inside the token (longest match -!=/>=/<=before=/>/<); before it is the column, after is the value. Ops:=!=><>=<=~(contains). Numeric when both sides are numbers, else textual. No quotes needed unless the value has a space (then the usual minimal quoting:where "name=block driver"). Built. - Column ops are verb + columns:
select name core(keep those columns, in order),sort mem/sort reverse name(order rows by a column - "by" dropped, same reason the spaces are;reverseleads, likematch except, so the column lands at the end and nothing dangles). Built. - Conversions are short directional words -
to <fmt>/from <fmt>. The direction is named because you need both:torenders the model → text,fromparses text → model. A bare| jsoncouldn't express "parse incoming json" without the word pointing two ways (the implicit magic §26.5 forbids).to json,to yamlandfrom jsonare built.
The text filters (match/count/sort/first/last) stay - for genuinely-text streams like
a file's contents. A pipeline is routed to the record path when its first stage is a record
producer (is_record_producer - status, dir, caps, drives, find, uptime, events,
trace, jobs, ask, godspeed's reports, observe now - and wifi list / wifi hardware and the table forms of
hardware, routed by name beside it in pipe_run), else
the byte path. They coexist; the default rendering (no to) is the table grid. A text
filter applied to a record stream (e.g. dir | match foo) is a loud, guided error - use
where/select/sort <col>, or to json to drop back to text first.
A live, screen-owning loop cannot be a pipe stage. observe (bare) is the continuous
full-screen view - it owns the console and never yields a discrete stream, so piping it would
block the shell on a recv that never comes (the stage-1 hazard docs/pipes.md guards). Only
its one-shot snapshot, observe now, is pipeable; bare observe | … is a loud refusal, not
a hang. This is the general rule for any future live-loop utility: the snapshot form is the
record producer, the live form is not.
status the default table
status | where state=BlockRecv only blocked tasks
status | where mem>0 | select name mem filter, then keep two columns
status | sort reverse mem | to json ordered desc, as JSON
status | where name=shell | to yaml one task, as YAML
A service producing records - the roster example
examples/roster is the executable proof that records are not shell-only: it builds a Table
with the SDK, encodes it with the binary wire codec, and emits the bytes through the
shell-delegated pipe cap (zero ambient authority, like greet). The shell knows roster is a
record service, so it decodes the stream straight into a Table - no from json:
seat is an illustrative Int column (a desk number, not a CPU core - roster is a fixed
demo dataset, the same 4 rows on any machine; real core counts live in cores/status):
gsh> roster | sort reverse seat
name role seat
John worker 4
Luke courier 3
Mark worker 2
Matthew core 1
gsh> roster | where role=core | to json
[
{"name": "Matthew", "role": "core", "seat": 1}
]
gsh> roster | select name seat
name seat
Matthew 1
Mark 2
Luke 3
John 4
This is the whole point of the SDK record API: a third-party service emits typed data as
records, and the verbs (where/select/sort/to) operate on it directly - no text, no
re-parsing. (osdev test files pins it.)
from - the bridge from text into the typed world
read file.json gives text (bytes); where/select need records. So from json
parses text → the table model: read svc.json | from json | where core=1. read deliberately
does not auto-parse by extension - that would be hidden behavior (§26.5). from is the
explicit bridge, and the symmetric partner of to:
read a.json | from json | to yaml | write a.yaml convert json → yaml
read svc.json | from json | where mem>0 external data, filtered
This crosses the byte ↔ record boundary, and that unification is built: one pipeline
threads a Stream that is either Bytes (a text buffer) or a Table (records), and each
stage is dispatched by its command and by which it currently holds - so sort is a
line-sort on Bytes and a column-sort on a Table, and from/to flip between the two. A
mismatch (e.g. where on bytes, match on a table) is a loud error pointing at the fix. from json parses a flat [ {…}, … ] array (string/number/true|false/null, no nesting; the
first object defines the columns). ini could plug into the same to/from pair later, but it
suits a single record or key-value view, not a many-row table - json/yaml are the table-native
pair.
What's built vs next
- Built: the
Tablemodel (owned column names + arena);to_grid(default, full string cells - no clipping),to_json,to_yaml; the compactwhere,select,sort [reverse] <col>; the shell-side record producers -status(task roster),dir(name/type/size/sealed),caps(resource/rights),drives(index/label/status/size_mib/free_mib),find(name/type/path),uptime,events,trace,jobs,wifi list(network/band/signal/dbm/security/note), andobserve now(the roster + atickscumulative-cpu-time column - the native "top",observe now | sort reverse ticks);from json(text → records); and the unified byte↔record pipeline (Stream = Bytes | Table, dispatched by command + data type,from/tobridging). All in-process (no wire codec), QEMU-verified incl. a json → records → yaml → file round-trip anddir | where type=file | sort reverse size. - Also built: the SDK record API - the
Tablemodel,where/select/sort,to_json/to_yaml/to_grid(over aRecordSink),from_json, and the binary wire codec (encode/decode) now live ings::record, so any service can produce records, render them, or put them on the wire as records (sdk/rust/CLAUDE.md).examples/rosteris a record service: itencodes aTable, the shelldecodes it -roster | where role=core, nofrom json. - Next: a JSON string-escaper (values are plain ASCII today);
from yaml; record streams larger than one IPC message (chunkedencode+ the streaming-pipe work); eventually heterogeneous records and richer interop.
Discipline (so it doesn't rot into PowerShell-magic)
Keep the typing and serialization explicit (§26.5): no implicit property coercion, no
automatic ToString(). Keep everything bounded (§26.6): fixed cols/rows/arena, loud on
overflow. This is a subsystem, pulled into existence one producer at a time (§26.2), not a
speculative framework. The payoff is deleting a whole category of cryptic tools: keep the data
typed and most of grep/awk/sed/cut never needs to be born - replaced by
where/select/sort, which read on sight.