writeonce/docs/superpowers/specs/2026-08-01-systems-track-design.md
shoney.arickathil d7304f23ec docs: status board at docs/00-status.md; gap-closure spec applied; recover lost doc
- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT
  PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets —
  stories, in progress, done, pending, discarded, learnings. It covered only the
  Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs
  repointed; `Kanban:` banners renamed to `Status:`.
- New discarded.md (settled rejections with reasons: inheritance, `abstract`,
  Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine
  state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong-
  output, malloc-path ASan trick, deferred checks that never reach the VM).
- RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk,
  never committed (gitignored path), cited by five docs incl. principle 12.
  Root cause was broader: all seven forward-roadmap plans in
  docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped
  the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc.
- Repaired broken links across docs/, 270 -> 36: fixes a regression from the
  earlier reference/ -> .dev/reference/ move (relative paths at ../../ and
  deeper were skipped), plus depth and reorg drift. The 36 residual point at
  content that does not exist and need decisions, not paths.
- New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md,
  applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps
  time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut
  (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw,
  abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins.
  Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by-
  design. types.ml header drops its false satisfaction-set claim. 00-code-
  review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
2026-08-10 23:42:26 +02:00

14 KiB
Raw Blame History

writeonce systems track — Haxe keyword study, program mode, systems stdlib (design)

Date: 2026-08-01 Status: approved design, pre-implementation Companion spec: 2026-08-01-oop-compiler-vm-design.md (the OOP core this track extends) Driving workload: ~/projects/log-watcher — ~1,200 lines of Haxe compiled to C++ (--cpp), a single-binary systems daemon: log-tail watcher, cron.d supervisor, flock/pgrep probes, hand-rolled MCP-over-HTTP server, JSONL detection sink.

Motivation

writeonce today can only be a full-stack server. log-watcher is the counterexample class: a CLI daemon that reads files, probes processes, serves a small TCP protocol, and sleeps in a poll loop. The language should build such applications too. Method: study the language log-watcher is written in — Haxe — keyword by keyword, adopt broadly what fits, reject explicitly what breaks doctrine, and prove the result by re-expressing log-watcher in .wo.

Decisions locked during brainstorming

Question Decision
"Hexa" meaning Haxe — log-watcher's language.
Deliverable Spec + .wo sample workload. Repo pattern: samples force the grammar (blog/ecommerce/pricing precedent).
Adoption stance Broad Haxe parity MINUS doctrine breakers. No inheritance (extends/override/super), no Dynamic/untyped, no macros — plan-13 doctrine and the OOP spec stay locked. Everything else adopts liberally.
Systems access Program mode + safe builtin stdlib. No FFI/extern — capabilities are typed builtins implemented in the C runtime.

Part 1 — The Haxe keyword verdict table

Every Haxe keyword (plus the contextual ones), one verdict each: have (writeonce equivalent exists), adopt (new surface this track adds), reject (with the reason). This table is normative for the plan documents.

Haxe keyword Verdict writeonce mapping / reason
class have class (state + methods, no hierarchy)
interface have structural interfaces (OOP spec section 3)
function have fn
var (locals) have let; mutability via MVS rules, no second keyword
this have self (identifier, positionally bound)
if / else have same
for / in have same
while have same
return have same
true / false have same
enum have+adopt tagged unions exist; adopt payload variants (Pending | Failed(reason: Text)) with exhaustive switch
final have MVS immutability by default; const for named constants
new have constructor brace literal Type { … }; no keyword
switch / case / default adopt expression-form switch, exhaustive over unions; default optional when exhaustive
typedef adopt structural record aliases with optional fields (?field) — the SupConfig/TailState pattern
null / Null<T> adopt ?T optional types; forced handling before use (no nil deref trap possible); bare null only assignable to ?T
try / catch adopt expression-form over the trap system: catch binds the structured error {code, method, line, msg}; uncaught = existing trap surface. throw (explicit raise) is cut — 0 uses in the driving workload; parked post-iteration-12
break / continue adopt loop control
do (do-while) adopt parity, trivial
static adopt class-level fn/const — namespaced functions without instances (Flock.held, Pgrep.alive pattern)
abstract reject a distinct scalar type adds a conversion surface without buying safety this language needs; domain scalars are plain Int/Text
using adopt static extension methods — doctrine-safe reuse (composition sugar, the inheritance substitute)
import / package adopt use + directory-as-module; stdlib namespaces (fs, proc, net, time, env, json)
is cut runtime type test restricted to union variants and interface values; a compile error on statically-known types — 0 uses in the driving workload; parked post-iteration-12
inline adopt (values) const compile-time values; inline functions rejected — optimization is the compiler's job
public / private adopt (as pub) default private; pub exports; property-accessor pattern (default, null) becomes pub(read) — public read, owner-only write
#if / #else / #end adopt build-flag conditional compilation only (the -D portable pattern); flags from the build command, no expression language beyond flag names
string interpolation '${}' adopt in string literals
&& / || adopt spelled and/or (words, not symbols) — the lexer has no & case at all (a bare & reports WO-E001), so words cost nothing to add as keywords and read better in the sample's conditional-heavy code; one new precedence level below comparison and above assignment (or binds loosest, then and, then comparison, then the arithmetic ladder); short-circuit; Bool-typed operands only, Bool result, no truthiness; lowers to compare-and-jump on existing opcodes (JZ plus a jump), no VM change
extends reject no-inheritance doctrine (plan 13, OOP spec) — is-a via unions, has-a via composition
super reject no hierarchy to call up
override reject nothing to override
overload reject one name, one signature; keeps dispatch and diagnostics simple
implements reject (keyword) satisfaction is structural and implicit; declaring it adds a lie surface
dynamic / Dynamic reject static typing is the VM's foundation (untagged registers); typed json.decode covers the real use
untyped reject no escape hatch from the type system
macro reject kills the fast-compile promise; codegen belongs to wo gen tooling
extern reject no FFI hole in the memory-safety story; capabilities are audited builtins
cast reject no unsafe casts; conversions are typed (abstract from/to, explicit builtins)
operator reject no operator overloading; KISS

Part 2 — Program mode

Entry. A project containing a free fn main(args: multi Text) -> Int compiles as a program; the return value is the exit code. wo run <dir> executes it. woc build produces the single binary (plan-3 trailer mechanics unchanged). A project with service blocks and no main remains a server. Both present: main runs first and decides what to start — exactly log-watcher's shape (watch/run/mcp subcommands selecting the daemon flavor).

Blocking model — the load-bearing decision. Program mode runs one shard and blocking builtins are legal (time.sleep, proc.run, blocking net.accept): the Haxe original is a poll loop around Sys.sleep, and writeonce expresses that directly. Server shards keep the never-block doctrine — the same stdlib calls are loop-integrated (io_uring) there. One API, two execution disciplines, selected by mode. No async/await keyword exists in either mode.

CLI surface. env.args() -> multi Text, env.get(name) -> ?Text, env.exit(code) (never returns), print/print_int (exist) plus print_err; output flushes on newline (the reason for log-watcher's Util.say disappears).

Daemon idiom. while true { …; time.sleep(ms) } is supported and expected. Signals: the runtime owns signalfd (doctrine); SIGTERM/SIGINT set a shutdown flag programs poll via env.stopping() -> Bool. No signal callbacks.

Part 3 — Systems stdlib

Six builtin modules, scoped to what log-watcher's code actually uses. Every handle (file, socket, process) is an owned object whose drop closes it — MVS deterministic destruction is RAII: no close bookkeeping, no leaked fds by construction, and a handle sent nowhere dies at scope end.

Module Surface log-watcher use it covers
env args(); get(name) -> ?Text; exit(code); stopping() -> Bool CLI subcommand dispatch and exit-code propagation for main; env.get reads the MCP API key with an environment fallback (1 use); env.stopping drives the poll-loop shutdown check (4 uses) — the daemon idiom's exit condition.
fs exists(path); stat(path) -> ?{size, inode, mtime}; read_at(path, offset, max) -> Text; read_all(path, cap); append(path, text); list(dir) -> multi Text rotation detection needs the inode; bounded tail-chunk reads (never front-to-back scans); JSONL detection sink (open-append-close); cron.d directory scan. No write/truncate/delete in v1 — the read-only posture is the default posture.
proc run(cmd, args: multi Text) -> {code: Int, out: Text, err: Text}, bounded capture the flock -n exit-code probe and pgrep -f. Args-array only — no shell-string form, command injection unrepresentable.
net listen(addr, port) -> Listener; accept(listener) -> Conn; read(conn, max) -> Text; write(conn, text) the hand-rolled MCP HTTP subset (127.0.0.1 accept loop, one request per connection). TCP only in v1.
time now() wall ms (exists); sleep(ms); iso(ms) -> Text; local(ms) -> {year, month, day, hour, minute, dow} the daemon sleep; iso gives JSONL detection timestamps and MCP response fields a stable textual instant; local gives cron next-fire computation broken-out calendar fields, including day-of-week. mono() is cut — 0 uses in the driving workload; parked post-iteration-12.
json json.decode(text) as RecordType -> ?RecordType; json.encode(value) -> Text config loading and JSON-RPC — typed, replacing Haxe's Dynamic idiom: missing optional fields are fine, shape mismatches yield nil, never a trap. The as here is the decode-target position only — a checked conversion returning ?T, not a cast; it exists nowhere else (the cast rejection stands). Reuses the HTTP plan's C codec as builtins.

Core builtins

The sample calls 22 unqualified builtin names across ~176 sites, none of them in any spec: len ×55, push ×17, byte_at ×11, starts_with ×9, index_of ×8, has ×7, split ×6, split_ws ×6, join ×5, parse_int ×5, trim ×4, slice ×4, substr ×4, pop ×3, ends_with ×2, last_index_of ×2, to_lower ×2, sort ×2, char_of ×1, shift ×1, remove ×1, reverse ×1. print_err joins this set — Part 2 already named it; this table never listed it.

These are always in scope — no use line, no namespace — the same status print, print_int, now, words, count, latest already have, and they share the same flat WO_B_* id space in the VM's builtin table as every other builtin. Grouping into text operations, collection operations, and map operations is documentation only, not namespaces. Each builtin has a fixed-arity typed contract, resolved at compile time like every other builtin; out-of-range indices trap (T_BOUNDS), never return a sentinel.

Deliberately not adopted: iteration/closure builtins (map, filter, reduce) — the language has no function-value type, and adding higher-order functions would require one.

Part 4 — The sample workload

docs/examples/log-watcher/ — the Haxe original re-expressed in .wo, file-for-file:

.wo file .hx sibling carries
main.wo Main.hx subcommand dispatch, config decode into a typedef record with ?fields
watcher.wo, logtail.wo Watcher.hx, LogTail.hx TailState record, bounded tail reads, rotation-by-inode, quiet-period rule
cron.wo Cron.hx cron.d parse, next-fire computation
probes.wo Flock.hx, Pgrep.hx proc.run exit-code probes as static fns
mcp.wo Mcp.hx, Tools.hx typed request/response records; pure handle(req) -> resp kept socket-free (the original's best design decision, preserved); serve loop over net

A README table records the mapping and what (if anything) each file could not express — an empty "could not express" column is this track's acceptance criterion.

Error handling

One system, two surfaces. Traps remain the runtime truth (OOP spec section 6). This track adds the language surface: try expr catch (e) fallback-expr — e is the structured error record; uncaught faults still surface as traps. throw (explicit raise) is cut — 0 uses in the driving workload; parked post-iteration-12. Optionals (?T) handle expected absence (missing file stat, failed decode, missing env var) — the stdlib returns nil for those, reserving traps for genuine faults. The Haxe original's try … catch (e:Dynamic) return false probes become optional-returning calls — clearer than the original.

Testing

  • Language adoptions: each feature lands with conformance-corpus fixtures — golden (runs, expected stdout) and must-fail (expected WO-E###) — extending the OOP track's corpus and error catalog.
  • Stdlib: corpus fixtures against real resources — tempdir files (stat/inode/rotation simulation via rename), spawned /bin/true-class processes, loopback sockets. Handle-drop RAII proven under ASan (a leaked fd test: open many handles in a loop, assert no fd growth).
  • Acceptance: the log-watcher sample compiles; its testable cores (tail state machine, cron next-fire, MCP handle) pass fixtures ported from the Haxe test suite's cases; the sample binary runs watch against a growing tempfile and detects an error-final quiet period.

Success criteria

  1. The keyword table is fully implemented: every adopt row parses, typechecks, and executes with corpus coverage; every reject row has a diagnostic or a documented absence.
  2. fn main program mode: wo run executes a CLI program; exit codes propagate; woc build produces a self-contained binary for it.
  3. All six stdlib modules pass their corpus fixtures; handle RAII is ASan-proven.
  4. docs/examples/log-watcher/ compiles and its README mapping table has an empty "could not express" column.
  5. The sample's watch mode detects a silent death (error-final + quiet period) end to end on a real tempfile.

Out of scope (named)

Threads/worker pools in program mode (the shard-actor track owns concurrency); UDP/TLS; fs mutation beyond append; signal callbacks; sqlite-equivalent embedded SQL over RAM (that is the DB engine's job — a future sample can wire MiniLog's idea to select); Haxe macro-based reflection idioms. throw (explicit raise), time.mono, and is are also cut — 0 uses in the driving workload each; parked post-iteration-12.