Not a port of GNU/BSD coreutils. Same commands, but re-expressed in the fixpoint style: Datalog queries, DAFSA automata, determinism, and timeline time-travel — so every command is legible, derivable, and reproducible by construction.
Guiding thesis (the manifesto): understandability is the goal, determinism is the proof. A command should be able to show its own derivation. "That's the whole thing."
fx-init's probe loop already materializes system state into volatile relations
(file, process, fs, net, ...). Commands become thin views over a
relation layer instead of imperative walks.
| command | fixpoint form |
|---|---|
find |
flagship — recursive descent = transitive-closure rule over dir(parent,child); the least-fixed-point computation (where the org name is earned) |
ls / tree |
order-by-size/mtime free via column-ordered fixed-width keys (name lex order is a Zig sort — sym ids are insertion-ordered) + free recursion |
du / df |
sum aggregate on file.size; fs relation |
grep / fx log |
DAFSA regex-WALK — automaton product-construction over interned content; reuses the M4 log-DB search engine |
ps / top |
process relation, joinable to store command relation (declared-vs-actual) |
sort / uniq / wc |
algebraic on the relations layer ("free" = integer-keyed relations → numeric order / -u interner dedup; lex string order is not a public API — a Zig sort over interned contents; wc = aggregate) |
fx-find does not query fx-init's probe relations — the probe loop is
low-frequency system-health state (file, process, fs, ...) and is stale /
stat-level / not a full recursive tree. Wrong fidelity for find.
Instead, find walks the live FS once and streams entries into a transient
datalog-dafsa DB:
entry(parent, name, path)
file(path, size, mode, uid, gid, mtime)
Then the recursive descent — the actual "find" logic — is a Datalog closure
over entry (least-fixed-point), with -name / -size / -maxdepth / -prune
as datalog conjunctions. DAFSA prefix-enum gives sorted output for free; filters
become joins. The win is that the recursion itself is expressed as Datalog, not
hand-rolled in C.
Meeting point with the probe: optionally join probe relations for enrichment
("is this file owned by an installed package" = declared-vs-actual), or fast-path
cache. A versioned FS index (materialized at activation / fx commit
boundaries, not continuously) becomes a relation that time-travel can as-of query
— "what did the tree look like at generation N" (Lens 2).
Implementation status (2026-08-24):
ls,du,sort,uniq,wcship as datalog-backed coreutils (transient datalog-dafsa DB, Dhall-typed records, exactly the fx-find pattern), andcat/head/tailship as pure typed deterministic binaries per the honest cut (Lens 3 signatures below). Per command:
ls= stat relations; order-by-size/mtime free via column-ordered fixed-width keys (name order is a Zig sort — sym ids are insertion-ordered).du= two-stratum program (reach/under transitive closure + contrib/du groupedsum) — concept.md'ssum-on-file.sizerow, realized.sort= numeric order free via u32 keys; lex = a Zig sort over interned contents;-u= interner set dedup.uniq= interner as the identity oracle + optional groupedcount.wc=count/sumaggregates over per-line facts.cat/head/tail= clean typed binaries that compose as content-addressable components (catbytes→bytes;head/taillines→lines).
Implementation status (l1views batch):
tree,df,ps,topship as Lens-1 views, and all four are registered Lens-3 stages (tree/df operand stages over their PATH arg, ps/top source generators). Per command:
tree= datalog reach closure (fx-find's rules verbatim) over a walk-sidenoderelation; glyph render + GNU footer;--rowsemits find's exact rows type, sotree |> grepcomposes with zero registry novelty.df= pure statvfs binary (the honest cut: datalog columns are raw u32 and would wrap on real >16TiB disks); mounts parse + dedup-keep-last + single-path form; rows{fs,mount,total_kb,used_kb,avail_kb}.ps= datalog flat-relation view over live/proc(the fx-ls idiom, no rules); best-effort snapshot (ENOENT mid-walk skips a died process).top= deterministic single-shot ranking — the honest cut vs GNU top's live TUI: no refresh, no cpu% (delta sampling is nondeterministic); ranks by total cpu ticks, same rows type as ps.ps/top/dfrows carry nopathfield, so the only rows consumer today (grep, readingpath) rejects them at type-check (MissingField) — they are new leaf row types awaiting their consumers, not silent grep no-ops. Replay over the live process set / mount list diverges loudly (the accepted find/ls/du live-operand honesty level).
Time-travel splits cleanly into two independent timelines that must not be conflated:
- The store/system timeline — fxstore's job (M5), already built: package
activation, config change, boot, roll-forward rollback. Uses the snapshot
machinery (
dl_publish_snapshot,dl_snapshot_versions,dl_query_version, roll-forward rollback,dl_cas_revision).rm/cp/mv/touch/mkdir/lnandhistory/diffdo not ride this — they are coreutils and operate on the FS layer below. - The per-tree FS journal — what
fx history(coreutil, removed) ran on. The journal/record/history code has been deleted from the codebase and is deprioritized "for now" (2026-08-23 pivot): this design is kept conceptually below, but is not implemented.fx diffno longer rides the journal — it is now a plain Dhall-typed diff coreutil (see build-order item 3).
Implementation status (2026-08-23):
fx-journal.zig,fx-record.zig, andfx-history.zighave been removed.fx diffwas rewritten as a standalone typed diff coreutil with no datalog/journal dependency: two files → a line-level unified diff (Myers O(ND) edit script →@@hunks with 3-line context); two directories → a recursive, sorted path-level listing (+pathadded /-pathremoved /!pathchanged by sha256 content hash). The journal design below remains the conceptual narrative for what a future timeline could be; it is no longer the shipped behavior.
SUPERSEDED 2026-08-24 by Option B below — the global content-addressed derivation log. This per-tree design is kept as design history; what shipped is a single global, content-addressed log (the shipped behavior of every mutation command).
Each tree you opt into gets its own datalog-dafsa db (a .fx journal file —
git-like: per-tree, small blast radius, per-tree retention). Mutations append a
small delta batch of facts; nothing ever mutates shared state:
op(ts, seq, cmd, cwd, args, target) # audit trail
del(path, hash, size, mode) # what vanished
add(path, hash, size, mode) # what appeared / replaced
The fixpoint trick: current state = replaying the journal from empty (a least-fixed-point over the delta stream). You never store a "current tree relation set" — you derive it by folding the deltas. That is event-sourcing as a relation stream: every point in a tree's life is reconstructible by replaying up to it.
fx history= query the journal:oprows, filterable bypath/target/cmd/ts. "Did rm readme.md" →op where cmd=rm. (design only — not built)fx diff --to A --to B= replay deltas to reconstructfile(path, hash, size)at A and B, then diff the two reconstructed sorted sets (linear merge). No snapshots involved. (this journal-basedfx diffis design only — the shippedfx diffis the plain typed coreutil in build-order item 3, not a journal query)
Why this is clean: no coupling to system state (a tree's history is only its
own mutations, never "gen 7 activated visage"); no dl_publish_snapshot
fixpoint cost per op (just appends); cheap mutations are first-class at full
granularity. The granularity-split resolution ("snapshot on meaning, journal
everything else") still holds for the store timeline; the FS layer needs no
snapshots at all — only the fold.
What shipped is a single global derivation log — one append-only fact stream for all mutations, backed by a content-addressed store (CAS) so the log answers not just what happened but can also restore the bytes that were lost.
State root — $FX_STATE_DIR (override, used by tests and smoke), else
$XDG_STATE_HOME, else $HOME/.local/state, then +/fx, i.e.
<state>/fx/log (the log) and <state>/fx/cas/<sha256hex> (the content store).
Entry schema (JSON Lines — one object per line, LF-terminated, appended
with a single atomic write() call):
{"seq":<u64>,"ts":<unix-seconds>,"cwd":"...","cmd":"fx-rm","args":{...},"fx":[<effect>,...]}.
args is the canonical record — term_to_json output when invoked in Dhall
form, synthesized canonical JSON otherwise. fx is the effects array: the
actual state changes in application order (never intended-but-failed).
Effect (one JSON object, fixed field order, null when N/A):
{"op":"write|unlink|rmdir|mkdir|rename|touch|link|symlink","path":"...","kind":"file|dir|symlink","in":"sha256:...|null","out":"sha256:...|null","mode":<u32>,"size":<u64>,"from":"...|null","target":"...|null","mtime_s":...,"mtime_ns":...,"created":bool}.
in is the prior bytes' content hash (the CAS in-hash — what undo restores);
out is the post-state content hash (verification-only, not stored in CAS).
CAS — <state>/fx/cas/<sha256hex>: one bare file per hash, raw bytes.
casPut writes via tmp+rename (atomic; idempotent by content addressing — same
bytes → same file, dedup'd). Only bytes that would otherwise be lost are
interned: prior DST on cp-overwrite / mv-replace, and every removed regular file
on rm -r. Hashing reuses the dhall-c sha256 module; hashes are logged as
sha256:<hex> (Lens 3 integrity form).
Crash-order invariant (the fxstore store.c discipline, restated for this
layer): (1) capture the bytes that will be destroyed into CAS, (2) mutate
(the syscalls), (3) append the log entry. Crash after (1) → an orphan CAS
blob (harmless — dedup'd / GC-able); crash after (2) → a mutation that was never
logged (GNU-equivalent behavior, documented); never a log entry whose
in-hash is missing from CAS. A partial mutation failure mid-walk (rm -r dies
at file 3 of 5) logs the effects that actually happened and exits non-zero.
Undo rule (the core invariant, verbatim): effects are logged in application order; undo applies the inverse of each effect in strictly reversed order. The reverse of a valid mutation order is always a valid undo order (children unlink before parent rmdir ⇒ reversed: parent mkdir before child write).
No-op rule (verbatim): idempotent no-ops (rm on missing, mkdir existing,
cp same-content, mv src==dst, ln same-relation) produce zero effects and
therefore NO log entry — the log is the actual derivation history, not an
audit of invocations. touch on an existing file always logs (mtime
intentionally moves — its fixpoint is content-level).
Shipped / follow-up: all 7 mutators (fx-cp/fx-mv/fx-rm/fx-mkdir/
fx-rmdir/fx-touch/fx-ln) append entries and intern lost bytes; fx-log
reads the log back (seq, ts, cmd, args-summary, effect count). fx-undo
(inverse application + divergence gate + CAS restore) and cp -r are the
designed follow-ups; the schema is already undo-ready.
Every command takes the typed Dhall record form or a GNU-style POSIX fallback; every actual mutation appends a derivation entry (zero effects ⇒ no entry, per the no-op rule). Each row lists the no-op condition (the inputs on which the command converges to a no-op) and the GNU divergence where the fixpoint behavior deliberately differs from GNU coreutils.
| command | Dhall schema | POSIX | no-op condition | GNU divergence |
|---|---|---|---|---|
fx-mkdir |
{path : Text, parents : Optional Bool} (default True) |
fx-mkdir [-p] DIR... |
existing dir (both forms) | GNU errors without -p; fixpoint equals GNU -p; no -m in v1 |
fx-rmdir |
{path : Text} |
fx-rmdir DIR... |
missing path | GNU errors on missing |
fx-touch |
{path : Text} |
fx-touch FILE... |
none (existing always logs) | mtime intentionally moves — its fixpoint is content-level; no -a/-m/-d/-t in v1 |
fx-ln |
{src : Text, dst : Text, symbolic : Optional Bool} (default False) |
fx-ln [-s] TARGET LINK_NAME |
same-relation (hard: same st_dev+st_ino; sym: same readlink target) |
GNU errors File exists on any existing dst; no -f in v1 |
fx-mv |
{src : Text, dst : Text} |
fx-mv SRC DST |
src missing; src == dst | GNU errors on missing src; EXDEV → clear error, no copy-fallback (GNU falls back) |
fx-cp |
{src : Text, dst : Text} |
fx-cp SRC DST |
src and dst content-equal (same-content) | missing src is an error (not a no-op); cp -r out of scope in v1 |
fx-rm |
{path : Text, recursive : Optional Bool} (default False) |
fx-rm [-r] PATH... |
missing path | GNU errors on missing; rm -r walks post-order and never follows symlinks |
The journal alone answers what happened (structural); recovering bytes is opt-in, per tree, in escalating depth:
- Structural (default). Journal records
path/hash/sizedeltas only — no bytes, no hot-path capture (honest cut: don't back 10GB files as facts). Change-detection only; "readme.md was deleted" answered, contents not restorable. - + CAS (opt-in). Intern content at line/chunk granularity into the DAFSA;
interned
add/delfacts reference u32 symbols instead of duplicating bytes; each symbol maps back to its bytes by content hash (blob = sha256(content)) for recovery. A file is a sequence of interned-line refs (a path through the line-DAFSA): shared lines across files dedupe once, and DAFSA structurally merges lines that share prefixes/suffixes. This is fx-grep's DAFSA-WALK mechanism, generalized into being the whole content history. - + Chunking (future refinement). Content-defined chunking (rolling hashes → stable chunk boundaries → chunks dedupe by hash) to capture shared middle blocks across near-identical files. Neither whole-file CAS nor DAFSA prefix/suffix sharing catches a shared middle block; this is git-style similarity dedupe.
DAFSA vs CAS — why both, and why they're different axes. DAFSA dedupes prefixes/suffixes of whole strings (a trie with common suffixes merged); it is a compressed set-acceptor that answers membership and supports prefix-walk, but is not addressable — it collapses the set and doesn't remember which path is which file, so you can't fetch "file N" out of it. CAS dedupes by whole-value equality and is an addressable store (O(1) content-hash fetch). They compose, they don't substitute: DAFSA shrinks what you index; CAS is how you get bytes back. And a shared middle chunk is caught by neither — that's layer 3's job.
Tagline (FS layer): the journal is the fold; DAFSA is the index; CAS is the recovery — the last two optional.
Lens 2's provenance commands: fx-what /bin/foo traces an installed file back
through its activation install relation to the store dir that provides it,
then through the dependency closure to the roots that pulled it in; fx-why is
the inverse (what does a package provide and depend on). The command shows its
derivation — the datalog proof tree / build closure. The manifesto made
executable. SHIPPING as store-relative provenance over the activation
facts (schema + full story in the provenance section below).
The deepest / most distinctive idea — not porting commands, but replacing the whole command-and-pipeline model.
- Every command's flags become a typed record, not ad-hoc
-xzvfsoup:fx ls {path="/", long=True, sort=BySize}. - Pipelines are typed compositions whose intermediates are content-addressed
(Dhall
sha256:import integrity) → a pipeline is a derivation.fx historycan replay any past pipeline's output exactly: determinism as proof. - Replace sh scripting with Dhall action-composition (the dhake model:
Dhall buildfile → typed action plan → execute). The shell becomes the
composition layer, not a string-quoting minefield. Closest analogues:
rh shell/nushell— differentiator = type system + content addressing + time travel. - Idempotence-as-guarantee (the etymology lens): a fixed point satisfies
f(x)=x. Commands like
mkdir -p,rmon missing,cpsame-content already want to be idempotent (dhake even does phony/idempotent targets). The fixpoint style makes convergence a guarantee: running a command on its own output is a no-op. Every mutation lands as an idempotent "re-assert this relation," which composes with roll-forward rollback. (Shipped in the mutators 2026-08-24 — the Option B no-op rule above makesmkdir -pon an existing dir,rmon missing, andcpsame-content converge to no-op with NO log entry; see the idempotence table.)
fxsh is the shell: every stage is parsed by its command's GENERATED parser and
the chain is typechecked by fx-pipeline.compose before anything runs. Its
semantic core is the operator, which IS the declaration of intent:
a | b RUN kernel pipes; streaming; nothing interned or recorded.
a |> b RECORD CAS-interns every intermediate — the line becomes a
REPLAYABLE DERIVATION (what fx-compose does).
Both modes typecheck with the SAME predicate: every pipeline of the chain is
checked (argv arity guards + adjacent shape compatibility, recursing into
( … ) groups) before anything runs, so the operator changes HOW a line
runs, never WHAT is accepted (ls | wc and ls |> wc are both rejected). A
line that runs is therefore also a line that would typecheck. This is a
TIGHTENING for run mode: ls | wc used to run and now rejects, exactly like
its |> spelling. Recording is a WHOLE-LINE property — you cannot hash an
intermediate that never materialised, so one |> puts the line in record mode.
Known provenance hole (deliberate). Redirects (<, >, >>, 2>, 2>>,
2>&1) are RAW: they write files without a derivation-log entry, so a file
created by fxsh -c 'seq 1 3 > f' has no fx-why record, unlike the same file
created by fx-cp. Within that, the fd kind is POSIX: 2> FILE truncates and
2>> FILE appends (the latter was silently truncating before and is now fixed
at every site). This keeps | honest — if a |-line sometimes mutated the
global effect log, it would not be "just run" any more. A redirect on a |>
line is therefore REJECTED loudly rather than guessing what a redirected
derivation means; the natural future landing spot is "a redirect inside a
record line becomes part of the derivation" (not implemented).
Control flow (U10). Above the pipeline there is a small line grammar:
line := pipeline (('&&' | '||') pipeline)*
pipeline := cmd (('|' | '|>') cmd)*
cmd := WORD+ | '(' line ')'
&& / || short-circuit on exit status; ( … ) groups a whole line and nests,
so ( a | b ) | c works and a group's status propagates to a following &&.
A redirect is per-pipeline (a 2> f && b redirects only the first).
Control flow is a run-mode construct: |> yields a derivation, not an exit
status, so nothing exists for && / || to branch on — a line mixing them with
|> is rejected loudly rather than inventing an answer.
The tree is FLATTENED before any fork (every child argv is built in the parent), so a subshell child only forks/dup2s/waits and never allocates — the same async-signal-safety rule the redirect path documents.
Variables and globs (fxsh U2/U3) are run-mode features: |-lines have
them, |>-lines reject them — a recorded derivation must not silently absorb
host state, or its hash would be incomplete.
RUN mode expands $VAR / ${VAR} / $? (undefined expands to EMPTY; a
literal $ is written \$, '$X' or "\$x"), takes whole-line VAR=value
assignments and export NAME (exported names reach child processes), and
globs *, ? and [...] against the working directory (matches SORTED,
null-glob OFF — no match leaves the word literal — and dotfiles stay hidden
unless the component starts with .). A redirect target is a shell word
too: its $ sites are expanded with the same table (F=real.txt then
cat < $F reads real.txt). Quoted or escaped metacharacters are literal:
echo '*' prints *, and a verbatim backslash is PRESERVED — echo "a\*b"
prints a\*b (POSIX/bash), and a quoted backslash before a metacharacter
never reads as a live glob on either operator ('a\*.txt' |> cat records
instead of falsely rejecting). A literal $ survives into a |> derivation
byte-identically, so replay equals run.
RECORD mode rejects all three kinds LOUDLY, at the byte offset of the first
offender: a $ expansion, an assignment, or a LIVE glob metacharacter on a
|> line is host state (shell variables, or the working directory's
contents) that the manifest does not carry — so echo '*' |> cat is fine
while echo * |> cat rejects. In v1 neither feature exists between | and
|>: RUN mode simply has them and RECORD mode refuses them.
Limitations (v1, honest gaps — each one rejects LOUDLY where it is a rejection):
- No
;statement separator: one line is one chain. VAR=valueandexport NAMEare WHOLE-LINE statements: a prefixX=1 cmdor a chain segmentX=1 && cmdis a loud error, never a silent prefix. (export NAMEwithout a value marks an already-set name exported;exportalone is just an unknown stage.)- No field splitting of expansions:
X='a b'makes$XONE argument. - No parameter operators (
${X:-y},${#X}, …), no$$, no$1, no$( … )command substitution — every unsupported$form is a loud error, never a silent pass-through. - An assignment/export statement cannot carry a redirect:
X=1 > fis a loud error (exit 2) rejected BEFORE the variable table is touched —fis not created and the redirect is never silently dropped. - A redirect inside a group
( … > f )is rejected. - A redirect on a
|>line is rejected (unchanged — see the provenance hole above).
A pipeline is a typed derivation. The unit of composition is the pipeline value: a materialized artifact carrying a Dhall type. There are four shapes:
Value = bytes raw byte stream (the honest-cut stream)
| lines newline-delimited text stream
| rows { <field> : <T>, ... } a stream of typed records
| single <T> one typed value (a count, a diff, a file)
Every command declares an input→output signature. This is the key new thing beyond today's single-Dhall-record args — today each command takes one record and produces text; fx-compose makes that a typed function between pipeline values:
fx find { path, name, type } : single { path : Text } -> rows { path : Text, kind : < File | Dir >, size : Natural, mtime : Natural }
fx grep { pattern } : rows { path : Text } -> lines (engine stage is a real DAFSA regex-WALK — `regex_compile` + `.*(...).*` wrap over each row's path, same engine/subset as the `fx-grep` binary; not a substring cut)
fx ls { path, long } : single { path : Text } -> rows { name : Text, size : Natural, mode : Natural }
fx cat : bytes -> bytes
fx head / fx tail : lines -> lines
fx sort / fx uniq : lines -> lines
fx wc : lines -> single { lines : Natural, words : Natural, bytes : Natural }
fx du { path } : single { path : Text } -> rows { path : Text, bytes : Natural }
Composition is type-checked, then content-addressed. a |> b is well-formed
iff out(a) structurally equals in(b) (Dhall type equality). Running a pipeline
materializes every intermediate to a content-addressed store
($HOME/.local/state/fx/cas/<sha256-of-bytes>), so a pipeline expression has a
canonical, replayable form — each stage referenced by its sha256: integrity.
Registry note (2026-08-24): the fx-pipeline
builtin()signature registry has been extended with the full 2026-08-24 batch —find,grep,lsas above, pluscatbytes→bytes;head/tail/sort/uniqlines→lines;wclines→single {lines,words,bytes};dusingle{path}→rows{path,bytes}.grep |> sort |> uniq |> wctype-checks;ls |> wc(rows vs lines) andcat |> sort(bytes vs lines) are rejected withShapeMismatch.Registry note (2026-08-24, engine deepening): the registry now also carries
nl/expandlines→lines,cksumbytes→single {sum : Natural, bytes : Natural}, andsha256sumbytes→single {hash : Text}— composing as typed stages like the rest.ls/duwere registry-present but display-text-only; they now take--rowsand emit their declared wire rows (canonical field order viafx-wire), and the engine dispatches them as operand stages —du |> grepruns end-to-end, not just type-checks. Checksum stages re-emit the typed single without the state-dir-dependent filename token (the same trap aswc).Registry note (2026-09, Lens-3 generators + two-file): the registry now also carries
echo/seqnone→lines(SOURCE stages — the.noneinput kind; valid at position 0 only) andpaste/commlines→lines(TWO-FILE stages — the stage args are the second file path, live-read at run AND replay, thefind/ls/dulive-operand caveat).sort |> paste |> wcandseq:3 |> paste:/tmp/b |> sorttype-check;paste |> find(lines vs rows) andsort |> seq(lines vs none) are rejected withShapeMismatch.
Single-schema command interfaces (2026-09, cmdif): each command's TWO arg forms — the typed Dhall record and the POSIX flags — are now derived from ONE source,
schemas/<name>.dhall(a{ ty, dflt, posix }record literal).fx-cli.zigis the shared schema evaluator (completion is(dflt // user) : ty, defaults LEFT-biased);src/tools/fx-clijson.ziggenerates a PURE-Zig POSIX parser (src/generated/cli_<name>.zig, imported by the command binary as its own module — no dhall at runtime, plan RISK 4) andzig build gen-cli-check(wired intotest) fails on a stale or hand-edited generated file.fx-lsis migrated (the STEP-2 template): its handparsePosixArgs/Options/SortTagare deleted, and the differential test infx-ls.zigproves the two arg forms equal over an argv matrix — each vector runscli_ls.parsePosix(argv)against the record form completed from the schema, rendered byrenderDhallRecord, and driven through the runtimeevalDhallArgs, both sides compared as canonical term_to_json bytes (field-complete by construction). The encoder and the differential runner are SHARED (fx-cli.encodeOptionsWire/fx-cli.expectPosixEqualsRecord) — a comptime-reflection encoder over the generatedOptionsstruct, so no hand-maintained field list can drift, and a migrated command's differential is a one-line wrapper supplying its generated parser module, schema path and record evaluator. The generated parser is deliberately stricter where the hand parser drifted:-S -tiserror.Conflict(schemamutually_exclusive), a second bare operand is rejected, and-laclustering /--longlong forms are accepted. The remaining commands migrate in flag-shape batches, each landing with its own differential matrix (the same template).
Single-schema file map (2026-09, cmdif STEP 4): all 58 commands have migrated to the generated-parser + shared-evaluator architecture (the per-command hand
parsePosixArgs/usage/Optionsare gone everywhere the migration's vocabulary could express the surface), so the full data flow is visible in one picture — The migration is COMPLETE: fx-find, fx-grep and fx-seq's hand POSIX parsers are DELETED (their schemas now declare-name/-maxdepth,-type f|dand the counts arity remap — the former vocabulary gaps);fx-log/fx-undogained schemas (schemas/log.dhall,undo.dhall) and generated parsers; andfx-what/fx-whygained real runtime Dhall-record evaluators — so every schema-backed command runs the shared differential runner (expectPosixEqualsRecord). Deliberate cuts, NOT pending work: fx-basename's-aoperand-routing arm (mode-dependent operand routing; the flag field exists but routes nothing), required operands (the placeholder-default + main()-check convention stands), and fx-true/fx-false stay schema-less (nothing to configure). Remaining honest VOCABULARY GAP notes live where the engine, not the command, owns a spelling (find/grep are native stages; nl's runtime-derived enums; echo's no-clear-flag cut; echo's options-after-operand divergence). Everything else: the hand parser is gone and the differential matrix is the proof.schemas/<name>.dhall SINGLE SOURCE OF TRUTH per command: one { ty, dflt, posix } Dhall record literal (ty = the argument record TYPE the runtime evalDhallArgs accepts, dflt = the defaults literal, posix = flags/ positionals/mutual-exclusion surface) | v (src/tools/fx-clijson.zig, build time: validates (dflt // {}) : ty and emits) | +---------------+---------------+ v v src/generated/cli_<name>.zig (the same schema, the OTHER the generated typed POSIX parser arg form, needs no generated (pure std — no dhall at runtime, code: completed at runtime as imported by the command binary (dflt // user) : ty by as its own module) fx-cli.completeSrc) | | +---------------+---------------+ v src/fx-cli.zig the SHARED layer: the schema evaluator (evalSchemaSrc / completeSrc / (both arg forms), renderDhallRecord, encodeOptionsWire live here) the comptime-reflection wire encoder and the generic differential runner (expectPosixEqualsRecord) the per- command differential matrices call
zig build gen-cliregenerates the committedsrc/generated/cli_*.zigin place;zig build gen-cli-check(wired intotest) fails on a stale or hand-edited one. NOT in this graph: the fx-pipelinebuiltin()registry — it declares pipeline SHAPES (single {path : Text} -> rows {...}), not argument surfaces, and only fractionally overlaps the schemas (ls/du/df'spathpositional; nothing for grep's upstream-rows input or any output type), so consolidating it onto the schemas was considered and DECLINED (the naming gaps would need a hand-written field map — a new drift surface — and fx-cli's whole-arena transactions would dangle the registry's live Terms); see fx-pipeline.zig's registry-header note for the full argument and the test-enforced guarantees that keep the layers honest anyway.
Determinism as proof, not hope. Replaying a pipeline re-runs each stage and compares the produced intermediate against the recorded sha256. A divergence is a caught non-deterministic command (or a CAS hash collision — astronomically unlikely) surfaced loudly. This is the time-travel that is not git: you don't restore bytes, you re-derive a known-good intermediate and trust the hash.
Idempotence is the fixed-point guarantee. The etymology lens: a fixed point
satisfies f(x)=x. fx-compose marks mutation commands as idempotent when their semantics
are "re-assert this relation" (mkdir -p, rm-on-missing, cp-same-content), and
then proves convergence: f(f(x)) == f(x) by construction. Running a pipeline on
its own output is a no-op; this is what composes with the journal's roll-forward.
Implementation order (smallest → thesis-defining):
- Pipeline value + type-checker (pure Dhall, no engine, no I/O): the four
shapes, command signatures as a registry, and
compose(a,b)→ Ok / type-error. Smallest demonstrable win:fx find {..} |> fx grep {..}accepted, and a mismatched composition (e.g.fx ls |> fx find) rejected with a typed error. This is a natural fit for the existing dhall-c zig module — reuse it, don't reinvent. - Content-addressed evaluation: run the chain, materialize each intermediate
into the CAS, emit the canonical
sha256:-integrity pipeline expression. Reuses the journal'ssha256Hexand the same db/state-dir conventions. - Replay / determinism gate: re-run + compare per-stage hashes; report the exact stage that diverged. Time-travel without a VCS.
- Idempotence annotations + convergence proof on the mutation commands, then roll-forward rollback ties back into the journal (Lens 2).
- The flag vocabulary now covers the single-dash multi-char surface that
drove the old cut (
-name,-maxdepthdeclare as multi-char shorts that never cluster;-type f|das anEnumselector carryingvalue = Some "<argv spelling>"; seq's 1/2/3-operand arity as thecountspositional remap), so find/grep/seq no longer keep hand parsers — find/grep remain NATIVE pipeline stages (fx-eval's nativeFind) as a composition-choice cut, not a vocabulary one, and their binaries keep the long-form--rowsmode (U10) that lets the run-mode executor'sfind | grepmean the same thing as the record path'sfind |> grep. Still not schema-expressible, deliberately: fx-basename's mode-dependent-aoperand routing, a required-operand vocabulary (placeholder defaults +main()checks stand), fx-true/fx-false stay schema-less (nothing to configure), and the full engine-side inventory (roles, argv plans, engine-synthesised stage flags, non-stage commands like the mutators) documented inschemas/README.md. - Streaming transform tools (
cat,head,tail,sed,awk,tr,dd): their value-add isn't a data model — don't back a 10GB file as facts. Determinism is already their default when inputs are deterministic. Keep them as clean deterministic binaries that become content-addressable components inside pipelines/derivations. - Don't put the database on the hot I/O path. The relations layer is for
query, legibility, history — not a backing store for every
cp. - The gimmick trap: "we used a database, therefore it's fixpoint" is the
branding-vs-built-in failure to avoid. The relations layer earns its keep only
where recursion / join / time-travel is genuinely useful (
find,du,diff, provenance, history). Where a plain loop is clearer, write a plain loop. - Buffered-hash cut (mutators, 2026-08-24): content hashing (
cpsame-content check,rmcapture, CAS intern) reads each file whole into memory and hashes it — the same honesty cut asfx diff'swalkCollect. Streaming/chunked hashing for large files is a future refinement. - Mutator scope cuts (v1, 2026-08-24):
cp -r,ln -f, andtouch -a/-m/-d/-tare out of scope;mkdir -mis out of scope (mode is0777 & ~umask, recorded as the post-create mode). Each is documented as a follow-up rather than silently half-shipped. - fx-compose stage cuts (v1, 2026-08-24):
seq/echo/yeswere out (output generators — no input shape) andpaste/commwere out (two-file semantics — a second live-path operand passed as stage args breaks hermetic replay). Resolution (2026-09, Lens-3):seq/echoshipped via a fifth input kind.none(source) —echo/echo:TEXT(args = one verbatim operand) andseq:5/seq:1 2 9/seq:{ last = 5, ... }(args whitespace-split into 1-3 integer operands, or one Dhall record) emitlinesand are valid only at position 0 (a.noneinput matches no producer output).yesstays cut: unbounded output is not an internable CAS value — it needs a streaming model or a NEW bounded stage name, not an overload ofyes.paste/commshipped via the live-second-path operand: the stage args ARE the second file path (paste:/tmp/b.txt,comm:b-sorted.txt), the pipeline input rides the CAS as FILE1, and PATH2 is live-read at run AND replay — exactly the already-acceptedfind/ls/dulive-operand caveat (a changed PATH2 diverges replay loudly, an absent one fails with StageFailed;comm's PATH2 must be pre-sorted by the caller — the naive merge stays deterministic either way). Flags (paste -d/-s,comm -1/-2/-3) remain cut — the single-string stage-arg DSL has no flag grammar; the full hermetic fix, a two-input join DAG with both operands CAS-interned, stays deferred.basename/dirname/realpathwere deferred (they need a single-Textvalue wire form to feed a scalar operand — a wire gap, not a registry rejection) — resolved later in the batch: the bare-Text single VALUE wire form shipped (one canonical JSON string + LF, the samewriteStringescaping as every other wire string), and all three are now staged as singleText-> singleText(fx-compose --text /a/b/c.txt basename dirname); the six remaining checksum flavors (md5sum/sha1sum/sha224sum/sha384sum/sha512sum/b2sum) are mechanical clones ofcksum/sha256sum. Each is a deliberate cut, not an omission.
Resolution (2026-08-24): this batch upheld the cut. The user's framing was
"datalog-backed where meaningful"; the engine was used exactly where the data
model earns it (5/8 — ls/du/sort/uniq/wc), and cat/head/tail
ship as clean typed deterministic binaries that compose as content-addressable
components (cat bytes→bytes, head/tail lines→lines) in fx-compose
pipelines. Uniformity is delivered through the typed record interface on all
eight; only the engine is reserved for where it pays.
Lens 2's provenance commands: fx-what /bin/foo traces an installed file back
through the store closure to its derivation; fx-why is the inverse (what does
a package provide and depend on). The command shows its derivation — the datalog
proof tree / build closure. The manifesto made executable.
The shipped slice (2026-08): store-relative provenance. The design-only
sketch this section used to carry was wrong about the data model, so the schema
first: the store DB never had provides(pkg, path) / derives(path, pkg, src)
relations. What exists is the activation pkg graph
(store/pkg/dep/root/closure, all snapshot-versioned) with derivations
implicit in the content-addressed hex — and nothing mapping an installed path
back to any of it. The slice materializes exactly that mapping as two new
snapshot-versioned relations, written by fx-activate in the SAME activation txn
as the generation/svc facts (idempotent declare, clear-and-rewrite on
re-activation, so every snapshot is self-consistent):
install(target, origin, mode, genhash)— one fact per installed file.targetis rootfs-absolute (/etc/p,/bin/name);originis STORE-relative ({genhash}-system-generation/etc/pfor the /etc copies,{hash}-{pkg}for the /bin symlinks) so facts survive store relocation — resolved against--storeat query time;modeis the raw u32 (0o644 for etc files, 0 for symlinks) so the reconcile pass can comparest_mode & 0o7777;genhashpins the snapshot that wrote the fact.provides(pkg, store_dir)— the pkg-name → store-dir mapping ({hash}-{pkg}) that used to be implicit in the derivation hex.
The write side is the real activation txn, not a seeded relation — the old
"do not fabricate a seeded ownership relation; a fake POC teaches the wrong
lesson" rule kept by construction. The query engine lives in fxstore (it owns
the store schema + closure semantics); the fx-what / fx-why coreutils are
thin readers that open the store DB — query decoupled from the write side.
fx-what PATH [--as-of N] [--store DIR]— resolveinstall(target)as-of the snapshot → store-relative origin →providesinverse → pkg → dependency closure → the indented datalog proof tree (which roots pull it in). An unmanaged path (noinstallfact) is a clear error suggestingverify.fx-why PKG [--as-of N] [--store DIR]— inverse: the closure the pkg pulls in, its store dir, the targets it provides, and which roots reach it.- Both ride the store snapshot timeline (the same as-of read every other
store reader uses):
--as-of Nanswers over history, and pre-prov snapshots (relations absent) read as empty, not as errors. fxstore verify— the truth pass, because declared buildfile actions ≠ on-disk state. Perinstallfact: lstat the target (missing), compare symlink target vs origin (link_target), content-hash etc files against the store copy (hash), compare mode bits (mode); plus a walk of /etc + /bin for entries noinstallfact claims (unmanaged).
Roll-forward consistency: the automated boot roll-forward re-publishes the
target generation's activation facts, prov relations included, so provenance
agrees with the restored store. Documented cut: the manual fxstore rollback CLI still leaves prov facts at the pre-rollback activation — the
same pre-existing semantics as every other activation fact
(generation/svc); verify flags the disagreement in the meantime, and the
next automated roll-forward re-converges it. On the books rather than silent.
fx find— recursive Datalog closure over a live-walkedentryrelation. Smallest, most on-brand, instantly demonstrable ("literally a least-fixed-point computation"). No timeline involved. SHIPPING (Track A, Zig 0.16.0, linkinglibdatalog.sovia C-FFI).fx grep/fx log— DAFSA regex-WALK; reuses the planned M4 log-DB primitive.fx diff— Dhall-typed diff coreutil: two files → line-level unified hunks; two directories → recursive+/-/!listing by content hash. SHIPPING. (Thefx history/ journal timeline is REMOVED — the design below is kept conceptually, but the journal/record/history code has been deleted and is deprioritized "for now".)- Provenance
what/why— SHIPPING: store-relative provenance over the activation-writteninstall(target,origin,mode,genhash)/provides(pkg,store_dir)relations —fx-what/fx-whycoreutils plusfxstore what/why/verify, as-of any store snapshot, with the reconcile/verify drift pass (see the provenance section above). Known cut: manualfxstore rollbackleaves prov facts at the pre-rollback activation (shared with generation/svc) — documented, re-converges on roll-forward. - Typed command composition /
fx-compose— the real differentiator (Lens 3), biggest scope, do last. Deepened (2026-08-24): the engine'sgrepstage is a real DAFSA regex-WALK (same engine/subset as thefx-grepbinary), thebuiltin()registry gainsnl/expand/cksum/sha256sumas typed stages, andls/dudispatch with a real--rowswire mode (see Lens 3); remaining v1 stage cuts are in the honest-cut section. - The 2026-08-24 batch —
ls,du,sort,uniq,wcSHIPPING as datalog-backed coreutils (transient DB, Dhall-typed records);cat,head,tailSHIPPING as pure typed binaries (honest cut). Extends the fx-pipelinebuiltin()signature registry with all eight (see Lens 3). - Mutation coreutils + global derivation log + CAS — the 7 mutators
(
fx-cp/fx-mv/fx-rm/fx-mkdir/fx-rmdir/fx-touch/fx-ln) over the Option B global content-addressed derivation log + CAS store, plus thefx-logreader. SHIPPING (2026-08-24). fx-undo/ replay-verify — inverse-effect application + divergence gate- CAS restore. Designed, follow-up.