Merge branch 'master' into chat-ws-lifecycle

This commit is contained in:
shoney.arickathil 2026-08-27 23:11:49 +02:00
commit ebc3522c40
201 changed files with 8219 additions and 1042 deletions

139
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,139 @@
# NOTE: this workflow has never run. Authored 2026-08-25 and not
# executable locally — the first real tag push is its first test.
# Expect to adjust the toolchain step if the pinned OCaml/dune version
# is not available on the runner image.
name: release
# Fires only on a version tag, so nothing is published by an ordinary
# push. `workflow_dispatch` is a DRY RUN: it builds, verifies and reports
# the glibc floor, but skips the tag guard (there is no tag) and skips
# publishing. Use it to rehearse before tagging anything.
on:
push:
tags:
- 'v*'
workflow_dispatch:
# The ONE line that replaces `gh auth login`: it widens the automatic
# GITHUB_TOKEN so this job may write releases. No PAT, no secret to
# rotate, and the token dies with the job.
permissions:
contents: write
jobs:
release:
# DELIBERATE, not a default. The release binaries link glibc
# dynamically, so the build host's glibc caps which symbol versions
# they can import — and that cap becomes the minimum glibc every
# user needs. Built on 24.04 (glibc 2.39) the floor is 2.39;
# built here on 22.04 (2.35) it is 2.35, which is the difference
# between excluding and including Ubuntu 22.04, Debian 12 and
# RHEL 9. Raise this image only with a reason, and update the
# supported-systems list in docs/examples/site/install/view.wo in
# the same change.
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
# setup-ocaml gives a compiler and opam. It does NOT give dune —
# dune is an ordinary opam package, and this project has no .opam
# file for it to infer one from, so nothing pulls it in. The first
# run failed here with `dune: command not found`.
- uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: '4.14'
# setup-ocaml may already have installed a dune (it uses one for its
# own cache), in which case asking for an exact older version is a
# DOWNGRADE the solver refuses — which is how the pinned
# `dune.3.14.0` failed. So: use whatever is there, and only install
# if there is nothing. Any dune >= 3.14 satisfies this project's
# `(lang dune 3.14)`.
- name: Ensure dune is available
run: |
opam exec -- dune --version || opam install -y dune
echo "dune: $(opam exec -- dune --version)"
# The tag is the release's identity; VERSION is what the binaries
# report. If they disagree the download URL would name a version
# nobody can install. mkdist.sh already guards VERSION against the
# binaries; this guards the tag against VERSION.
- name: Tag must match VERSION
if: github.event_name == 'push'
run: |
tag="${GITHUB_REF_NAME#v}"
ver="$(cat VERSION)"
[ "$tag" = "$ver" ] || {
echo "tag $GITHUB_REF_NAME does not match VERSION $ver" >&2
exit 1
}
# `opam exec --` because mkdist.sh calls dune internally; without
# the opam environment on PATH the script cannot find it.
- name: Build the tarball
run: opam exec -- ./scripts/mkdist.sh
# The site links one exact filename. If mkdist ever changes its
# naming, the download button 404s for every visitor — so fail
# here instead.
- name: Asset name must match what the site links
run: |
ver="$(cat VERSION)"
asset="writeonce-${ver}-linux-amd64.tar.gz"
test -f "dist/$asset"
grep -q "$asset" docs/examples/site/install/view.wo || {
echo "$asset is not the filename /install links" >&2
exit 1
}
- name: Verify the digest
run: cd dist && sha256sum -c "writeonce-$(cat ../VERSION)-linux-amd64.tar.gz.sha256"
# Prove the ARTEFACT works, using the binaries inside it rather
# than the ones just built in the tree. This is what catches a
# tarball that packaged the wrong thing.
- name: Smoke-test the extracted toolchain
run: |
ver="$(cat VERSION)"
tmp="$(mktemp -d)"
tar -C "$tmp" -xzf "dist/writeonce-${ver}-linux-amd64.tar.gz"
export PATH="$tmp/writeonce/bin:$PATH"
woc version
wovm --version
mkdir -p "$tmp/hello"
cd "$tmp/hello"
printf 'name = "hello"\nversion = "0.1.0"\n\n[runtime]\nwo = ">= 0.1"\n' > wo.toml
printf 'fn main() -> Int {\n print("hello, writeonce");\n return 0;\n}\n' > main.wo
woc .
out="$(./target/hello)"
[ "$out" = "hello, writeonce" ] || { echo "got: $out" >&2; exit 1; }
# Record the real glibc floor of what is about to ship, so the
# claim on /install can be checked against a build log rather
# than trusted.
- name: Report the glibc floor
run: |
ver="$(cat VERSION)"
tmp="$(mktemp -d)"
tar -C "$tmp" -xzf "dist/writeonce-${ver}-linux-amd64.tar.gz"
for b in "$tmp"/writeonce/bin/*; do
printf '%s needs %s\n' "$(basename "$b")" \
"$(objdump -T "$b" | grep -oE 'GLIBC_[0-9.]+' | sort -uV | tail -1)"
done
# gh is preinstalled on GitHub runners and reads GH_TOKEN from the
# environment, so there is no `gh auth login` anywhere in this file.
# Skipped on workflow_dispatch: a dry run must never publish.
- name: Publish
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
gh release create "$GITHUB_REF_NAME" \
"dist/writeonce-${ver}-linux-amd64.tar.gz" \
"dist/writeonce-${ver}-linux-amd64.tar.gz.sha256" \
--title "writeonce ${ver}" \
--generate-notes

View file

@ -25,16 +25,19 @@ program ships as one file that depends only on the system C library.
mistyped field name is a **compile error**, not a runtime surprise. There is
no SQL string anywhere in the shipped binary.
- **One binary, no runtime dependencies.** `woc .` produces a self-contained
executable (~100 KB for the sample programs) that links only libc. Copy it to
a server and run it.
- **Small on purpose.** No FFI, no package manager, no framework. The standard
library is a handful of OS modules. The language is designed to be read.
executable (160–260 KB for the sample programs in this repository) that links
only libc. Copy it to a server and run it.
- **Small on purpose.** No FFI, no reflection, no package registry —
dependencies are exact-rev git URLs and nothing else. The standard library is
a handful of OS modules. The language is designed to be read.
writeonce is **not** a web framework and does not (yet) serve HTTP, WebSockets,
or a UI. It is a systems language whose distinguishing feature is the embedded
database. If you have seen an older "writeonce" that served REST from `cargo
run`, that was a separate, earlier runtime; this page documents the current
`woc`/`wovm` toolchain.
writeonce is a systems language whose distinguishing feature is the embedded
database. HTTP/1.1 and WebSockets **do** work today — but as `.wo` libraries you
consume through `[deps]` (`porch` for serving, `writeonce-view` for
HTML), never as runtime features: the runtime stays framework-agnostic on
purpose. TLS is always terminated by a proxy in front. If you have seen an older
"writeonce" that served REST from `cargo run`, that was a separate, earlier
runtime; this page documents the current `woc`/`wovm` toolchain.
---
@ -139,8 +142,10 @@ has a known owner, memory is freed deterministically, and values that form
cycles are collected by an inferred garbage collector (you never annotate GC-
ness; the compiler infers it). The surface will look familiar:
- **Types:** `Int`, `Text`, `Bool`, and user `class` types. `?T` marks an
optional (nullable) value; `nil` is the empty case.
- **Types:** `Int`, `Float`, `Bool`, `Text`, `Bytes`, `Timestamp`, `Id`, and
user `class` types. `?T` marks an optional (nullable) value; `nil` is the
empty case. `Int` and `Float` never mix implicitly — `float` and `trunc` are
the only bridges.
- **Containers:** `multi T` (a growable list) and `map<K, V>`. Literals:
`[]`, `[a, b]`, `{}`.
- **Classes & records:** classes with fields and methods, `static const` /
@ -149,6 +154,11 @@ ness; the compiler infers it). The surface will look familiar:
expressions, and `try { … } catch (e) { … }` (also an expression form).
- **Strings:** interpolation with `${expr}` inside a `"…"` literal.
- **Functions:** free functions and methods; arguments and returns are typed.
- **Concurrency:** `spawn C { … }` starts an actor and yields an `actor M`
address; `send` is fire-and-forget, `call` parks the calling fiber until the
receive returns. A class becomes an actor by declaring `fn receive(msg: M)`.
Blocking stdlib calls park the fiber — there is no `async`, no `await`, and no
user-visible thread.
```
fn classify(n: Int) -> Text {
@ -166,14 +176,17 @@ A compact set of OS modules, reached by their reserved names — no imports:
| Module | What it does |
| --- | --- |
| `fs` | `exists`, `list`, `stat`, `read_all`, `read_at`, `append` |
| `time` | `sleep`, `now`, `local`, `iso` |
| `fs` | `exists`, `list`, `stat`, `read_all`, `read_at`, `append` — read and append; a file cannot yet be replaced, truncated, deleted or renamed |
| `time` | `sleep`, `now`, `ticks` (µs monotonic), `local`, `iso` |
| `env` | `get`, `stopping` (a cooperative shutdown flag) |
| `net` | TCP `listen` / `accept` / `read` / `write` / `close` (host + port) |
| `net` | `listen` / `accept` / `read` / `write` / `close`, per-call deadline twins `read_dl` / `accept_dl` / `write_dl`, `listen_unix`, `peer`. Listeners only — there is no outbound `connect` |
| `proc` | `run` a child process, capture stdout/stderr/exit |
| `json` | `encode` / `decode` (`json.decode(t) as T` yields `?T`) |
These are deliberately minimal — the surface a real program needs, and no more.
Alongside them sit free builtins for text, containers, the `Float`/`Bytes`
bridges, `base64`, and the digests `sha1` / `sha256` / `hmac_sha256`. The full
list is `docs/guides/language-surface.md`.
---
@ -269,14 +282,15 @@ dependencies, declared in the manifest:
```toml
[deps]
niceframework = { git = "https://github.com/shoneyj/niceframework", rev = "v0.1.0" }
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }
```
`woc` fetches each dep (via the `git` binary) into `.wo-deps/<name>/`, pins
the resolved commit in `wo.lock`, and `use niceframework` (or
`use niceframework/sub`) imports its public names like any module. Builds
never touch the network once the lock is satisfied; a moved tag is reported,
and `woc --update-deps myproject/` refreshes the lock deliberately. Flat
**The `[deps]` key IS the module name** `use` imports — the repository name
never appears in your source. `woc` fetches each dep (via the `git` binary)
into `.wo-deps/<name>/`, pins the resolved commit in `wo.lock`, and `use porch`
(or `use porch/router`) imports its public names like any module. Builds never
touch the network once the lock is satisfied; a moved tag is reported, and
`woc --update-deps myproject/` refreshes the lock deliberately. Flat
dependencies only (a dep may not have its own `[deps]`) — honest and small,
by design.
@ -292,8 +306,9 @@ WO_DATA=./data ./target/myproject report # a fresh process still sees the da
## Worked examples
Two complete sample programs live in the repository and double as the language's
acceptance tests:
Thirteen sample programs live under `docs/examples/`; eight of them are wired to
a `just` recipe and double as the language's acceptance tests. The three worth
reading first:
- **`docs/examples/employee/`** — departments and employees related by
`ref`/`backlink`, `@unique`, foreign-key restrict on delete, per-department
@ -303,7 +318,7 @@ acceptance tests:
just employee # compile + run every mode against a durable database
```
- **`docs/examples/writeonce-framework/` + `docs/examples/web-app/`** — a web
- **`docs/examples/porch/` + `docs/examples/web-app/`** — a web
framework written in writeonce (HTTP/1.1 behind a TLS-terminating proxy,
router with `:param` captures, interface-based handlers) and a storefront
consuming it **as a `[deps]` dependency**, with `@table` persistence. Run:
@ -319,7 +334,10 @@ acceptance tests:
just log-watcher
```
Read either program's `main.wo` for idiomatic, working writeonce.
Read any of their `main.wo` files for idiomatic, working writeonce. The rest —
`site` (the writeonce.de tutorial, server-rendered, `just site`), `fibers`,
`db-actor`, `db-bench`, `gc-cycle`, `operators`, `shop` — cover the concurrency,
GC and benchmark surfaces.
---
@ -330,10 +348,16 @@ honest. These exist as design iterations and/or work-in-progress branches, not
as features you can use today:
- **Query aggregates** — `group … by … into g` with `count`/`avg`/`min`/`max`
and projection records. (Today the same result is written by hand from the
shipped primitives.)
- **HTTP service layer** — `service` blocks that route requests to methods.
- **Concurrency** — a shard-actor runtime and green-threaded fibers.
and projection records. The clause parses and is then refused by the
typechecker; today the same result is written by hand from the shipped
primitives.
- **File mutation and outbound sockets** — `fs` can create, grow and read a
file but never replace, truncate, delete or rename one, and there is no
`net.connect` at all, so nothing reaches out (no OIDC, SMTP, object store or
webhook). Both are iteration 38.
- **`service` blocks** — a declaration form that routes requests to methods,
lowering onto the framework library. Today you register routes as ordinary
framework calls, which works and is what every sample does.
- **Cross-program database access** — one program attaching to another's
database over a local channel, with keypair authentication and per-client
rights.
@ -341,8 +365,11 @@ as features you can use today:
- **Compile-time metaprogramming** — `@derive(Json/Csv/Eq/…)` generated from a
class's own metadata, no reflection.
Known current limits worth naming: `net` is TCP host+port only; `proc.run` has
no timeout or signal control; there is no stdin/stdout byte I/O and no FFI.
Known current limits worth naming: `proc.run` has no timeout or signal control;
there is no stdin/stdout byte I/O and no FFI; `map` lookup is a linear scan;
actor mailboxes are bounded but there is no supervision tree yet; the WAL is
append-only, so it grows and boot replays all of it; TLS is always a proxy's
job.
---

View file

@ -2,7 +2,9 @@
Lexer → parser → typechecker → ownership pass → bytecode emitter, for `.wo`. OCaml stdlib only (no Menhir, no ppx); dune is the build runner. Sibling of the C `wovm` bytecode VM ([`runtime/`](../runtime/README.md)) — the two halves of the OOP track's spec (`docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`) meet at plan 3, where `woc`'s emitted `.wob` runs on `wovm`.
**Stage: plan 3 (`docs/plan/compiler/2026-08-01-wob-emit-e2e-single-binary.md`) complete, Tasks 1–6 + 8** (Task 7, a parity harness against the Rust runtime, was deferred by explicit decision — the two stacks now diverge by design). `.wo` source compiles to `.wob` bytecode (`--emit`) and to a single self-contained executable (`build`) that runs `wovm` with no arguments and no repo-relative dependency. Milestone 1's acceptance gate — compile-time budget, the full conformance corpus under ASan, the single-binary smoke, both unit suites — is `just oop-accept`. Plan 2 (lexer through ownership pass) shipped first and is unchanged.
**Stage: well past plan 3.** Plan 2 (lexer through ownership pass) and plan 3 (`docs/plan/compiler/2026-08-01-wob-emit-e2e-single-binary.md`, Tasks 1–6 + 8 — Task 7, a parity harness against the since-removed Rust runtime, was deferred by explicit decision) closed the milestone: `.wo` source compiles to `.wob` bytecode (`--emit`) and to a single self-contained executable (`build`) that runs `wovm` with no arguments and no repo-relative dependency. Milestone 1's acceptance gate — compile-time budget, the full conformance corpus under ASan, the single-binary smoke, both unit suites — is `just oop-accept`.
Since then the front end has taken iterations **15** (`[deps]`, `wo.lock`, `--update-deps`), **17** (`kind = "library"`, entry-less check mode, `internal/` as WO-E108), **19** (`Float` and `Bytes`), **24** (`call`'s typed reply, WO-E226), **34** (digest builtins), **35** (net deadline seams), **36** (`not`, bitwise operators, hex/binary literals, compound assigns — `.wob` v6) and **37** (the backtick raw text literal with `{{ }}` auto-escaping). Current language surface: [`docs/guides/language-surface.md`](../docs/guides/language-surface.md). Current status: [the board](../docs/stories/00-status.md).
## Requirements
@ -24,23 +26,30 @@ just woc-test # same, from the repo root
```
woc <path> # compile (lex, parse, typecheck, ownership-check); nothing prints on success
woc <dir> # BUILDS instead, when <dir>/wo.toml exists — the primary mode
woc version # e.g. "writeonce 0.1.0 linux/amd64"
woc --emit <path> -o <out.wob> # compile through to a .wob bytecode module, runnable by wovm
woc build <dir> -o <app> [--runtime <path>]
# compile + append the .wob image to a copy of wovm (--runtime,
# else $WO_RUNTIME, a wovm beside this woc, or runtime/wovm)
woc --update-deps <dir> # re-fetch [deps] at their manifest revs, rewrite wo.lock
woc -D <name> ... # define a build flag for the #if/#else/#end token filter
woc --dump-tokens <path> # stdout: one line per lexed token
woc --dump-ast <path> # stdout: the declaration + body AST, indented
woc --dump-owner <path> # stdout: the ownership pass's four tables (moves, drops, rc, residual)
woc --dump-gc <path> # stdout: the inferred-GC pass's traced set
woc --dump-bc <path> # stdout: disassembled bytecode for every emitted method
woc --emit <path> -o <out.wob> # compile through to a .wob bytecode module, runnable by wovm
woc build <dir> -o <app> [--runtime <path>]
# compile + append the .wob image to a copy of wovm (default
# runtime/wovm, or --runtime) into one self-contained <app>
```
`<path>` is a single `.wo` file or a directory. A directory is discovered recursively for every `.wo` file under it — same contract as `wo run` (`crates/rt/src/lib.rs::discover`): dot-prefixed entries and `target`/`data`/`node_modules` are skipped, results are sorted by path. Every discovered file compiles as one program (declarations in one file resolve for bodies in another, regardless of discovery order); diagnostics from every file and every stage print sorted by `(file, line, col)`. For multi-file `--dump-*` output, each file's dump is preceded by a `=== path ===` header line (`compiler/src/dump.ml`'s `file_header`) — a single-file run never prints one.
`woc <dir>` on a directory holding a `wo.toml` is the mode every sample and the install docs use: it reads the manifest's `name` plus the optional `[build]` runtime/target keys and produces `<target>/<name>` exactly as `woc build` would. A manifest with `kind = "library"` is checked entry-less and writes nothing.
`<path>` is a single `.wo` file or a directory. A directory is discovered recursively for every `.wo` file under it: dot-prefixed entries and `target`/`data`/`node_modules` are skipped, results are sorted by path. Every discovered file compiles as one program (declarations in one file resolve for bodies in another, regardless of discovery order); diagnostics from every file and every stage print sorted by `(file, line, col)`. For multi-file `--dump-*` output, each file's dump is preceded by a `=== path ===` header line (`compiler/src/dump.ml`'s `file_header`) — a single-file run never prints one.
Diagnostics render as `file:line:col: severity CODE: message` plus a source excerpt with a caret; every shipped code is cataloged in `docs/plan/oop-vm/01-error-catalog.md`. Exit codes: **0** clean compile, **1** diagnostics reported, **2** usage/IO failure.
## Layout
- `src/` — one module per stage: `diag` (diagnostics, collector, exit-code decision), `token`/`lexer`, `ast`/`parser`, `types` (typechecker), `owner` (MVS ownership pass), `emit` (bytecode emitter, consumes `owner`'s four tables), `disasm` (bytecode disassembler, backs `--dump-bc`), `dump` (stable text dumps for all of the above)
- `src/` — one module per stage: `diag` (diagnostics, collector, exit-code decision), `token`/`lexer`, `ast`/`parser`, `types` (typechecker), `gcinfer` (the inferred-GC pass, backs `--dump-gc`), `owner` (MVS ownership pass), `emit` (bytecode emitter, consumes `owner`'s four tables), `disasm` (bytecode disassembler, backs `--dump-bc`), `dump` (stable text dumps for all of the above)
- `bin/` — the `woc` executable: CLI parsing, file discovery, the multi-file/cross-file driver, `--emit`/`build` output
- `test/` — `runner.ml` (golden runner + CLI smoke) and `test_diag.ml` (diag.ml unit checks); `test/golden/<stage>/` holds one-file-per-fixture goldens (`tokens`, `ast`, `owner`, `owner-err`, `bc`); `test/fixtures/driver/` holds the multi-file CLI-smoke fixtures (directory discovery, cross-file symbols, diagnostic ordering) that don't fit the one-`.wo`-file-per-fixture golden shape

View file

@ -295,3 +295,62 @@ a keyword, and visibility is name resolution at compile time.
The `0x`/`0b` prefix commits only when a real base digit follows, so
`0xg` stays `Int 0` + `Ident` — a parse error at its own position, no
new lexer diagnostic. `_` separators are consumed only BETWEEN digits.
## The raw text literal (iteration 37)
Multi-line markup used to be impossible to write: a statement ends at a
newline, so a page was one `h = h .. "<...>"` statement per line, every
attribute single-quoted to dodge `\"`, and every piece of data wrapped
in a hand-written `esc()` call. Backtick literals replace all three.
Things worth knowing before editing them:
- **It is a LEXER form, not a node.** A backtick literal emits exactly
the `Token.Str` (no holes) or `Token.InterpStr` (holes) a `"..."`
string emits, so `types.ml`, `owner.ml`, `emit.ml`, the `.wob` format
and the VM are all untouched — nothing downstream can tell the two
spellings apart. That is the whole reason the feature is small. A
design that introduced a `Markup`/`Element` AST variant instead would
have had to teach five files about it.
- **No escape processing at all inside.** Quotes and backslashes are
content, which is the point. The cost is that the form cannot express
a literal backtick, a literal `${`, or a literal `{{` — those are
written by concatenating an ordinary `"..."` string with `..`. One
greppable door beats inventing an escape character for the one form
whose selling point is not having any. (`docs/examples/site/content.wo`
keeps two `code_block` samples as escaped `"..."` strings for exactly
this reason: they contain `\${`.)
- **The margin is stripped at LEX time**, so the constant pool holds the
dedented text and there is no runtime cost. Java's text-block rule:
one newline right after the opening backtick is dropped, the smallest
leading whitespace run across non-blank lines is removed from every
line, and a whitespace-only closing line loses its whitespace but
keeps its newline. A literal with no newline is left alone — eating
the leading spaces of `` ` hi` `` would be a surprise, not a service.
The measuring pass runs over a SHADOW string where each hole is one
non-whitespace sentinel byte, so ` {{ x }}` counts as indent 4 and
as a non-blank line.
- **`{{ e }}` desugars to `esc(${e})`, resolved by ordinary name
lookup.** `desugar_interp` in `parser.ml` builds a `Call` on an
`Ident "esc"` — precisely what a developer wrote by hand before. The
compiler learns nothing about HTML, `esc` stays writeonce-view's ordinary
`pub fn`, a typo'd field inside the hole is a normal name/type error,
and a locally defined `esc` shadows deliberately (a custom escaper is
a feature). `${ }` inside the same literal stays raw — that is the
greppable door for markup you built yourself. The one place the
desugar leaks: with no `esc` in scope the program fails on a name it
never typed, so `emit.ml`'s WO-E403 message carries a hint for that
one name.
- **`{{` is special ONLY inside a backtick literal.** Inside `"..."` it
is still two braces, so CSS and JS text in existing samples lexes
byte-identically.
- **WO-E005 closed a real hole.** The string scanner's catch-all used to
append a raw newline like any other byte, so a forgotten closing quote
silently swallowed the rest of the file with no diagnostic. Now the
scan stops at the newline WITHOUT consuming it — the `Newline` token
still terminates the statement, so recovery costs one line instead of
the file. The rt-parity silence for a plain unterminated string with
no newline is untouched, and `runner.ml` still pins it.
- **The `..` line continuation stays.** A line ending in `..` still
swallows its newline. Raw literals took over the multi-line-markup job
that motivated it, but it remains the general way to spread a long
concatenation over several lines and has its own corpus fixture.

View file

@ -34,6 +34,7 @@ let kind_label (k : Token.kind) : string =
let part_str = function
| Token.SText s -> Printf.sprintf "TEXT(%s)" s
| Token.SExpr s -> Printf.sprintf "EXPR(%s)" s
| Token.SEsc s -> Printf.sprintf "ESC(%s)" s
in
Printf.sprintf "INTERP_STR(%s)" (String.concat "," (List.map part_str segs))
| Token.KwType -> "KW_TYPE"

View file

@ -3259,8 +3259,20 @@ and emit_call (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : As
| None ->
if is_builtin_name name then emit_builtin p f v ~dst ?expected e name args
else begin
(* iteration 37: `{{ e }}` in a raw text literal desugars to
a call to `esc`, so a program using that hole without an
`esc` in scope lands here with a name it never typed.
The caret is already on the literal; this says why. *)
let hint =
if name = "esc" then
" (a `{{ ... }}` hole calls it -- add `use html`, or \
declare your own `fn esc(t: Text) -> Text`)"
else ""
in
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:(Printf.sprintf "call to `%s`, which is not a declared fn or a builtin" name);
~message:
(Printf.sprintf "call to `%s`, which is not a declared fn or a builtin%s"
name hint);
put f (ins_abx op_loadk dst (const_int p 0))
end)))
| Field (base, mname) -> (

View file

@ -55,6 +55,32 @@ let unknown_char_code = Diag.lexing_prefix ^ "01" (* WO-E001 *)
let unterminated_escape_code = Diag.lexing_prefix ^ "02" (* WO-E002 *)
let directive_code = Diag.lexing_prefix ^ "03" (* WO-E003: #if/#else/#end misuse *)
(* iteration 37, the raw text literal (backtick-delimited, verbatim
content, no backslash escapes). Two codes, because the two shapes
are genuinely different situations:
WO-E004 — a raw literal that runs off the end of the file. Unlike a
plain "..." string (silent, rt parity, see above), this one IS
reported: multi-line is the raw literal's normal case, so a missing
closing backtick would otherwise swallow every remaining line of the
file with nothing to show for it. Reported at the OPENING backtick,
which is the only position that helps -- EOF tells the reader
nothing about which literal never closed.
WO-E005 — a raw newline inside a "..." or '...' string. This used to
be accepted silently: the string scanner's catch-all appended the
newline like any other byte, so a forgotten closing quote ate the
rest of the file with no diagnostic at all. Nothing in the repo ever
relied on it (zero of the .wo sources span a line inside quotes) and
the backtick literal is now the spelling for multi-line text, so the
accident becomes an error. The scan stops at the newline WITHOUT
consuming it, so the Newline token is still emitted and the
statement terminates -- one diagnostic, and the next line parses
normally instead of being swallowed. The rt-parity silence for a
plain unterminated string with no newline is untouched. *)
let unterminated_raw_code = Diag.lexing_prefix ^ "04" (* WO-E004 *)
let newline_in_string_code = Diag.lexing_prefix ^ "05" (* WO-E005 *)
(* haxe-parity Task 8: build flags. `woc -D name` fills this before any
tokenize call; undefined flags are false. A module-level ref because the
compiler is a single-shot process — tests that care set it explicitly
@ -269,6 +295,126 @@ let preprocess (collector : Diag.Collector.t) ~(file : string)
go toks;
List.rev !out
(* ---- the raw literal's margin rule (iteration 37) -------------------
A render() body is written at its method's indentation, but that
indentation is an artifact of the SOURCE, not of the markup -- nobody
wants six leading spaces on every line of the served HTML. So the
common margin is removed here, at lex time: the constant pool holds
the dedented text, no downstream stage ever sees the source
indentation, and the whole rule costs nothing at run time.
The rule (Java's text blocks, which solved exactly this):
- one newline immediately after the opening backtick is dropped,
so the first markup line can start on its own line;
- the smallest leading run of spaces/tabs across all non-blank
lines is removed from every line (characters counted, tabs NOT
expanded -- mixing them is the author's problem, and expanding
would need a tab width the language does not have);
- a whitespace-only final line (the usual case: the closing
backtick sits on its own line) loses its whitespace but keeps
its newline.
A literal with no newline in it is left completely alone -- there is
no margin to speak of, and silently eating the leading spaces of
` hi` would be a surprise, not a service.
Holes do not disturb any of this. A line's indentation is by
definition the run of whitespace at its start, and the only thing
that can split a line across segments is a hole, which ends that run
-- so an indentation run always lives whole inside one SText. The
measuring pass replaces each hole with a single non-whitespace
sentinel byte so that a line that is ` {{ x }}` correctly counts
as indent 4 and as NON-blank. *)
let is_indent_char c = c = ' ' || c = '\t'
let segments_shadow (segs : Token.str_part list) : string =
let b = Buffer.create 64 in
List.iter
(function
| Token.SText s -> Buffer.add_string b s
| Token.SExpr _ | Token.SEsc _ -> Buffer.add_char b '\001')
segs;
Buffer.contents b
let min_indent (shadow : string) : int =
let m = ref max_int in
List.iter
(fun line ->
let n = String.length line in
let i = ref 0 in
while !i < n && is_indent_char line.[!i] do
incr i
done;
(* a blank (or whitespace-only) line never sets the margin *)
if !i < n && !i < !m then m := !i)
(String.split_on_char '\n' shadow);
if !m = max_int then 0 else !m
let strip_margin (k : int) (segs : Token.str_part list) : Token.str_part list =
if k = 0 then segs
else begin
let at_line_start = ref true in
let one seg =
match seg with
| Token.SExpr _ | Token.SEsc _ ->
at_line_start := false;
seg
| Token.SText s ->
let n = String.length s in
let b = Buffer.create n in
let i = ref 0 in
while !i < n do
if !at_line_start then begin
let dropped = ref 0 in
while !dropped < k && !i < n && is_indent_char s.[!i] do
incr dropped;
incr i
done;
at_line_start := false
end
else begin
let c = s.[!i] in
Buffer.add_char b c;
if c = '\n' then at_line_start := true;
incr i
end
done;
Token.SText (Buffer.contents b)
in
(* fold_left, not List.map: `one` carries state across segments and
List.map's application order is unspecified. *)
List.rev (List.fold_left (fun acc seg -> one seg :: acc) [] segs)
end
let drop_trailing_margin (segs : Token.str_part list) : Token.str_part list =
match List.rev segs with
| Token.SText s :: rest_rev ->
let n = String.length s in
let i = ref n in
while !i > 0 && is_indent_char s.[!i - 1] do
decr i
done;
(* only a run that directly follows a newline is a closing line *)
if !i < n && !i > 0 && s.[!i - 1] = '\n' then
List.rev (Token.SText (String.sub s 0 !i) :: rest_rev)
else segs
| _ -> segs
let dedent (segs : Token.str_part list) : Token.str_part list =
let shadow = segments_shadow segs in
if not (String.contains shadow '\n') then segs
else begin
let segs =
match segs with
| Token.SText s :: rest when String.length s > 0 && s.[0] = '\n' ->
Token.SText (String.sub s 1 (String.length s - 1)) :: rest
| _ -> segs
in
let k = min_indent (segments_shadow segs) in
drop_trailing_margin (strip_margin k segs)
end
let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
Token.t list =
let lx = make src in
@ -279,6 +425,14 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
| { Token.kind = Token.Newline; _ } :: _ -> true
| _ -> false
in
(* A line ending in `..` continues on the next line — the ONE newline
suppression in the language, so multi-line markup/text builds read
as one expression (the shop template's ask; story 37 rides it). *)
let last_is_dotdot () =
match !out with
| { Token.kind = Token.DotDot; _ } :: _ -> true
| _ -> false
in
let report_unknown line col c =
Diag.Collector.add collector
(Diag.error ~code:unknown_char_code ~file ~line ~col
@ -289,6 +443,18 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
(Diag.error ~code:unterminated_escape_code ~file ~line ~col
~message:"unterminated string escape" ())
in
let report_unterminated_raw line col =
Diag.Collector.add collector
(Diag.error ~code:unterminated_raw_code ~file ~line ~col
~message:"unterminated raw text literal" ())
in
let report_newline_in_string line col =
Diag.Collector.add collector
(Diag.error ~code:newline_in_string_code ~file ~line ~col
~message:
"newline in string literal (use a `...` raw text literal for \
multi-line text)" ())
in
let running = ref true in
while !running do
match peek lx with
@ -307,7 +473,8 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
end
else if c = '\n' then begin
ignore (advance lx);
if not (last_is_newline ()) then emit Token.Newline line col
if not (last_is_newline ()) && not (last_is_dotdot ()) then
emit Token.Newline line col
end
else if c = ' ' || c = '\t' || c = '\r' then ignore (advance lx)
else if c = '"' || c = '\'' then begin
@ -374,6 +541,13 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
| None ->
report_unterminated_escape esc_line esc_col;
scanning := false)
| Some '\n' ->
(* WO-E005. Deliberately NOT consumed: the outer loop turns
it into the Newline token that terminates the statement,
so recovery is one bad line rather than the rest of the
file. *)
report_newline_in_string lx.line lx.col;
scanning := false
| Some other ->
ignore (advance lx);
Buffer.add_char buf other
@ -384,6 +558,64 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
| [ Token.SText s ] -> emit (Token.Str s) line col
| segs -> emit (Token.InterpStr segs) line col)
end
else if c = '`' then begin
(* iteration 37: the raw text literal. Everything up to the
closing backtick is content -- newlines included, and with NO
escape processing at all, which is the whole point: markup
carries quotes and backslashes verbatim. A literal backtick
(or a literal `{{`) is written by concatenating an ordinary
"..." string with `..`; that door is one greppable operator,
which beats inventing an escape character for the one form
whose selling point is not having any.
Two hole forms, and ONLY here -- inside "..." a `{{` is still
two literal braces, so existing CSS/JS text is untouched:
${ expr } raw, exactly like a "..." string's hole
{{ expr }} HTML-escaped (the parser wraps it in esc()) *)
ignore (advance lx);
let buf = Buffer.create 64 in
let parts = ref [] in
let flush_text () =
parts := Token.SText (Buffer.contents buf) :: !parts;
Buffer.clear buf
in
let scanning = ref true in
while !scanning do
match peek lx with
| None ->
report_unterminated_raw line col;
scanning := false
| Some '`' ->
ignore (advance lx);
scanning := false
| Some '$' when peek_at lx 1 = Some '{' ->
flush_text ();
ignore (advance lx);
ignore (advance lx);
parts := Token.SExpr (read_interp_expr lx) :: !parts
| Some '{' when peek_at lx 1 = Some '{' ->
flush_text ();
ignore (advance lx);
ignore (advance lx);
(* read_interp_expr stops at the first `}` at depth 0 and
consumes it -- the second one closes this hole. Reusing it
means brace depth and nested string literals are already
handled, so `{{ Point{x:1}.x }}` scans correctly. *)
let raw = read_interp_expr lx in
(match peek lx with
| Some '}' -> ignore (advance lx)
| _ -> report_unterminated_raw line col);
parts := Token.SEsc raw :: !parts
| Some other ->
ignore (advance lx);
Buffer.add_char buf other
done;
flush_text ();
(match dedent (List.rev !parts) with
| [] -> emit (Token.Str "") line col
| [ Token.SText s ] -> emit (Token.Str s) line col
| segs -> emit (Token.InterpStr segs) line col)
end
else if is_digit c then begin
(* iteration 19: one scanner for both numeric worlds. The integer run
is scanned into a buffer as well as accumulated, because a fraction

View file

@ -1332,6 +1332,19 @@ and parse_primary (st : state) : Ast.expr =
and desugar_interp (st : state) (pos : Ast.pos) (segs : Token.str_part list) : Ast.expr =
let mk_str s = { Ast.id = fresh_id st; pos; kind = Ast.StrLit s } in
let mk_interp inner = { Ast.id = fresh_id st; pos; kind = Ast.Interp inner } in
(* iteration 37: `{{ e }}` in a raw text literal IS `esc(${e})` -- the
desugar builds exactly the call a developer writes by hand today
(docs/examples/shop/**/view.wo used `${esc(...)}` throughout), so
every later stage sees only Call/Interp/StrLit/Concat nodes it
already handles. No new AST variant, no new builtin, no VM change,
and a typo'd field inside the hole is an ordinary name/type error.
`esc` resolves by ordinary lookup (wo-html's `pub fn esc`, in scope
after `use html`); a locally defined `esc` shadows it deliberately
-- a custom escaper is a feature, not a collision. *)
let mk_esc inner =
let callee = { Ast.id = fresh_id st; pos; kind = Ast.Ident "esc" } in
{ Ast.id = fresh_id st; pos; kind = Ast.Call (callee, [ mk_interp inner ]) }
in
let parse_segment_expr (raw : string) : Ast.expr =
let sub_collector = Diag.Collector.create () in
let sub_toks = Lexer.tokenize sub_collector ~file:st.file raw in
@ -1362,7 +1375,8 @@ and desugar_interp (st : state) (pos : Ast.pos) (segs : Token.str_part list) : A
(function
| Token.SText "" -> None
| Token.SText s -> Some (mk_str s)
| Token.SExpr raw -> Some (mk_interp (parse_segment_expr raw)))
| Token.SExpr raw -> Some (mk_interp (parse_segment_expr raw))
| Token.SEsc raw -> Some (mk_esc (parse_segment_expr raw)))
segs
in
match parts with

View file

@ -23,6 +23,13 @@
type str_part =
| SText of string (* literal text, escapes already applied *)
| SExpr of string (* raw, unlexed source of one `${...}`'s body *)
(* iteration 37: the escaping half of the raw text literal. Same raw,
unlexed payload as SExpr -- what differs is only what the parser
wraps it in: `${...}` desugars to a bare Interp, `{{...}}` to an
`esc(Interp ...)` call. Produced ONLY by a backtick raw literal;
inside a "..." string `{{` stays two literal braces, so CSS and JS
text in existing samples lexes byte-identically. *)
| SEsc of string (* raw, unlexed source of one `{{...}}`'s body *)
type kind =
(* literals *)

View file

@ -0,0 +1,2 @@
1:1 METHOD page(name: Text) -> Text
2:3 RETURN "<p>" .. INTERP(name) .. esc(INTERP(name)) .. "</p>"

View file

@ -0,0 +1,3 @@
fn page(name: Text) -> Text {
return `<p>${name}{{ name }}</p>`
}

View file

@ -0,0 +1,42 @@
1:71 NEWLINE
4:1 KW_FN
4:4 IDENT(page)
4:8 LPAREN
4:9 IDENT(name)
4:13 COLON
4:15 IDENT(Text)
4:19 RPAREN
4:21 ARROW
4:24 IDENT(Text)
4:29 LBRACE
4:30 NEWLINE
5:3 KW_LET
5:7 IDENT(one)
5:11 EQ
5:13 STR(<div>hi</div>)
5:28 NEWLINE
6:3 KW_LET
6:7 IDENT(verbatim)
6:16 EQ
6:18 STR(a"b\n)
6:25 NEWLINE
7:3 KW_LET
7:7 IDENT(block)
7:13 EQ
7:15 STR(<div class="card">
many
line
</div>
)
12:4 NEWLINE
13:3 KW_LET
13:7 IDENT(holes)
13:13 EQ
13:15 INTERP_STR(TEXT(<p>),EXPR(name),TEXT(),ESC( name ),TEXT(</p>))
13:41 NEWLINE
14:3 KW_RETURN
14:10 IDENT(block)
14:15 NEWLINE
15:1 RBRACE
15:2 NEWLINE
16:1 EOF

View file

@ -0,0 +1,15 @@
-- iteration 37: the backtick raw text literal. One token per literal,
-- content verbatim (no escape processing), common margin removed at
-- lex time, two hole forms -- ${} raw and {{}} escaped.
fn page(name: Text) -> Text {
let one = `<div>hi</div>`
let verbatim = `a"b\n`
let block = `
<div class="card">
many
line
</div>
`
let holes = `<p>${name}{{ name }}</p>`
return block
}

View file

@ -236,6 +236,128 @@ let () =
check "dash-continuation: a - b (spaced) lexes as Ident, Dash, Ident"
(kinds = [ Token.Ident "a"; Token.Dash; Token.Ident "b"; Token.Eof ])
(* ---- raw text literal, iteration 37 (not golden-diffed) ------------
golden/tokens/raw-literal.wo pins the token STREAM; these pin the
pieces a dump cannot show: that a backtick literal with no holes is
byte-identical to the Str a "..." string would have produced, that
the common margin is removed at LEX time (so no runtime cost and no
downstream stage ever sees the source indentation), and that the two
new diagnostics fire at the right position. *)
let () =
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" "let t = `<div>hi</div>`" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "raw literal: no holes lexes as a plain Str"
(kinds
= [ Token.KwLet; Token.Ident "t"; Token.Eq; Token.Str "<div>hi</div>"; Token.Eof ]);
check_eq "raw literal: reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector))
string_of_int
let () =
(* Nothing between the backticks is escape-processed: a quote is a
quote and a backslash-n is two characters, which is the whole
point of the form: markup without quote-escape noise. *)
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" "`a\"b\\n`" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "raw literal: content is verbatim, no escape processing"
(kinds = [ Token.Str "a\"b\\n"; Token.Eof ])
let () =
(* The margin case, written the way a render() body actually is:
opening newline dropped, the 4-space common margin removed from
every line, the whitespace-only closing line reduced to nothing
while its newline survives (Java text-block behavior). *)
let src = "let t = `\n <div>\n many\n </div>\n `" in
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" src in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "raw literal: common margin stripped, leading newline dropped"
(kinds
= [
Token.KwLet;
Token.Ident "t";
Token.Eq;
Token.Str "<div>\n many\n</div>\n";
Token.Eof;
])
let () =
(* Both hole forms in one literal. The payloads are raw and unlexed,
exactly as SExpr has always carried `${...}` -- the parser is what
tells them apart (SEsc gains the esc() wrapper). *)
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" "`<p>${a}{{ b }}</p>`" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "raw literal: ${} stays raw, {{}} becomes SEsc"
(kinds
= [
Token.InterpStr
[
Token.SText "<p>";
Token.SExpr "a";
(* the empty run between two adjacent holes, exactly as a
"..." string has always produced it -- the parser drops
empty SText segments in desugar_interp *)
Token.SText "";
Token.SEsc " b ";
Token.SText "</p>";
];
Token.Eof;
])
let () =
(* Unlike a plain "..." string, an unterminated raw literal is an
error: multi-line is its normal case, so silently swallowing the
rest of the file would be a footgun, not rt parity. *)
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"raw.wo" "let t = `abc" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "unterminated raw literal: closes with what was collected"
(kinds = [ Token.KwLet; Token.Ident "t"; Token.Eq; Token.Str "abc"; Token.Eof ]);
let diags = Diag.Collector.diagnostics collector in
check_eq "unterminated raw literal: exactly one diagnostic reported" ~expected:1
~actual:(List.length diags) string_of_int;
match diags with
| [ d ] ->
check "unterminated raw literal: WO-E004 at the backtick (line 1, col 9)"
(d.code = "WO-E004" && d.site.line = 1 && d.site.col = 9)
| _ -> check "unterminated raw literal: diagnostic shape" false
let () =
(* A raw newline inside "..." used to be accepted silently (the
scanner's catch-all appended it like any other byte), which meant a
forgotten closing quote ate the rest of the file with no
diagnostic. Now that the backtick literal is the blessed spelling
for multi-line text, that newline is an error and the scan stops
WITHOUT consuming it, so the Newline token still terminates the
statement and the next line parses normally. *)
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file:"nl.wo" "let t = \"ab\ncd" in
let kinds = List.map (fun (t : Token.t) -> t.kind) toks in
check "newline in string: scan stops at the newline, which still tokenizes"
(kinds
= [
Token.KwLet;
Token.Ident "t";
Token.Eq;
Token.Str "ab";
Token.Newline;
Token.Ident "cd";
Token.Eof;
]);
let diags = Diag.Collector.diagnostics collector in
check_eq "newline in string: exactly one diagnostic reported" ~expected:1
~actual:(List.length diags) string_of_int;
match diags with
| [ d ] ->
check "newline in string: WO-E005 at the newline (line 1, col 12)"
(d.code = "WO-E005" && d.site.line = 1 && d.site.col = 12)
| _ -> check "newline in string: diagnostic shape" false
(* ---- direct parser/AST assertions (Task 4, not golden-diffed) ------------
golden/ast/*.wo fixtures already pin the AST *shape* via --dump-ast,

View file

@ -18,6 +18,12 @@ Native speed — the big one. Everything is interpreted: ~40× behind Go on raw
## Verification 2026-08-20
> **Read the 2026-08-26 re-verification at the bottom before quoting anything
> from this section.** Eight of its rows have since been overtaken by shipped
> work. The section is kept as written — it is a dated measurement, and
> rewriting it would destroy the record of what was true when the iteration
> order was re-sequenced against it.
Every claim above was checked against the tree. **26 of 27 hold. One number
does not, and two problems are worse than stated.**
@ -79,7 +85,7 @@ the number or produce the benchmark.
| 22's battery never run | 22 is ⬜ "needs a spec first"; no `bench/baseline.json`, no `just db-bench`; `runtime/bench/` is the retired C prototype's harness |
| TSan covers one demo | only `scripts/fibers-accept.sh` builds and runs `wovm_tsan` |
| no fuzzing, no CI | no `.github/`, no fuzz target |
| one framework, five samples, one consumer | exact: `writeonce-framework`; employee, employee-list, fibers, gc-cycle, log-watcher; `web-app` |
| one framework, five samples, one consumer | exact: `writeonce-serve`; employee, employee-list, fibers, gc-cycle, log-watcher; `web-app` |
### Consequence
@ -87,3 +93,41 @@ The iteration order in
[`stories/language-runtime-database/00-story.md`](stories/language-runtime-database/00-story.md)
was re-sequenced against these findings on 2026-08-20 — Seq only, no `#`
renumbered, no file moved. See that table's second re-sequencing note.
---
## Re-verification 2026-08-26
Re-run against the tree, reading source rather than documents. **Eight rows
have been overtaken by shipped work; the rest still hold.** Overtaken:
| 2026-08-20 row | What the source says now |
| --- | --- |
| "no `Float`, no `Bytes`" | `types.ml`'s `builtin_scalars` is `["Int"; "Bool"; "Text"; "Timestamp"; "Id"; "Float"; "Bytes"]` — iteration 19, plus the `float`/`trunc` bridges and the `bytes_*`/`base64_*` builtins |
| "`send` is one-way — `WO_B_SEND=69` is the last builtin (`WO_B_MAX 69u`)" | `WO_B_MAX` is `95u`; `WO_B_CALL = 88` is a send that parks the caller for a typed scalar reply (iteration 24, WO-E226) |
| "no crypto primitives" | `WO_B_SHA1 = 85`, `WO_B_SHA256 = 86`, `WO_B_HMAC_SHA256 = 87`; `runtime/src/crypto.c`, vector-accepted in `test_crypto.c` (iteration 34) |
| "unbounded mailboxes, no backpressure" | mailboxes are capped (`WO_MAILBOX`, default 1024) with a sender-side reserve and a catchable `WO_T_ACTOR` trap on overflow |
| "no supervision, links, actor death" | **partly** overtaken: actor death landed with `call` — a dead or mid-call callee traps the caller instead of hanging it. `monitor` (id 89) and `time.after` (id 90) are still literal holes in the builtin enum; supervision trees remain absent |
| "22's battery never run — no `bench/baseline.json`, no `just db-bench`" | `bench/baseline.json` exists with the campaign's metrics, `just db-bench`/`db-bench-quick` are recipes, `bench/results/` holds the runs, iteration 22 is done |
| "no fuzzing, **no CI**" | `.github/workflows/release.yml` builds, verifies and publishes on a `v*` tag. Fuzzing is still absent, and CI is release-only — nothing runs the gates per change, which is iteration 30's remaining half |
| "one framework, five samples, one consumer" | two libraries (`writeonce-serve`, `writeonce-view`) and 13 samples, 8 of them gated |
| "The multi-shard DB gap is structural" (Understated) | closed by the arc's stage 3: the string `"database engine not initialized"` no longer exists in `runtime/src/`, worker statements marshal to the owner shard, and `just db-actor` gates it |
Still true, re-checked at the source: interpreted-only with no JIT and no SIMD;
the ceilings correction (`WO_STACK_SLOTS 4096`, `WO_MAX_REGS 64`,
`WO_MAX_FRAMES 256`, `WO_MAX_SHARDS 64`); no generics beyond `multi`/`map`; no
closures or function values; byte strings with no Unicode awareness; traps and
`try` instead of Result values; `switch` without destructuring; round-robin
placement with no work stealing; no timers beyond `time.sleep`; no TLS; no
HTTP/2; observability is `print`/stderr with no counters, tracing or profiler; no
debugger and no LSP; deps are git-rev-only with no registry, semver or transitive
resolution; blue-green and migrations are futures; TSan covers one demo. And
**`map<K,V>` lookup is still a linear scan** — `runtime/src/cont.h` says so in
its own header comment, which keeps it the compute problem this document argued
it was.
Two capability gaps this re-run named that the original critique did not, now
[iteration 38](stories/language-runtime-database/38-content-platform-capabilities.md):
`fs` has six builtins (ids 40–45) and can create, grow and read a file but never
replace, truncate, delete or rename one; and there is no `net.connect` anywhere
in `runtime/src/`, so no program can open an outbound connection.

View file

@ -6,8 +6,12 @@
> to pick the next implementation: anything whose incoming arrows are all
> green is startable today. Rebuilt 2026-08-20 from a sweep of every
> story/spec/plan markdown (the "misses" pass: iteration 17's outgoing
> edges, the concurrency chain, the post-12 parked drain, 9b→10,
> 14's gap fan-out, 20's fiber caveat).
> edges, the concurrency chain, the parked drain, 9b→25, 28's gap
> fan-out, 20's fiber caveat), and **refreshed 2026-08-26** against the
> code and the story frontmatter: graph 1 had drifted a generation
> behind — it still showed 17 parked and 18 as next, and it used the
> pre-renumber ids 10/12/13/14 for what are now stories 25/26/29/28. All
> iterations through 38 are now nodes.
## 1. Story iterations
@ -26,23 +30,36 @@ flowchart TD
I15["15 deps package manager"]:::done
I16["16 web framework v1 core"]:::done
I17["17 library kind + internal/ (PARKED — spec+plan ready, branch library-internal)"]:::parked
FWREORG["framework internal/ reorg + check mode (kills the --emit workaround; WO-E108/E109 reserved)"]:::parked
I18["18 framework v2: transaction{} + cache/flags/jobs (spec APPROVED — the next implementation)"]:::specd
I17["17 library kind + internal/ ✅ 2026-08-20"]:::done
FWREORG["framework internal/ reorg + check mode ✅ landed with 17 (WO-E108/E109 shipped)"]:::done
I19["19 Float + Bytes ✅ 2026-08-20"]:::done
I37["37 wo-html components + raw text literal ✅ 2026-08-25"]:::done
I35["35 net runtime seams ✅ 2026-08-23"]:::done
I36["36 operator parity: not/bitwise/hex literals — code landed 2026-08-22, awaiting the manual pass"]:::specd
RELEASE["packaging + release pipeline ✅ 2026-08-25 (no story: VERSION, just dist, install-accept, release.yml)"]:::done
I9c["20 cross-program tables (half-built)"]:::open
I9d["21 keypair attach auth (half-built; crypto+handshake already on its branch)"]:::open
I18["18 framework v2: transaction{} + cache/flags/jobs (⏸ hold 2026-08-21; spec+plan approved, held intact)"]:::parked
I9c["20 cross-program tables (⏸ hold 2026-08-21; channel half-built)"]:::parked
I9d["21 keypair attach auth (⏸ hold 2026-08-21; crypto floor now exists via 34)"]:::parked
I9e["22 durability + throughput baseline ✅ 2026-08-21"]:::done
I8["8 shard-actor runtime ✅ 2026-08-21"]:::done
I24["24 chat + actor lifecycle 🔄 THE LIVE SLICE (absorbing 31 + 34)"]:::specd
I34["34 crypto builtins ✅ code landed as 24's T1 (ids 85-87)"]:::done
I31["31 actor lifecycle — call/mailbox-cap/death landed in 24; monitor + time.after (ids 89/90) open"]:::specd
I9f["23 io_uring group-commit"]:::open
I10["10 HTTP service layer (lowers onto the framework)"]:::open
I32["32 WAL checkpoint (append-only today; bounds replay)"]:::open
I33["33 single-file store WO_DATA=<path>.db (driver-only, off-chain)"]:::open
I25["25 HTTP service layer — `service` blocks (⏸ hold 2026-08-21; story file removed, plan remains)"]:::parked
I11["11 fibers ✅ 2026-08-21"]:::done
I12["12 blue-green deploy"]:::open
I13["13 metaprogramming @derive"]:::open
I14["14 skillhost workload (demoted)"]:::open
I9g["27 query grammar corpus (likely collapses)"]:::open
GAPS["14's gap fan-out: bounded subprocess, stdin/stdout transport, fs metadata, FFI-vs-out-of-process"]:::open
DRAIN["post-12 parked drain: pub(read)/using/#if, WO-E225, ADT roster, group-by"]:::parked
I26["26 blue-green deploy (⏸ hold)"]:::parked
I29["29 metaprogramming @derive (⏸ hold)"]:::parked
I28["28 skillhost workload (⏸ hold; demoted)"]:::parked
I38["38 content platform capabilities: fs mutation verbs + net.connect"]:::open
I9g["27 query grammar corpus (⏸ hold; likely collapses)"]:::parked
I30["30 observability, CI, fuzz — release-only CI exists; per-change gates + fuzz open (no story file)"]:::open
GAPS["28's gap fan-out: bounded subprocess, stdin/stdout transport, fs metadata, FFI-vs-out-of-process"]:::open
DRAIN["parked drain, what is LEFT of it: WO-E225 roster, ADT roster, group-by aggregates"]:::parked
FOUND --> I7
FOUND --> I9
@ -53,34 +70,53 @@ flowchart TD
I15 --> I17
I16 --> I17
I17 --> FWREORG
I16 --> I37
I19 --> I37
I17 --> RELEASE
I9 --> I18
I16 --> I18
I9 --> I9c
I9c --> I9d
I9c --> I10
I9b --> I10
I16 --> I10
I9c --> I25
I9b --> I25
I16 --> I25
I9b --> I9e
I7b --> I8
I9e --> I9f
I8 --> I9f
I8 --> I11
I9 --> I12
I10 --> I12
I9g --> I14
I7 --> I14
I14 --> GAPS
I12 -.scope directive.-> I13
I12 -.scope directive.-> DRAIN
I19 --> I34
I34 --> I24
I8 --> I24
I11 --> I24
I35 --> I24
I31 --- I24
I9f --> I32
I32 --- I33
I9 --> I26
I25 --> I26
I9g --> I28
I7 --> I28
I28 --> GAPS
I16 --> I38
I32 --> I38
I36 -.reopens the pure-wo HMAC question.-> I34
I26 -.scope directive.-> I29
I26 -.scope directive.-> DRAIN
```
Reading it: **18 is the only spec-approved open node with all
prerequisites green — the next implementation.** After 18: 20/21 and 22
are startable (chosen order: 20/21 first — half-built branches rot).
17 unparks on directive: its prerequisites landed, its spec+plan wait on
branch `library-internal`, and its landing brings the framework reorg
node with it. 13 and the parked drain sit behind 12 by the 2026-08-08
scope directive (dashed), not by any technical edge.
Reading it: **the live slice is 24** (chat + actor lifecycle, absorbing 31
and 34), and the chain behind it is 23 → 32. Everything else with all-green
incoming arrows is startable: **33** (driver-only, off-chain), **38** (the
fs-mutation and outbound-socket gaps), and **30**'s remaining half
(per-change CI and fuzzing — the release pipeline covered only publishing).
**36** needs no work, only the developer's manual pass over
`docs/examples/operators/`. The held tail — 18, 20/21, 25, 26, 27, 28, 29 —
resumes on its own precedence notes; 29 and what is left of the drain still
sit behind 26 by the 2026-08-08 scope directive (dashed), not by any
technical edge. Note what left the drain: `pub(read)`, `using` and `#if` all
shipped, so only the WO-E225/ADT rosters and group-by aggregates remain in
it.
## 2. The concurrency chain (iterations 8 / 23 / 11 and everything they gate)

535
docs/00-doc-audit.md Normal file
View file

@ -0,0 +1,535 @@
# Documentation truth audit — 2026-08-26
> **Status: findings resolved 2026-08-26, same day.** Every section below was
> acted on; see [What was fixed](#what-was-fixed) at the end for the
> disposition of each, including **one row where this audit was wrong and the
> document it accused was right** (B3's `WO-W201` claim). The findings are kept
> as written — a fix list whose findings have been edited away cannot be
> checked. `just linkcheck` went from 77 broken paths to 23, and all 23 that
> remain are in `.dev/`, which this repo does not author.
>
> A separate structural directive landed the same day and **removed the status
> folders** (`done/`, `refine/`, `hold/`, `in-progress/`) — status now lives only
> in frontmatter. Paths of the form `…/done/NN-*.md` quoted in the findings below
> were correct when written and no longer resolve; see
> [Structural change 2026-08-26](#structural-change-2026-08-26--status-folders-removed).
Scope: every `*.md` that documents THIS repo — root `README.md`, the numbered
`docs/0*.md`, `docs/guides/`, `docs/stories/`, `docs/plan/`, `docs/examples/`,
`docs/superpowers/`, the code-directory READMEs and `CODE-LOGIC.md` files,
`tests/corpus/README.md`, `bench/compare/go-sqlite/README.md`,
`scripts/install-readme.tmpl.md`, `.claude/agents/database-developer.md`.
Excluded, and why: `.dev/skills/` (vendored copies of plugin skills, not ours),
`.dev/reference/` (other people's codebases), `.superpowers/sdd/` (dated task
reports — snapshots, correct as history).
Method: claims were checked against the tree, not read off prose. `woc`
(`compiler/_build/default/bin/woc`) and `wovm` (`runtime/wovm`), both built
2026-08-25, were run against every sample; `justfile` recipes, `wob.h`
constants, `types.ml`'s builtin tables, story frontmatter and
`scripts/linkcheck.py` were used as ground truth. Nothing in this report is
inferred from another document.
Verdict: **the deep reference docs are in good shape; the front door is not.**
`docs/guides/language-surface.md`, `docs/plan/oop-vm/08-builtin-surface.md`, the
three `CODE-LOGIC.md` files and the status board's tables track the code
closely. The root `README.md`, `runtime/README.md`, `docs/00-code-review.md`,
`docs/00-dependency-graph.md` and two example status banners describe a repo
that stopped existing between one and six weeks ago.
No document was changed by the audit pass itself — the findings below record the
tree as it stood before any fix. What was then changed in response is listed in
[What was fixed](#what-was-fixed).
---
## A. Wrong about shipped features (the highest-cost class)
### A1. `README.md` — the Roadmap lists three landed features as unavailable
`README.md:326-342` is headed "Planned, **not yet available**", and
`README.md:10-14` promises "Features that are planned but **not yet available**
are listed separately under Roadmap — they are not described as if they work."
Three of its six entries have shipped:
| README claim | Reality |
| --- | --- |
| `:336` "**Concurrency** — a shard-actor runtime and green-threaded fibers." | Both landed 2026-08-21 (iterations 8 and 11, both `done/`). `spawn` is a lexer keyword (`lexer.ml:163`); `send`/`call` are builtins (`WO_B_SEND=69`, `WO_B_CALL=88` in `wob.h`); `actor M` is a type (`types.ml`'s `TActor`). Gates exist and run: `just fibers`, `just db-actor`. `runtime/src/park.c` is the parking implementation; `runtime/test/test_fiber.c` and `test_mailbox.c` are its unit suites. |
| `:335` "**HTTP service layer** — `service` blocks that route requests to methods." | The *`service` block syntax* is genuinely absent — that half is honest. But it sits under a banner that also denies HTTP entirely, which is false (see A2). |
| `:332` "**Query aggregates** — `group … by … into g`" | **This one is correct.** `types.ml:2220,2241` rejects it: "group-by aggregation is not supported yet". Kept here only because `docs/guides/language-surface.md` contradicts it — see C1. |
### A2. `README.md:33-37` — "does not serve HTTP, WebSockets, or a UI"
> "writeonce is **not** a web framework and does not (yet) serve HTTP,
> WebSockets, or a UI."
Contradicted 30 lines later by its own §"Worked examples" (`:306-313`), which
describes `docs/examples/porch/` as "a web framework written in
writeonce (HTTP/1.1 …, router with `:param` captures, interface-based
handlers)" and `just web-app` as its gate. Also contradicted by:
- iteration 16 (web framework) and 37 (wo-html components), both `done/`;
- `docs/examples/site/` — server-rendered pages, gated by `just site`;
- WebSockets: `docs/examples/porch/http/ws.wo` (`ws_accept`, the 101
hijack sentinel) and `http/wsframe.wo` (a pure-`.wo` RFC 6455 frame codec),
both landed per `docs/in-progress/2026-08-23-chat-ws-lifecycle.md` (T6, T7).
### A3. `README.md:30` — "no package manager"
> "**Small on purpose.** No FFI, no package manager, no framework."
Same page, `:265-281`, documents `[deps]`, `.wo-deps/<name>/`, `wo.lock` and
`woc --update-deps`. Iteration 15 (deps package manager) is `done/`;
`just deps-accept` is its gate. "No framework" is contradicted by `:306`.
The intended claim is presumably "no *registry*" — which is true and is what
`docs/00-code-review.md:77` says.
### A4. `docs/examples/employee/README.md:3` — "does not compile on today's toolchain"
> "**Status: target workload — does not compile on today's toolchain.** …
> It becomes buildable when iteration 9 … and iteration 9b … land."
Both landed. `woc docs/examples/employee/` exits 0. `just employee` is a
first-class acceptance gate, and the `justfile:88-91` comment calls it "the
database track's acceptance workload". The banner is ~2 weeks stale.
### A5. `docs/examples/log-watcher/README.md:12-20` — same shape
> "**Status: design artifact — the spec's forcing function.** The systems
> track is approved, pre-implementation. Today's `woc` (milestone 1) …
> diagnoses the adopted surface as WO-E101: `use`, `typedef`, standalone union
> aliases …, `pub(read)`, `switch`, `try`."
Every one of those forms is shipped (`docs/guides/language-surface.md` §2–§5,
verified in `lexer.ml`/`parser.ml`). `woc docs/examples/log-watcher/` exits 0.
`just log-watcher` is the gate the `justfile:82-87` calls "the test the whole
track exists to pass".
Same file, `:8-9`: "the five builtin stdlib modules — `fs`, `proc`, `net`,
`time`, `json`". There are **six** (`types.ml:206`): `env` is missing.
### A6. `runtime/README.md` — describes the pre-2026-08-18 world
The directory's orientation README is still the old `wo-rt-c` prototype page
with the VM bolted on at `:78`. Concretely wrong:
- `:31` "Or from the repo root: `just rt-c-demo`" and `:60` "`just rt-c-bench`"
— **neither recipe exists.** The justfile has 16 recipes plus two `mod`s;
no `rt-c-*` among them.
- `:5,:83` "the production Rust runtime (`crates/rt/`)", `:76` "This file is for
reading; `crates/rt` is for running writeonce" — the Rust runtime was removed
2026-08-18 (`docs/08-project-structure.md:11`).
- `:3` `prototypes/wo-db/`, `:43` `docs/runtime/database/03-inmemory-engine.md`,
`:47` `docs/plan/09-concurrency-scaleout.md` — none of these paths exist
(also in the link audit's sections B/C/E).
- `:84` "**`@gc` reference counting** + budgeted cycle collection … Bacon–Rajan
trial deletion" — retired by iteration 7b. `@gc` on a class is now
**rejected** (`docs/guides/language-surface.md:73`), and
`runtime/src/CODE-LOGIC.md` states the replacement outright: "incremental
tri-color mark-sweep … (iteration 7b — RC and Bacon–Rajan are gone)".
- `:89` "`DB_STUB` traps 'engine not linked' until the DB engine binds (plan
5)" — the engine bound in iteration 9. The opcode survives
(`wob.h:232`, `vm.c:1806`) but the sentence reads as "no database yet".
- `:89` builtin list "`now/print/print_int/words/multi_*/map_*`" — there are
now ~70 free builtins plus six module namespaces (`types.ml:784-849`).
- File map (`:92-107`) omits four of the thirteen sources in `runtime/src/`:
`crypto.c/.h`, `json.c`, `park.c/.h`, `sysio.c`.
- `:105` and `:117` "13 suites" / "one of the 13 ASan test binaries" — there
are **18** (`runtime/test/test_*.c`).
- `:1` "now at **phase E**" vs `:57` "A → B → C → D → E → F, all ✅ shipped"
vs `:60` "Measured (phase F…)" — three answers in one file.
Verified-correct in the same file, for contrast: `WO_HEAP_MB` / 64 MiB arena,
the four `.vscode/launch.json` configs, the exit-code contract, and the
`make -C runtime` targets.
---
## B. Verification tables that no longer verify
### B1. `docs/00-code-review.md` — the 2026-08-20 table has decayed
The doc's value is that it *checked* a critique line by line. Seven rows of
`:55-82` are now false, and the doc carries no superseded banner:
| Row | Then | Now |
| --- | --- | --- |
| "no `Float`, no `Bytes` \| absent from `compiler/src/types.ml`" | true | `types.ml:174` `builtin_scalars = ["Int";"Bool";"Text";"Timestamp";"Id";"Float";"Bytes"]` — iteration 19, `done/` |
| "`send` is one-way \| `WO_B_SEND=69` is the last builtin (`WO_B_MAX 69u`)" | true | `WO_B_MAX 95u`; `WO_B_CALL = 88` is a send that parks for a typed reply |
| "no crypto primitives \| none" | true | `WO_B_SHA1=85`, `WO_B_SHA256=86`, `WO_B_HMAC_SHA256=87`; `runtime/src/crypto.c`; `runtime/test/test_crypto.c` |
| "22's battery never run \| … no `bench/baseline.json`, no `just db-bench`" | true | `bench/baseline.json` exists, `just db-bench` / `db-bench-quick` exist, 30+ result files in `bench/results/`, iteration 22 is `done/` |
| "no fuzzing, no CI \| **no `.github/`**, no fuzz target" | true | `.github/workflows/release.yml` exists (fuzzing still absent) |
| "one framework, five samples, one consumer" | true | 13 sample projects under `docs/examples/` |
| "accept on one shard \| one listener, `SO_REUSEADDR` only" | true | iteration 35 landed `serve_conn` + fiber-per-connection |
| `:46-51` "The multi-shard DB gap is structural … `wo_builtin_db` returns `WO_T_DB` 'database engine not initialized'" | true | that string is gone from `runtime/src/`; arc stage 3 landed the transparent DB actor, gated by `just db-actor` |
Rows that still hold, checked: interpreted-only/no JIT, no SIMD, the
`WO_STACK_SLOTS 4096` / `WO_MAX_REGS 64` / `WO_MAX_FRAMES 256` correction, no
generics, no closures, byte strings, no Result type, switch-not-destructuring,
no supervision, growable mailboxes, no `timerfd`, no TLS, no debugger/LSP,
deps-are-git-rev-only, blue-green is a future, TSan covers one demo. And
**`map<K,V>` lookup is still a linear scan** — `cont.h:1-6` says so in as many
words.
### B2. `docs/00-link-audit.md` — numbers and paths both stale
Dated 2026-08-20. `just linkcheck` today reports **files=235, local=675,
broken=77, bad anchors=0**; the doc's table says 206 / 569 / 88. Its own
sections B–F sum to 77, not the 88 its prose claims twice (`:12`, `:145`) —
an internal arithmetic error independent of the drift.
Its repair table (`:29-37`) references paths that have since moved:
`docs/00-status.md` (now `docs/stories/00-status.md`), and
`refine/{08,11,19,20,21}` (now under `done/` and `hold/`).
Two broken links exist today that the audit does not account for:
- `docs/examples/employee-list/README.md:5,6` → `…/refine/20-cross-program-tables.md`
and `…/refine/21-keypair-attach-auth.md`; both files are now in `hold/`.
- `docs/stories/language-runtime-database/hold/26-blue-green-deploy.md:9` →
`00-story.md`; the sibling stopped being a sibling when 26 moved into `hold/`.
Conversely `docs/00-principles.md:57,77,78,87` — four links the audit lists as
open — resolve now.
### B3. `docs/plan/oop-vm/01-error-catalog.md` — not the complete catalog it claims
`docs/guides/language-surface.md:8` calls it "every diagnostic";
`compiler/README.md:39` says "every shipped code is cataloged" there. Ten
codes the compiler emits are absent:
| Code | Defined at | What it is |
| --- | --- | --- |
| `WO-E003` | `lexer.ml:56` | `#if`/`#else`/`#end` misuse |
| `WO-E108` | `bin/main.ml:277` | `internal/` crossed at a `[deps]` boundary |
| `WO-E109` | `bin/main.ml:275` | unknown `wo.toml` `kind` value |
| `WO-E219`–`WO-E223` | `types.ml` | five type-pass codes |
| `WO-E226` | `types.ml:443` | `call`'s reply type through actor-`M` erasure (iteration 24) |
| `WO-E250` | `types.ml:435` | the whole query surface (iteration 9b) |
`WO-E250` is the notable one: it is the only diagnostic the shipped query
language produces, and it is the code a reader hits first when they mistype a
query. Meanwhile `WO-W201` is still catalogued and no longer exists — the
inferred-GC plan (`docs/superpowers/plans/2026-08-18-inferred-gc-mark-sweep.md:151`)
listed "retire WO-W201" as an amendment; the code went, the catalog entry
stayed.
`WO-E004`/`WO-E005` (raw text literal, iteration 37) *are* catalogued —
checked, since `language-surface.md:29,31` depends on them.
---
## C. Docs that disagree with each other
### C1. Is group-by shipped? Two live docs, two answers
- `README.md:332` — Roadmap, "not yet available". **Correct.**
- `docs/guides/language-surface.md:175` — "Present today: from / where / order
/ take / select **plus group-by aggregation**." **Wrong**, and the same page
opens (`:16-17`) with "Every form listed below was compiled and run against
`woc`/`wovm` while writing this page, not read off the parser and hoped for."
The `group … by … into` clause in its §6 grammar block *parses*
(`parser.ml:1137-1144`) and is then rejected by the typechecker
(`types.ml:2241` "group-by aggregation is not supported yet";
`types.ml:2222` for the navigation form).
Reproduced: `docs/examples/employee-list/main.wo:38-41` uses `group e by
e.dept into g` and fails to compile.
`docs/00-dependency-graph.md:45` correctly still lists group-by under the
parked drain.
### C2. `docs/stories/00-status.md` — the narrative and the table disagree about iteration 24
The board's *Current work* table is right: `:314` records "🔄 **iteration 24
(absorbing 31 + 34): chat + actor lifecycle** — spec + plan approved
2026-08-23 … executing on branch `chat-ws-lifecycle`" and links the marker.
The ▶ NEXT PLAN narrative above it is not:
- `:82-85` "Next slice: **iteration 31, actor lifecycle** — its spec brainstorm
is the next act". 31 was absorbed into 24 by directive, and its
actor-death half already landed (`docs/in-progress/2026-08-23-chat-ws-lifecycle.md:20-23`,
commit `ed69841`).
- `:125` "**Next steps:** 31 (lifecycle spec brainstorm) → 24 (chat) → 23 → 32"
— same stale ordering.
- `:286` iteration 24 marked "⬜ fourth in chain … after 31".
- `:290` iteration 34 marked "⬜ off-chain but GATES 24". T1 crypto landed
(`d14fa9f`); `sha1`/`sha256`/`hmac_sha256` are in both `types.ml:847-849`
and `wob.h:461-463`. The gate is cleared —
`docs/00-dependency-graph.md:169` already says so.
Also missing from the board entirely: the 2026-08-25 packaging/release track.
`VERSION`, `scripts/mkdist.sh`, `just dist`, `just install-accept`,
`.github/workflows/release.yml`, `docs/guides/releasing.md` and `dist/writeonce-0.1.0-linux-amd64.tar.gz`
all exist; the last five commits are that work; no standup entry covers it.
### C3. `docs/00-dependency-graph.md` — the main graph is a generation behind
Its own header (`:3`) defers state to the board, but it paints state inline
anyway, and the first mermaid graph paints it wrong:
- `:29` `I17["17 library kind + internal/ (**PARKED** — spec+plan ready…)"]:::parked`
— story 17 is in `done/` with `status: done`; board `:302` reads
"✅ **landed 2026-08-20**".
- `:31` `I18[… (spec APPROVED — **the next implementation**)]:::specd` — story
18 is in `hold/`; board `:303` reads "⏸ hold (2026-08-21)".
- `:33,34` iterations 20/21 as `open` — both `hold/`.
- `:38,40,41,42` use the pre-renumber ids: "10 HTTP service layer", "12
blue-green deploy", "13 metaprogramming @derive", "14 skillhost workload".
The stories are **26**-blue-green-deploy, **29**-compile-time-metaprogramming,
**28**-skillhost-host-workload; no story numbered 10, 12, 13 or 14 exists.
- `:45` `DRAIN["post-12 parked drain: pub(read)/using/#if, …"]` — `pub(read)`
(`ast.ml:118-123`), `using` (`lexer.ml:164`) and `#if` (`lexer.ml:245-249`)
all shipped.
- The main graph has no node for iterations 19, 24, 31, 32, 33, 35, 36 or 37.
Four of those are `done/`. The later sub-graphs *do* cover 34/35/36
correctly (`:141,164-170`), so the drift is confined to the first graph.
---
## D. Structural claims that don't match the tree
### D1. `docs/08-project-structure.md` — "canonical map", four divergences
- `:39` "`plan/` compiler-track docs: architecture.md + the woc plans" under
`compiler/`. **`compiler/plan/` does not exist**; those docs live at
`docs/plan/compiler/` — which the same file's `:49` links correctly.
- `:22,76-77` `tests/corpus/` as "`run/`, `compile-fail/`, `trap/`, `gc/`".
There are nine directories: also `actor/`, `db/`, `lang/`, `sys/`,
`sample-logwatcher/` — all five **empty**. `tests/corpus/README.md:11-15`
documents them as planned per-plan additions, so the corpus README is the
honest one; the structure doc undercounts and neither mentions that five are
placeholders. (Live fixture counts: run 60, compile-fail 46, trap 5, gc 2.)
- `:79-81` `scripts/` as "`oop-e2e.sh`, `mkdist.sh` + `install-accept.sh`, and
the per-sample acceptance scripts (`employee-accept.sh`,
`log-watcher-accept.sh`)". There are 14 scripts; unmentioned:
`db-actor-accept.sh`, `db-bench.py`, `deps-accept.sh`, `fibers-accept.sh`,
`linkcheck.py`, `single-binary-smoke.sh`, `site-accept.sh`,
`web-app-accept.sh`.
- `:89` `examples/` as "log-watcher/, employee/, employee-list/ samples" —
there are 13.
- `:87` puts status at the `docs/` root; it is `docs/stories/00-status.md`
(the file's own `:5` links it correctly). The root also has
`00-dependency-graph.md` and `00-link-audit.md`, unlisted.
- The one-page map (`:17-29`) omits four tracked root entries: `bench/`,
`dist/`, `.github/`, `.claude/`.
- `docs/examples/db-actor/` has `main.wo`, a `wo.toml` and a gate
(`just db-actor`) but **no README** — the only sample without one.
Correct in the same file, checked: `runtime/wo-rt.c` exists; `.dev/` is
gitignored except `.dev/README.md` (`git ls-files .dev` returns exactly one
path) and `.dev/reference/` does hold `crates/`, `colibri/`, `llama-cpp/`,
`linux/`, `go/`.
### D2. `compiler/README.md` — stage banner and CLI list both behind
- `:5` "**Stage: plan 3 … complete, Tasks 1–6 + 8**". Plan 3 closed in early
August; the front end has since taken iterations 15, 17, 19, 24, 34, 35, 36
and 37. A reader takes this page as the compiler's current extent.
- `:26-34` "Running `woc`" omits four of the nine modes the binary's own
`usage_msg` prints: `--dump-gc`, `--update-deps <dir>`, `version`, and the
`woc <dir>` manifest build (the mode `README.md:111` teaches as the primary
one). `-D <name>`, the `#if` flag setter documented at
`language-surface.md:34`, is in neither the README nor `usage_msg` —
it exists at `bin/main.ml:1162`.
- `:37` "same contract as `wo run` (`crates/rt/src/lib.rs::discover`)" — the
Rust runtime is gone. The identical stale sentence is also the doc comment
at `compiler/bin/main.ml:16-19`. *(Code, not markdown — noted, not
changed.)*
Correct: the `=== path ===` multi-file header (`dump.ml:128`), the five golden
stages, the exit-code contract, OCaml 4.14 / dune 3.14 (`dune-project` says
`(lang dune 3.14)`).
### D3. `runtime/src/CODE-LOGIC.md` — file table missing two sources
`:12-25` is a complete-looking table of "the files, in dependency order" and
omits `park.c/.h` (fiber parking — iteration 11, and central to how blocking
builtins work) and `crypto.c/.h` (iteration 34). The doc is dated 2026-08-14,
before both; nothing marks it as of-that-date beyond the first line.
`database/src/CODE-LOGIC.md` and `compiler/src/CODE-LOGIC.md` were checked
against their sources and hold up.
---
## E. Runbook and instruction errors
`docs/guides/releasing.md`, authored 2026-08-25, contains steps that cannot be
followed:
- `:110` (step 11) "Remove `--draft`, commit, push." **`.github/workflows/release.yml`
contains no `--draft`** — `gh release create` at `:135-139` passes only the
two asset paths, `--title` and `--generate-notes`. Nothing to remove.
- Steps 5, 6 and 10 disagree with each other. `:50-54` (step 5) says the
rehearsal is a `workflow_dispatch` run that "skips the tag guard and the
publish step"; `:56` (step 6) says "nothing to undo — a dry run creates no
tag and no release"; `:89-92` (step 10) then instructs
`gh release delete v0.0.0-test --yes` and two tag deletions. There is no
path in the workflow that creates `v0.0.0-test`.
`.github/workflows/release.yml:1-2` — "this workflow has never run. Authored
2026-08-25 and not executable locally" — is contradicted by `:43` of the same
file ("The first run failed here with `dune: command not found`") and by
commits `05fafd3`, `d66087d`, `4470f03`, which are fixes read off real runs.
*(Code comment, not markdown.)*
Verified correct against the workflow and the built binaries: the asset name
`writeonce-0.1.0-linux-amd64.tar.gz`, the tag↔`VERSION` guard, the
`ubuntu-22.04` pin and its glibc reasoning (this machine's binaries need
`GLIBC_2.38`, matching `:8` step 8's "2.38 from this dev machine"), and
`scripts/install-readme.tmpl.md:24-25` — `woc version` prints
`writeonce 0.1.0 linux/amd64` and `wovm --version` prints `wovm 0.1.0`, exactly
as documented.
---
## F. Small factual errors
| Where | Claim | Actual |
| --- | --- | --- |
| `README.md:28` | "~100 KB for the sample programs" | 163–254 KB. Smallest built sample 163,117 B (`fibers`), largest 253,808 B (`site`); bare `wovm` is 161,848 B, so ~160 KB is the floor |
| `README.md:142` | "**Types:** `Int`, `Text`, `Bool`, and user `class` types" | seven builtin scalars (`types.ml:174`): also `Float`, `Bytes`, `Timestamp`, `Id` |
| `README.md:170` | `time` → "`sleep`, `now`, `local`, `iso`" | also `ticks` (µs monotonic, builtin 84 — iteration 22's one runtime addition) |
| `README.md:172` | `net` → "TCP `listen`/`accept`/`read`/`write`/`close` (host + port)" | also `read_dl`, `accept_dl`, `write_dl`, `listen_unix`, `peer` (ids 91–95, iteration 35) |
| `README.md:344` | "`net` is TCP host+port only" | `net.listen_unix` binds a unix socket (builtin 94) |
| `README.md:272,276-277` | `[deps]` key `niceserve`, then "`use niceframework`" | the `[deps]` KEY *is* the module name — `docs/examples/web-app/wo.toml:19-20` keys it `serve` and `main.wo:9` says `use serve`. The example as written would not compile |
| `README.md:295` vs `:298-320` | "**Two** complete sample programs" | three bullets follow; `:322` "Read **either** program's `main.wo`" compounds it. There are 13 samples, 8 of them gated |
| `docs/guides/language-surface.md:36` | "**Keywords (35)**" | 37. The list printed immediately after is complete and correct against `lexer.ml:147-184` — only the count is wrong |
| `docs/00-principles.md:104-105` | capabilities are "(`fs`, `proc`, `net`, `time`, `json`)" | six modules — `env` missing (`types.ml:206`) |
| `docs/superpowers/plans/2026-08-18-inferred-gc-mark-sweep.md:153-154` | `[x]` "Add a `docs/examples/gc-cycle` acceptance script + `just gc-cycle` recipe" / `[x]` "Verify: `just gc-cycle` green" | **no `gc-cycle` recipe exists** and there is no `scripts/gc-cycle-accept.sh`. `docs/examples/gc-cycle/` has sources, a `target/` and a "Run status" section (`README.md:186`) but no gate. Two checked boxes for work that did not land |
Forward references that are correctly labelled and are *not* findings:
`just chat` (iteration 24, T9 — pending in the marker), `just oop-parity`
(deferred by explicit decision, recorded at `compiler/README.md:5`), and
`docs/examples/employee-list/`, whose banner honestly says it does not compile
(confirmed: `woc` exits 2 on `[connect.employee]`, a section for the `hold/`
iteration-20 feature).
---
## What to fix first
1. **`README.md`** — it is the writeonce.de landing content
(`docs/08-project-structure.md:28`), so A1–A3, F's README rows and the
`use niceframework` example are the highest-value corrections in the repo.
2. **The two example status banners** (A4, A5) — one line each, and they
currently tell a visitor that the repo's two flagship gates don't build.
3. **`runtime/README.md`** (A6) — the largest single body of stale prose. It
wants splitting: `wo-rt.c` is a historical reference card, `runtime/src/` is
the shipped VM, and one page is trying to be both.
4. **`docs/00-code-review.md`** and **`docs/00-link-audit.md`** (B1, B2) — both
are dated verifications whose value depends on being re-run. Either re-run
them or banner them as of-date.
5. **`docs/plan/oop-vm/01-error-catalog.md`** (B3) — it is cited as normative by
two other docs; ten missing codes including the query surface's only one.
6. **`docs/guides/language-surface.md:175`** (C1) — a single false clause on
an otherwise excellent page.
7. **`docs/00-dependency-graph.md`** first graph (C3) and the board's NEXT PLAN
narrative (C2) — both trail their own companion tables.
---
## What was fixed
All on branch `docs-truth-audit-fixes`, 2026-08-26. Docs only — no code changed,
so no gate output changed.
| Finding | Disposition |
| --- | --- |
| **A1** README roadmap listed shipped concurrency | Concurrency entry removed; `spawn`/`send`/`call`/`receive` documented under "Language at a glance" as shipped. The `service`-blocks entry stayed but now says what you write *instead* today. Group-by stayed — it was the one correct entry. |
| **A2** "does not serve HTTP, WebSockets, or a UI" | Rewritten: both work, as `.wo` libraries consumed through `[deps]`, never as runtime features — which is the real (and more interesting) claim. TLS-by-proxy stated. |
| **A3** "no package manager" | Now "no package **registry** — dependencies are exact-rev git URLs and nothing else", which is true and is the distinction the doc meant. |
| **A4** `employee` README "does not compile" | Banner flipped to shipped, `just employee` named as the gate, with the one genuinely-ahead clause (`group … by … into`) called out rather than left to surprise a reader. |
| **A5** `log-watcher` README "design artifact" | Banner flipped to shipped with iteration 7's actual acceptance evidence; the "five stdlib modules" list corrected to six. |
| **A6** `runtime/README.md` a generation stale | **Restructured, not patched.** Leads with `wovm`; `wo-rt.c` demoted to a marked "Historical" section that keeps its measured numbers as the prototype record they are. Fixed: the two nonexistent recipes, `crates/rt`, `prototypes/wo-db`, the `@gc` refcount/Bacon–Rajan description (now inferred mark-sweep), `DB_STUB`, the builtin list, "13 suites" → 18, four missing source files, and the phase E/F contradiction. Three broken links went with it. |
| **B1** `00-code-review.md` decayed | History kept intact with a pointer at the top; a **Re-verification 2026-08-26** section added listing the eight overtaken rows against source, the ~20 that still hold, and the two new gaps (now iteration 38). "No supervision/actor death" is marked *partly* overtaken — death landed, supervision did not. |
| **B2** `00-link-audit.md` stale | Re-run and rewritten. The 48 dead-era exploration links are **resolved by de-linking, not re-pointing** — their prose names the retired plan by number, so re-targeting would have made each sentence lie. A successor map was added to `plan/discarded.md`, which is what that report's own "Still open" note asked for. Three more fixable breaks fixed. 77 → 23. |
| **B3** ten codes missing from the error catalog | Added with definitions read from source: WO-E003, E108, E109, E219–E223, E226, E250. Header's "as of plan 3" scope line corrected. The Completeness method section now records *why* the sweep rotted — codes are built as `<stage>_prefix ^ "NN"`, so grepping for the literal `WO-E250` finds only a comment. **The `WO-W201` half of this finding was wrong:** the catalog already marked it *(retired, iteration 7b)* with "*(no longer emitted)*". The doc was right; the audit misread its own grep. |
| **C1** `language-surface.md` claimed group-by works | Corrected in three places: the clause is marked in the grammar block, the "present today" list drops it, and the page's "every form was compiled and run" promise now names the exception. Keyword count 35 → 37. |
| **C2** board narrative trailed its own tables | ▶ NEXT PLAN rewritten: the live slice is 24 (absorbing 31 + 34), not "31 next". Rows for 24, 31 and 34 updated — 31's remaining surface is cited as the *reserved holes at ids 89/90*, which is machine-checkable. A standup entry for the 2026-08-25 packaging/release track was added; it had none. |
| **C3** dependency graph a generation behind | Graph 1 rebuilt: 17 → done, 18/20/21 → held, the pre-renumber ids 10/12/13/14 replaced by stories 25/26/29/28, nodes added for 19/24/30/31/32/33/34/35/36/37/38, and the parked drain reduced to what is actually left (`pub(read)`/`using`/`#if` all shipped). Node/edge references validated. |
| **D1** `08-project-structure.md` "canonical map" | Fixed: the nonexistent `compiler/plan/`, the corpus's nine directories (four with fixtures, five reserved and empty, with counts), all 14 scripts, the `docs/` subtree, and the four missing root entries (`bench/`, `dist/`, `.github/`, `.claude/`). The sample-acceptance list now names all eight gates. |
| **D2** `compiler/README.md` stage banner + CLI | Banner replaced with the eight iterations the front end has taken since plan 3. The CLI list gained `woc <dir>` (the primary mode), `version`, `--update-deps`, `--dump-gc` and `-D`. `crates/rt/src/lib.rs::discover` reference dropped. `gcinfer` added to the module list. |
| **D3** `runtime/src/CODE-LOGIC.md` file table | `park.c/.h` and `crypto.c/.h` added, dated so the gap is visible rather than papered over. |
| **E** `releasing.md` unfollowable steps | The `--draft` step and the phantom rehearsal cleanup deleted, remaining steps renumbered, and a paragraph added explaining why neither exists (plus how to opt into a draft if you want one). |
| **F** small factual errors | All corrected: binary size ~100 KB → 160–260 KB (measured), the scalar list, `time.ticks`, the five missing `net` members, the `fs` read-and-append limit, the `[deps]` key/`use` mismatch (the example would not have compiled), "two samples" → 13 with 8 gated, the six-module count in `00-principles.md`, and the `just gc-cycle` recipe that two checked boxes claimed. |
| **Later findings** | `00-story.md` gained the missing iteration 36 row; `docs/examples/db-actor/` gained the README it never had. |
### Left deliberately unfixed
- **23 broken links in `.dev/`** — vendored plugin-skill copies and reference
study trees. `.dev/` is gitignored (`git ls-files .dev` returns one path), so
these are per-developer notes. Fixing them means re-vendoring the skills with
their `references/` subdirectories.
- **The `just gc-cycle` gate itself.** The false checkbox is now disclosed in
both the plan and the sample's README, but wiring the acceptance script is
work, not documentation, and belongs to whoever picks up that loose end.
- **`tests/corpus/`'s five empty directories.** Documented as reserved with the
plan each was to be filled by; deleting or filling them is a test decision.
- **Two stale references in code comments**, recorded here rather than edited
because this pass was scoped to markdown: `compiler/bin/main.ml:16-19` and
`compiler/src/parser.ml:202` both still cite `crates/rt/src/*.rs`, removed
2026-08-18; and `.github/workflows/release.yml:1-2` says "this workflow has
never run" while line 43 of the same file reports what its first run failed
with.
---
## Structural change 2026-08-26 — status folders removed
Directive from the developer, applied after the fixes above: **`docs/` no longer
uses directories to encode status.** The four story subfolders
(`done/`, `refine/`, `hold/`, `in-progress/`) and top-level `docs/in-progress/`
are gone. All 34 story iterations sit flat in
`docs/stories/language-runtime-database/`, the slice marker sits flat in `docs/`
as `active-slice-<date>-<topic>.md`, and each file's `status:` frontmatter key is
the single place its state is recorded.
This reverses the 2026-08-20/21 convention ("the folder move IS the status
change"). The reason it is a good trade is visible in this repo's own history:
under the old scheme a status change relocated the file, which invalidated every
relative link in and to it — section A of the 2026-08-20 link audit was nine
instances of exactly that, and B2 above found two more that had accumulated
since. A status change is now a one-line edit that cannot break a link.
What the move required, all verified with `just linkcheck` (23 broken, all in
`.dev/`, unchanged from before the move):
- 34 files relocated with `git mv` so history follows them.
- **252 relative links recomputed in 70 files** — not by string substitution but
by resolving each link to an absolute path from its *old* location, remapping
through the move table, and re-deriving it relative to the file's *new*
location. String surgery would have mangled the `../` depth changes on the
moved files themselves.
- Link *text* and backticked paths that named a status folder stripped
separately — a correct target under stale display text is still a lie.
- Convention prose rewritten where it taught the old rule:
`stories/00-status.md`'s header, `stories/board-views.md` (including its Kanban
caveat, which described status changes as folder moves), and
`08-project-structure.md`'s map plus a new naming-convention entry.
- Phrases of the form "moves to `done/`" rewritten as "sets `status: done`" in
the live docs — including the four open checkboxes in the active plan
`2026-08-23-chat-ws-lifecycle.md`, which would otherwise have instructed a
future session to recreate the folders.
Two things surfaced that the move made visible rather than caused:
1. **A frontmatter collision, caught and fixed.** Giving the marker doc
`iteration: "24"` would have put two files in the repo claiming to be
iteration 24 with contradicting `status:` values. The marker is a progress
log, not a status carrier, so it takes `slice: "24"` and points at the story
that owns the status.
2. **Story 24's frontmatter said `refine` while the board said 🔄 live.** Under
the old scheme that drift was cheap to leave; under this one frontmatter *is*
the answer, so it is now `status: in-progress`. Iterations 31 and 34 keep
`refine` — they are absorbed into 24 but their own closeout is still pending,
which is what 24's T10 exists to do.
Dated records were deliberately left naming the old paths: the findings sections
of this document (which declare themselves a pre-fix snapshot), the history
section of `00-link-audit.md` (which says every path in it is as it was on that
date), and the "Files:" lists of closed plans. Rewriting those would destroy the
record of what was true when each was written.

View file

@ -1,154 +1,156 @@
# Markdown link audit — 2026-08-20
# Markdown link audit — re-run 2026-08-26
Scope: every `*.md` in the repo (`.git` excluded).
External URLs were not fetched (no network verification performed).
Scope: every repo-authored `*.md`. `.git`, `target`, `dist`, `node_modules`,
`_build` and — since 2026-08-26 — `.dev/` and `.superpowers/` are excluded; see
the note under the table. External URLs are not fetched (no network
verification).
| | files | relative links | broken paths | bad anchors |
|---|---|---|---|---|
| first scan | 207 | 574 | 97 | 0 |
| after section A fixes | 206 | 569 | **88** | 0 |
| first scan (2026-08-20) | 207 | 574 | 97 | 0 |
| after section A fixes (2026-08-20) | 206 | 569 | 88* | 0 |
| **re-run 2026-08-26, before fixes** | 235 | 675 | 77 | 0 |
| **re-run 2026-08-26, after fixes** | 237 | 652 | 23 | 0 |
| **after scoping the gate to repo-authored docs** | 149 | 656 | **0** | **0** |
Section A is repaired and verified. Sections B–F are pre-existing rot and
still open — every one of the remaining 88 lives there.
\* The 2026-08-20 report's prose said 88 twice while its own sections B–F summed
to 77. The 77 was right; the 88 was an arithmetic slip, corrected here.
**The gate is now clean: 0 broken, 0 bad anchors.**
The last 23 were all in `.dev/` — vendored plugin-skill copies and cloned
reference projects, neither of which this repo authors. `scripts/linkcheck.py`
now skips `.dev/` and `.superpowers/` alongside `.git`/`target`/`dist`. That was
forced by adding gofiber/fiber as a reference (2026-08-26): its own docs are
Docusaurus pages whose links resolve at site-build time, not on disk, so the
clone alone contributed 21 broken paths and 39 bad anchors. A gate that reports
the same dozens of failures forever is a gate nobody reads. Everything the repo
actually ships — `docs/`, `compiler/`, `runtime/`, `database/`, `tests/`,
`bench/`, `scripts/`, the root README — is still scanned, and is clean.
Re-check with `just linkcheck`.
Tool: `linkcheck.py` — walks the tree, strips fenced/inline code, extracts inline
links and reference definitions, resolves each relative target, and validates
`#fragment` against GitHub-style heading slugs of the target file.
Tool: `scripts/linkcheck.py` — walks the tree, strips fenced/inline code,
extracts inline links and reference definitions, resolves each relative target,
and validates `#fragment` against GitHub-style heading slugs of the target file.
---
## A. Regressions from the in-flight renumber — FIXED 2026-08-20
## What the 2026-08-26 re-run changed
All nine broke because files moved in the working tree; each had a known
successor. Repaired:
### 1. The dead-era exploration links — RESOLVED (48 links, 15 files)
Sections B and C of the 2026-08-20 report left a decision open: the studies under
`docs/plan/exploration/` cite the old flat `docs/plan/NN-*.md` numbering and the
`docs/runtime/database/` tree, both removed with the Rust track on 2026-08-18,
and no successor map existed. That decision is now made.
**De-linked, not re-pointed.** The link *text* in these studies names the retired
plan by number — `[plan 09a]`, `[plan 11]`, ``[`12-engine-disk-cutover.md`]`` —
so aiming those at a story would have made each sentence assert something false
about a document that never said it. The targets were stripped and the text kept
as plain code spans. The studies still read correctly as the dated records they
are, and they no longer claim a file exists.
The successor map lives in
[`plan/discarded.md`](plan/discarded.md#successor-map-for-the-removed-rust-era-plan-paths)
— one row per retired path, naming what carries that work now (or stating
plainly that nothing does, as with `12-engine-disk-cutover.md` and
`08-sendfile-static-assets.md`). That table is what the 2026-08-20 report's
"Still open" note asked for.
Files touched: `assembly/{00-overview,02-writeonce-stance}.md`,
`c-runtime/{00-plan,01-architecture,02-single-binary}.md`,
`linux/{01-epoll,02-eventfd,03-timerfd,04-signalfd,05-inotify,06-sendfile,07-io_uring,08-mmap,11-memfd_create,12-pwrite-fsync}.md`.
### 2. `runtime/README.md` — RESOLVED (3 links)
`prototypes/wo-db/`, `docs/runtime/database/03-inmemory-engine.md` and
`docs/plan/09-concurrency-scaleout.md` all went when that README was restructured
to lead with `wovm` and demote `wo-rt.c` to a clearly-marked historical section.
It also carried two recipes that do not exist (`just rt-c-demo`,
`just rt-c-bench`) — not a link problem, fixed in the same pass. See
[`00-doc-audit.md`](00-doc-audit.md) §A6.
### 3. Two breaks the 2026-08-20 report did not have — RESOLVED
Both were caused by story files moving between status folders after that report:
| Source | Was | Now |
|---|---|---|
| `docs/00-status.md:167` | `stories/language-runtime-database/05-language-surface.md` | `…/done/05-language-surface.md` |
| `docs/00-status.md:187` | `stories/language-runtime-database/18-memory-db-features.md` | `…/hold/18-memory-db-features.md` |
| `docs/stories/language-runtime-database/00-story.md:60` | `05-language-surface.md` | `done/05-language-surface.md` |
| `docs/stories/language-runtime-database/00-story.md:69` | `18-memory-db-features.md` | `hold/18-memory-db-features.md` |
| `docs/stories/language-runtime-database/25-http-service.md:4` | `../00-story.md` | `00-story.md` |
| `docs/stories/language-runtime-database/26-blue-green-deploy.md:4` | `../00-story.md` | `00-story.md` |
| `.../refine/08-shard-actor-runtime.md:98` | `../hold/09e-durability-throughput-scale.md` | `22-durability-throughput-scale.md` |
| `.../refine/08-shard-actor-runtime.md:100` | `09f-io-uring-commit.md` | `23-io-uring-commit.md` |
| `.../refine/20-cross-program-tables.md:143` | `../hold/09d-keypair-attach-auth.md` | `21-keypair-attach-auth.md` |
| `docs/examples/employee-list/README.md:5,6` | `…/refine/20-cross-program-tables.md`, `…/refine/21-keypair-attach-auth.md` | `…/hold/…` (both stories moved to `hold/` 2026-08-21) |
| `docs/stories/…/hold/26-blue-green-deploy.md:9` | `00-story.md` | `../00-story.md` (the sibling stopped being a sibling when 26 moved into `hold/`) |
The `25`/`26` pair used `../00-story.md` while `00-story.md` is a sibling — the
`refine/`-relative form pasted into files one level up.
This is the recurring shape: **a story folder move breaks every relative link
in and to that file.** Section A of the 2026-08-20 report was nine instances of
it; these are two more. Worth a check in whatever moves a story.
Link labels were renumbered with their targets, since the old IDs contradicted
the new paths: `9e`→`22` and `9f`→`23` in `refine/08` (both the "Gated by the
benchmark" note and settled decision 4, "Order: 22 → the 8+11 arc → 23").
### 4. Stale paths inside the report itself — RESOLVED
## B. Dead era: the old flat `docs/plan/NN-*.md` numbering (48 links)
The 2026-08-20 repair table cited `docs/00-status.md` (now
`docs/stories/00-status.md`) and `refine/{08,11,19,20,21}` (now under `done/` and
`hold/`). That table has been retired into the history section below rather than
carried forward with paths that no longer resolve.
`docs/plan/` now holds only `compiler/`, `exploration/`, `oop-vm/`,
`discarded.md`, `learnings.md`. Every flat-numbered plan doc is gone, and no
successor path was recorded. Missing targets, by inbound count:
### 5. Four links the report listed as open had already been fixed
- `09-concurrency-scaleout.md` — 12
- `11-wal-and-recovery.md` — 9
- `12-engine-disk-cutover.md` — 8
- `10-storage-foundations.md` — 8
- `done/02-event-loop-epoll.md` — 4
- `13-class-model-live-pricing.md` — 3
- `07-inotify-content-watcher.md` — 3
- `08-sendfile-static-assets.md` — 2
- `15-mcp-streamable-http.md`, `16-postgres-mirror.md`,
`done/03-hand-rolled-http.md`, `done/04-cutover-remove-tokio-axum.md` — 1 each
Inbound from: all of `docs/plan/exploration/{linux,postgresql,c-runtime,assembly}/`,
plus `docs/00-principles.md:57,77,78`, `runtime/README.md:47`,
`.dev/reference/README.md:55,56,58`.
**Decision needed** — these exploration docs still cite a plan structure that no
longer exists. Either map each to its story successor
(e.g. concurrency-scaleout → `stories/.../refine/08-shard-actor-runtime.md`,
wal/storage → `refine/22-durability-throughput-scale.md`,
io_uring → `refine/23-io-uring-commit.md`) or strip the links and keep prose.
## C. Dead era: the `docs/runtime/database/` tree (7 links)
`docs/runtime/` does not exist. Missing targets:
- `03-inmemory-engine.md` — 5 (incl. one `#recovery` anchor)
- `02-wo-language.md` — 2 (incl. one `#concurrency-model` anchor)
- `07-wo-seg-migration.md` — 1
Inbound from `docs/plan/exploration/linux/{07-io_uring,08-mmap,11-memfd_create}.md`,
`docs/plan/exploration/{assembly/02-writeonce-stance,c-runtime/02-single-binary}.md`,
`runtime/README.md:43`, `.dev/reference/README.md:31`.
## D. Never-created / removed siblings (5 links)
| Source | Target | Note |
|---|---|---|
| `docs/plan/exploration/linux/06-sendfile.md:10` | `./07-splice.md` | slot 07 is `07-io_uring.md`; no splice doc was written |
| `docs/plan/exploration/assembly/00-overview.md:19` | `../../../.dev/reference/go/src/runtime/atomic_amd64.s` | wrong depth **and** file absent from the vendored Go tree |
| `docs/00-principles.md:87` | `examples/blog/README.md` | `docs/examples/blog/` never existed |
| `.dev/reference/rest/README.md:76` | `../../docs/examples/blog/README.md` | same missing example |
| `.dev/reference/README.md:41,59` | `../docs/plan/exploration/colibri/00-colibri-and-mixtral.md` | `exploration/colibri/` absent (2 links) |
## E. `prototypes/` tree gone (4 links)
`prototypes/` is not in the repo. Referenced as `prototypes/wo-db/` from
`docs/plan/exploration/c-runtime/00-plan.md:88`, `02-single-binary.md:83`,
`runtime/README.md:5`, and `prototypes/llama-moe-stream` from
`.dev/reference/README.md:59`.
## F. Vendored skill copies — not ours to fix (13 links)
`.dev/skills/` holds flattened copies of plugin skills. The originals ship as
directories with sibling reference files; flattening dropped them.
- `.dev/skills/context-mode/context-mode.md:297-300` → `./references/{patterns-javascript,patterns-python,patterns-shell,anti-patterns}.md`
- `.dev/skills/superpowers/requesting-code-review.md:34,95` → `code-reviewer.md`
- `.dev/skills/superpowers/subagent-driven-development.md:232,300,345,400,410` → `implementer-prompt.md`, `task-reviewer-prompt.md`, `re-review-prompt.md` (×2), `../requesting-code-review/code-reviewer.md`
- `.dev/skills/superpowers/test-driven-development.md:206` → `writing-good-tests.md`
- `.dev/skills/superpowers/writing-skills.md:12,587` → `../using-superpowers/references/{codex,gemini}-tools.md`, `testing-skills-with-subagents.md`
Leave as-is, or re-vendor the skills with their `references/` subdirectories.
`docs/00-principles.md:57,77,78,87` resolved before this re-run — including the
`examples/blog/README.md` reference that section D called a never-created file.
Section D's other entries stand.
---
## Structural problems found alongside the links
## Out of gate scope — `.dev/` (was 23 links, now unscanned)
1. **Iteration 19 was double-booked — RESOLVED.**
`refine/19-chat-websocket-workload.md` and `refine/24-chat-websocket-workload.md`
were the same document, differing only in the `# Iteration NN` heading, while
Recorded so the knowledge is not lost, but no longer reported by
`just linkcheck`. Not ours to fix, unchanged in character from the 2026-08-20
report's section F.
- **`.dev/skills/` (15 links)** — flattened copies of plugin skills. The
originals ship as directories with sibling `references/` files; flattening
dropped them. `context-mode.md:297-300`, `subagent-driven-development.md` (5),
`writing-skills.md` (3), `requesting-code-review.md` (2),
`test-driven-development.md:206`. Leave as-is, or re-vendor the skills with
their subdirectories.
- **`.dev/reference/` (8 links)** — `README.md` (7) points at the removed
`docs/plan/{linux,assembly}/` and `15-mcp-streamable-http.md`, the absent
`exploration/colibri/`, and `prototypes/llama-moe-stream`;
`rest/README.md:76` points at `docs/examples/blog/`, which never existed.
`.dev/` is gitignored (`git ls-files .dev` returns only `.dev/README.md`), so
these are per-developer notes, not repo content.
---
## History — the 2026-08-20 first pass
Kept for the record; every path below is as it was on that date.
### A. Regressions from the in-flight renumber — FIXED 2026-08-20
Nine links broke because files moved in the working tree; each had a known
successor. Sources: `docs/00-status.md:167,187`,
`docs/stories/language-runtime-database/00-story.md:60,69`, the `25`/`26` story
pair (which used `../00-story.md` while `00-story.md` was a sibling — the
`refine/`-relative form pasted into files one level up), `refine/08-shard-actor-runtime.md:98,100`,
and `refine/20-cross-program-tables.md:143`. Link labels were renumbered with
their targets, since the old IDs contradicted the new paths: `9e`→`22` and
`9f`→`23`.
### Structural problems found alongside the links
1. **Iteration 19 was double-booked — RESOLVED.** `refine/19-chat-websocket-workload.md`
and `refine/24-chat-websocket-workload.md` were the same document while
`19-missing-scalar-types.md` also claimed 19. `00-story.md`'s mapping line
(`24←19(chat)`) and table row 20 make **24 canonical**, so the 19 copy was
deleted. `refine/11-fibers.md:13` had been pointing at the 19 copy — repointed
to 24 first, so the delete broke nothing. Prose in `refine/08` that named
"iteration 19" for chat now says 24 (4 places).
2. **`08-shard-actor-runtime.md` existed twice — RESOLVED.**
58 lines at the stories root vs 110 in `refine/`. The `refine/` copy supersedes
it outright: same acceptance criteria plus the 2026-08-20 settled decisions, the
inferred-GC restatement (7b retired `@gc`, which the root copy still required),
and the corrected substrate path (the root copy cited `runtime/wo-rt.c`, removed
with the Rust runtime). Root copy deleted; the one inbound link,
`docs/00-status.md:171`, now points at `refine/`. Six other referrers already did.
made **24** canonical, so the 19 copy was deleted after repointing
`refine/11-fibers.md:13` at 24.
2. **`08-shard-actor-runtime.md` existed twice — RESOLVED.** 58 lines at the
stories root vs 110 in `refine/`. The `refine/` copy superseded it outright
(the root copy still required `@gc`, retired by 7b, and cited
`runtime/wo-rt.c`, removed with the Rust runtime). Root copy deleted.
3. **Unresolved merge-conflict markers were committed** into
`refine/20-cross-program-tables.md:139-145` — `<<<<<<<< HEAD:… / ======== /
>>>>>>>> language-surface-strictness:…/hold/09c-cross-program-tables.md`, from a
rename-conflicted merge. This is what produced that file's broken `09d` link:
the stale side was still in the file. Resolved in favour of HEAD (the renumbered
`21` text). `grep` confirms no other conflict markers under `docs/`.
## Still open
- Sections B–F above: 88 broken links, all pre-existing.
- `docs/plan/discarded.md` and `docs/plan/learnings.md` are the only survivors of
the old flat plan layout, which is why B and C have no successor map. A rename
table in one of them would let the exploration docs be repaired mechanically
rather than by guesswork.
- `docs/00-status.md:171` still shows iteration 8 as ⬜ while `00-story.md:68`
records arc stages 1+2 as landed 2026-08-20. Not a link problem — a status
disagreement between the two index docs. Left alone.
- `refine/23-io-uring-commit.md:26` still quotes the old order as
"9e → 8+11 → 9f" in a dated note. No link involved; left as historical record.
`refine/20-cross-program-tables.md:139-145`, from a rename-conflicted merge —
which is what produced that file's broken `09d` link. Resolved in favour of
HEAD. `grep` confirmed no other conflict markers under `docs/`.
4. **A status disagreement, not a link problem:** `docs/00-status.md:171` showed
iteration 8 as ⬜ while `00-story.md:68` recorded arc stages 1+2 as landed.
Both now read landed.

View file

@ -101,9 +101,10 @@ directly instead of the lowest common denominator.
## 10. Capabilities are typed builtins — no FFI
Programs reach the system only through audited stdlib builtins (`fs`,
`proc`, `net`, `time`, `json`): bounded reads, args-array-only process
runs, handles that close on drop. There is no `extern`, no escape hatch.
Programs reach the system only through audited stdlib builtins — six
reserved namespaces (`fs`, `proc`, `net`, `time`, `json`, `env`): bounded
reads, args-array-only process runs, handles that close on drop. There is
no `extern`, no escape hatch.
*Why:* one FFI hole voids the entire memory-safety and security story;
typed capabilities make the safe path the only path.
*Enforced by:* [the systems-track spec Parts 2–3](superpowers/specs/2026-08-01-systems-track-design.md).

View file

@ -16,18 +16,25 @@ project.
```
writeonce-all/
├── compiler/ OCaml `woc` — lexer→parser→types→owner→emit; produces the compiler binary
├── runtime/ C `wovm` — the register VM that runs .wob images (src/); phase A–F C reference (wo-rt.c, bench/)
├── compiler/ OCaml `woc` — lexer→parser→types→gcinfer→owner→emit; produces the compiler binary
├── runtime/ C `wovm` — the register VM that runs .wob images (src/); retired io_uring reference (wo-rt.c, bench/)
├── database/ C embedded engine — class-shaped tables, secondary indexes, typed WAL + recovery
├── tests/ corpus/ — conformance fixtures: run / compile-fail / trap / gc
├── scripts/ oop-e2e.sh (corpus runner), mkdist.sh / install-accept.sh (packaging), sample acceptance
├── docs/ ALL documentation: numbered docs, stories/, plan/, examples/, superpowers/
├── tests/ corpus/ — conformance fixtures: run / compile-fail / trap / gc (+ five reserved, still empty)
├── scripts/ the corpus runner, packaging, linkcheck, and one acceptance script per sample
├── bench/ baseline.json (the db-bench gate's thresholds) + results/ + compare/ (Go+SQLite peer)
├── docs/ ALL documentation: numbered docs, stories/, plan/, examples/, guides/, superpowers/
├── dist/ `just dist` output: writeonce-<ver>-linux-amd64.tar.gz + .sha256
├── .github/ workflows/release.yml — builds, verifies and publishes on a `v*` tag push
├── .claude/ agents/ — project subagent definitions (see docs/guides/database-developer-subagent.md)
├── .dev/ gitignored per-developer links + reference study trees (v1 crates, colibri, llama-cpp)
├── justfile task runner: woc-/wovm-build, the *-test gates, oop-accept, dist, install-accept
├── VERSION single-sourced toolchain version (stamped into woc/wovm; asserted by `just dist`)
└── README.md the getting-started front door (also the writeonce.de landing content)
```
`target/` and `.vscode/` are local build and editor state, not part of the
project layout.
## Root directories in detail
### `compiler/` — the OCaml `woc` compiler
@ -36,13 +43,19 @@ writeonce-all/
compiler/
├── dune-project
├── README.md orientation: pipeline map, build/test commands
├── plan/ compiler-track docs: architecture.md + the woc plans
├── src/ one module per stage: diag, token, lexer, ast, parser,
│ types, owner, emit, disasm, dump
├── bin/main.ml the woc executable (check / --emit / build / version modes)
└── test/ golden runner + golden/ fixtures per stage
│ types, gcinfer, owner, emit, disasm, dump
├── bin/main.ml the woc executable — check / build-from-manifest / --emit /
│ build / version / --update-deps / the --dump-* modes / -D
└── test/ golden runner + golden/ fixtures per stage (tokens, ast,
owner, owner-err, bc) + fixtures/driver/ CLI-smoke cases
```
The compiler-track plan docs live under
[`plan/compiler/`](plan/compiler/architecture.md) in this `docs/` tree, not
inside `compiler/` — the repo rule below applies to the compiler like everything
else.
Doctrine: OCaml stdlib only — no Menhir, no ppx, no opam libraries; handwritten
lexer and recursive-descent parser. Build: `just woc-build`; gate:
`just woc-test`. Architecture map:
@ -73,22 +86,34 @@ compiler (`emit.ml`), lowered to engine builtins — no SQL text in the image.
### `tests/`, `scripts/`
- `tests/corpus/` — the conformance spine: `run/`, `compile-fail/`, `trap/`,
`gc/`. Exact-outcome matching: byte-equal stdout, exact `WO-E###`, exact trap
code. Driven by `scripts/oop-e2e.sh` (`just oop-e2e`).
- `scripts/` — `oop-e2e.sh` (corpus), `mkdist.sh` + `install-accept.sh`
(tarball packaging), and the per-sample acceptance scripts
(`employee-accept.sh`, `log-watcher-accept.sh`).
- `tests/corpus/` — the conformance spine. Four directories carry fixtures:
`run/` (60), `compile-fail/` (46), `trap/` (5), `gc/` (2). Five more —
`actor/`, `db/`, `lang/`, `sys/`, `sample-logwatcher/` — are reserved slots
from the original plan and still **empty**; see
[`tests/corpus/README.md`](../tests/corpus/README.md) for which plan each was
to be filled by. Exact-outcome matching: byte-equal stdout, exact `WO-E###`,
exact trap code. Driven by `scripts/oop-e2e.sh` (`just oop-e2e`).
- `scripts/` — the corpus runner (`oop-e2e.sh`) and
`single-binary-smoke.sh`; packaging (`mkdist.sh`, `install-accept.sh`); the
docs gate (`linkcheck.py`); the benchmark campaign (`db-bench.py`); and one
acceptance script per sample — `employee-accept.sh`, `log-watcher-accept.sh`,
`web-app-accept.sh`, `site-accept.sh`, `fibers-accept.sh`,
`db-actor-accept.sh`, `deps-accept.sh`.
### `docs/` — all documentation
```
docs/
├── 00-*, 01-problem.md, 08-*.md status / principles / code-review / problem / structure
├── stories/ the canonical iteration arc (language-runtime-database/)
├── examples/ log-watcher/, employee/, employee-list/ samples
├── 00-*.md, 01-problem.md, 08-*.md principles / code-review / dependency-graph /
│ link-audit / doc-audit / problem / structure
├── stories/ 00-status.md (the board) + board-views.md +
│ the canonical iteration arc (language-runtime-database/,
│ FLAT — status lives in each story's frontmatter)
├── active-slice-*.md the live slice's one marker doc, deleted when it lands
├── guides/ runbooks: releasing, language-surface, subagents
├── examples/ 13 sample projects, 8 of them wired to a `just` recipe
├── plan/ compiler/ plans, oop-vm/ contracts, exploration/ studies,
│ discarded.md + learnings.md registers
│ perf-targets.md, discarded.md + learnings.md registers
└── superpowers/ specs/ (approved designs) + plans/ (implementation plans)
```
@ -106,15 +131,25 @@ gates.
`just woc-build` + `just wovm-build` produce the two binaries; `just oop-accept`
runs the full milestone gate (compile-time budget, conformance corpus under
ASan, single-binary smoke, both unit gates). Sample acceptance:
`just employee` (database), `just log-watcher` (systems stdlib). Packaging:
`just dist` → `writeonce-<ver>-linux-amd64.tar.gz`, proven by
`just install-accept`.
ASan, single-binary smoke, both unit gates). Sample acceptance: `just employee`
(database), `just log-watcher` (systems stdlib), `just web-app` and `just site`
(the framework consumed through `[deps]`), `just fibers` and `just db-actor`
(the concurrency arc), `just deps-accept` (the package manager),
`just db-bench` / `db-bench-quick` (the benchmark campaign, gated against
`bench/baseline.json`). Docs gate: `just linkcheck`. Packaging: `just dist` →
`writeonce-<ver>-linux-amd64.tar.gz`, proven by `just install-accept`;
publishing is `.github/workflows/release.yml` on a `v*` tag
(see [`guides/releasing.md`](guides/releasing.md)).
## Naming conventions
- Binaries: `woc` (OCaml compiler), `wovm` (C VM); a `woc build` / `woc <dir>`
output is named by the project's `wo.toml`.
- Story iterations: `<NN>-<topic>.md`, flat in
`docs/stories/language-runtime-database/`. **No directory encodes status**
(directive 2026-08-26) — each story's `status:` frontmatter key is the only
place state is recorded, so a status change is a one-line edit and never
moves a file or breaks a link.
- Plan/spec files: `YYYY-MM-DD-<topic>.md` under `docs/superpowers/{specs,plans}/`;
compiler plans under `docs/plan/compiler/`; normative contracts under
`docs/plan/oop-vm/`.

View file

@ -1,9 +1,15 @@
# In progress — chat + actor lifecycle (iteration 24, absorbing 31 + 34)
---
slice: "24" # the story that owns the status; see stories/24-chat-websocket-workload.md
status: in-progress
---
Active slice, branch `chat-ws-lifecycle`. Spec:
[`../superpowers/specs/2026-08-23-chat-websocket-actor-lifecycle-design.md`](../superpowers/specs/2026-08-23-chat-websocket-actor-lifecycle-design.md)
# Active slice — chat + actor lifecycle (iteration 24, absorbing 31 + 34)
Branch `chat-ws-lifecycle`. Spec:
[`superpowers/specs/2026-08-23-chat-websocket-actor-lifecycle-design.md`](superpowers/specs/2026-08-23-chat-websocket-actor-lifecycle-design.md)
· plan:
[`../superpowers/plans/2026-08-23-chat-ws-lifecycle.md`](../superpowers/plans/2026-08-23-chat-ws-lifecycle.md).
[`superpowers/plans/2026-08-23-chat-ws-lifecycle.md`](superpowers/plans/2026-08-23-chat-ws-lifecycle.md)
· board: [`stories/00-status.md`](stories/00-status.md).
## Progress (2026-08-23)
@ -49,4 +55,6 @@ Every landed task: full battery 12/12, fresh-built.
standup entry, graph nodes, framework README ledger rows, runtime +
chat CODE-LOGIC sections, delete this marker. Final battery.
This file is deleted when the slice lands (board convention).
This file is deleted when the slice lands (board convention). It lives flat in
`docs/` rather than a status folder — since 2026-08-26 no directory in this repo
encodes state; `status:` above is the only place it is recorded.

View file

@ -0,0 +1,62 @@
# `db-actor` — the database reached from any shard
> **Status: shipped — arc stage 3's acceptance gate.** Run it with
> `just db-actor`. Landed 2026-08-21 with the shard-fiber arc
> ([story 8](../../stories/language-runtime-database/08-shard-actor-runtime.md)
> · [plan](../../superpowers/plans/2026-08-20-shard-fiber-arc.md)).
The database lives on **one** shard — the owner, shard 0 — because RAM is
authoritative and a single writer is what makes the WAL's ordering meaningful.
That is a problem the moment actors are placed round-robin across cores: a
`spawn`ed actor has no say in which shard it lands on, and before stage 3 a
worker-shard `insert` trapped `WO_T_DB` with "database engine not initialized".
Stage 3's answer is a **transparent DB actor**: statements issued off the owner
shard marshal to it, execute there, and materialize their replies back. The
program's source says nothing about any of it — the same `insert` and the same
`from … select` work wherever the actor happens to run. This sample exists to
prove exactly that, which is why its acceptance criterion is *placement
independence* rather than any particular output.
## What it does
`Note` is a `@table` with a secondary index on `tag`. `Writer` is an actor: each
one inserts a row, then scans the whole table and prints the sum it sees. `main`
spawns two writers, waits, then scans once itself.
With the default shard count, round-robin placement puts at least one writer off
the owner shard — so one of those inserts and one of those scans travel the RPC
path under test, and the other does not. Both must produce the same shape.
```bash
just db-actor # the gate
woc docs/examples/db-actor/ # or build it by hand
WO_SHARDS=1 ./docs/examples/db-actor/target/db-actor # force the local path
```
## What the gate proves
`scripts/db-actor-accept.sh`, 8 checks:
| Check | Why it is shaped that way |
| --- | --- |
| multi-shard, three rounds | The writer lines are asserted as a **set**, not a sequence — scheduling decides their order, and pinning it would be testing the scheduler, not the RPC. The `main` line is exact. |
| both `WO_IO` backends forced | The reply park has to be plane-independent: io_uring and epoll must give the same answer, or the parking is leaking into semantics. |
| single shard, byte-exact | The local path is untouched by stage 3. Any drift here means the RPC changed the non-RPC case. |
| `WO_DATA` restart pair | A worker's insert must commit on the **owner's** WAL before its ack, so a restart replays it: 2 rows, then 2+2 after a second run. This is the durability claim the RPC could most easily break. |
Run under `wovm_asan` and `wovm_tsan` as well — cross-shard message passing is
exactly where a data race would hide, and TSan covering this demo is the one
place it runs.
## Read it for
- **How little the source knows.** Compare `Writer.receive` here against the
same statements in [`employee`](../employee/): identical. Transparency is the
feature.
- **Why `main` waits.** `main` is not an actor and has no mailbox, so it sleeps
rather than awaiting — the gap iteration 31's `call` closes for actors and
[24's marker](../../active-slice-2026-08-23-chat-ws-lifecycle.md) tracks.
Reasoning under the engine side: [`database/src/CODE-LOGIC.md`](../../../database/src/CODE-LOGIC.md).
Contract: [`plan/oop-vm/04-db-binding.md`](../../plan/oop-vm/04-db-binding.md).

View file

@ -21,7 +21,6 @@ class Job {
-- round-robin, so with two writers at default shards one lands off the
-- primary — the RPC path under test.
class Writer {
pad: Int
fn receive(msg: Job) {
insert Note { tag: "w", val: msg.n };
let total = 0;
@ -33,8 +32,8 @@ class Writer {
}
fn main() -> Int {
let a: actor Job = spawn Writer { pad: 0 };
let b: actor Job = spawn Writer { pad: 1 };
let a: actor Job = spawn Writer {};
let b: actor Job = spawn Writer {};
send(a, Job { n: 1 });
send(b, Job { n: 2 });
-- no request/response surface yet (iteration 31): poll until both rows

View file

@ -2,8 +2,8 @@
> **Status: target workload — does not compile on today's toolchain.**
> Written ahead of iterations
> [20 (cross-program tables)](../../stories/language-runtime-database/refine/20-cross-program-tables.md)
> and [21 (keypair attach auth)](../../stories/language-runtime-database/refine/21-keypair-attach-auth.md),
> [9 (cross-program tables)](../../stories/databasev2/09-cross-program-tables.md)
> and [10 (keypair attach auth)](../../stories/databasev2/10-keypair-attach-auth.md),
> the way every acceptance sample here precedes its features. It also leans
> on 9/9b (the [employee sample](../employee/) it attaches to must run
> first).

View file

@ -1,14 +1,16 @@
# employee — the database track's acceptance workload
> **Status: target workload — does not compile on today's toolchain.**
> This sample is written *ahead of* the features it exercises, exactly as
> log-watcher was written ahead of iterations 5–7: the sample is the test,
> and the plans compile toward it. It becomes buildable when iteration 9
> (engine: [`2026-08-01-db-engine-binding.md`](../../superpowers/plans/2026-08-01-db-engine-binding.md))
> **Status: shipped — this is the database track's acceptance gate.** Run it
> with `just employee`. The sample was written *ahead of* the features it
> exercises, exactly as log-watcher was written ahead of iterations 5–7: the
> sample is the test, and the plans compiled toward it. Both landed — iteration
> 9 (engine: [`2026-08-01-db-engine-binding.md`](../../superpowers/plans/2026-08-01-db-engine-binding.md))
> and iteration 9b (query surface:
> [`2026-08-15-employee-relations-query.md`](../../plan/compiler/2026-08-15-employee-relations-query.md))
> land. Normative semantics:
> [`2026-08-15-employee-relations-query.md`](../../plan/compiler/2026-08-15-employee-relations-query.md)).
> Normative semantics:
> [the 9b spec](../../superpowers/specs/2026-08-15-table-relations-query-design.md).
> One clause below is still ahead of the compiler and marked where it appears:
> `group … by … into` parses and is then refused by the typechecker.
Two `@table` classes and every 9b feature load-bearing:

View file

@ -31,7 +31,6 @@ class Counter {
-- Part 2: an actor whose receive PARKS mid-message. The park releases
-- the shard: main keeps ticking while this fiber sleeps.
class Sleeper {
pad: Int
fn receive(msg: Tick) {
print("sleeper: down for ${msg.n}ms");
time.sleep(msg.n);
@ -61,7 +60,7 @@ fn main() -> Int {
print("part1 done");
-- ---- part 2: a parked fiber blocks nobody ----
let s: actor Tick = spawn Sleeper { pad: 0 };
let s: actor Tick = spawn Sleeper {};
send(s, Tick { n: 150 });
let t = 0;
while t < 8 {

View file

@ -185,8 +185,15 @@ inferred). The developer writes no memory annotations for either.
## Run status
Iteration 7b is landing in phases (plan:
Iteration 7b landed 2026-08-18 (plan:
[`../../superpowers/plans/2026-08-18-inferred-gc-mark-sweep.md`](../../superpowers/plans/2026-08-18-inferred-gc-mark-sweep.md)).
This sample compiles and runs on today's toolchain — `woc
docs/examples/gc-cycle/` then `./docs/examples/gc-cycle/target/gc-cycle`.
> **It has no `just` recipe.** The plan's phase 4 checked off a
> `just gc-cycle` acceptance that never landed; the sample is the one
> compiling example in the repo with no gate behind it. Run it by hand,
> and see the plan's 2026-08-26 disclosure note.
**Phase 1 (landed).** The inference pass classifies each class; `woc --dump-gc
docs/examples/gc-cycle` prints:

View file

@ -6,17 +6,17 @@ ported file for file, per the approved
(Part 4). A single-binary systems daemon: log-tail watcher, cron.d
supervisor, flock/pgrep probes, hand-rolled MCP-over-HTTP server, JSONL
detection sink. Program mode (`fn main`, blocking legal, one shard) plus the
five builtin stdlib modules — `fs`, `proc`, `net`, `time`, `json` — carry
stdlib modules it needs — `fs`, `proc`, `net`, `time`, `json`, `env` — carry
all of it; read each `.wo` next to its `.hx` sibling.
> **Status: design artifact — the spec's forcing function.** The systems
> track is approved, pre-implementation. Today's `woc` (milestone 1)
> recovers the `class`/`fn` skeletons in these files (`--dump-ast` lists
> every Watcher method) but diagnoses the adopted surface as WO-E101:
> `use`, `typedef`, standalone union aliases (`type CronResult = …`),
> `pub(read)`, `switch`, `try`. This sample exists to force that grammar
> (the blog/ecommerce/pricing precedent) and becomes the track's acceptance
> test: it compiles and detects a real silent death when the track ships.
> **Status: shipped — the systems track's acceptance gate.** Run it with
> `just log-watcher` (`just log-watcher::build` / `::soak 60` for the rest).
> Landed with iteration 7 on 2026-08-15: executable, not merely compilable —
> zero ASan leaks in all three modes, SIGTERM ends parked syscalls, fds flat,
> `LW_SOAK` gate. The sample existed to force the grammar it uses (the
> blog/ecommerce/pricing precedent), and every form it needed — `use`,
> `typedef`, standalone union aliases (`type CronResult = …`), `pub(read)`,
> `switch`, `try` — is now shipped surface.
## The mapping

View file

@ -1,11 +1,24 @@
# writeonce-framework
# porch — the writeonce web framework
> **Named `porch` on 2026-08-26.** Rename history: `writeonce-framework`
> (`use framework`) → `writeonce-serve` (`use serve`, 2026-08-25) → **`porch`**
> (`use porch`). Stories, specs, plans and the audit reports dated before each
> change still say the older name — they are dated records and were left as
> written, which is the repo's convention.
>
> Why `porch`: the structure in front of the house you actually enter through,
> and in writeonce the house *is* the database. The name appears only in `use`
> lines and the `[deps]` key — names resolve bare through `use` edges, so no
> handler body mentions it.
A web framework **written in writeonce**, consumed as a `[deps]` dependency
(iteration 15). Spec: `docs/superpowers/specs/2026-08-18-web-framework-design.md` §B.
(iteration 15). Roadmap: [`docs/stories/porch/`](../../stories/porch/00-story.md)
— its own track, eight iterations, numbered from 1, derived from
[the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md). Spec: `docs/superpowers/specs/2026-08-18-web-framework-design.md` §B.
```toml
[deps]
writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }
```
## What it is
@ -82,6 +95,13 @@ first (pure `.wo` cannot express it yet).
### Transport
> Parity reference: [the Fiber v3.5.0 study](../../plan/exploration/fiber/00-fiber-parity.md)
> read all 32 of Fiber's middleware packages against this framework on
> 2026-08-26. **Nine already have a working counterpart here** (CORS, basic
> auth, key/bearer auth, security headers, ETag, static files, logger, host
> authorization, recover-as-500). The rows below marked ⛔/⏸ are what it found
> missing, each with an owner.
| Item | State |
| --- | --- |
| HTTP/1.1 parsing | ✅ parses + 400-and-survive; duplicate `Content-Length` rejected outright (RFC 9112 §6.3, slice 2); BODY_MAX bounds headers and body |
@ -149,7 +169,14 @@ first (pure `.wo` cannot express it yet).
| --- | --- |
| base64 | ✅ pure `.wo` (`http/auth.wo`) |
| SHA-1 · SHA-256 · HMAC-SHA256 | ✅ C runtime builtins (iteration 34, ids 85–87, RFC-vector gated); SHA-512/CRC32 wait for a consumer |
| Unlocks (signed cookies, CSRF, session integrity, webhook verification, JWT HS256) | ⬜ UNBLOCKED (the primitives exist since iteration 34); each is its own slice; **hard stop at JWT HS256** — no RS256, no JOSE zoo |
| Unlocks — signed cookies, webhook verification, JWT HS256 **verification** | ⬜ genuinely unblocked (integrity only needs iteration 34's HMAC); each its own slice; **hard stop at JWT HS256** — no RS256, no JOSE zoo |
| Unlocks — CSRF, session integrity, JWT **issuing** | ⛔ **BLOCKED, corrected 2026-08-26.** This row previously read "UNBLOCKED (the primitives exist since iteration 34)" and that was wrong: HMAC lets you *authenticate* a token, not *mint* one, and **writeonce has no source of randomness at all** (no `getrandom`, no CSPRNG builtin — grep the runtime). An HMAC over a guessable session id is a signed guess. A random-bytes builtin is [iteration 39](../../stories/language-runtime-database/39-web-framework-parity.md)'s first goal |
| Cookies (read + `Set-Cookie`) | ⛔ absent in BOTH directions, and `Resp.headers` is a `map<Text,Text>` so it structurally cannot carry two `Set-Cookie` lines — [iteration 39](../../stories/language-runtime-database/39-web-framework-parity.md) |
| Sessions · CSRF · rate limiting · idempotency | ⬜ [iteration 39](../../stories/language-runtime-database/39-web-framework-parity.md). Limiter and idempotency need only a `@table` + `time.ticks` and are the cheapest wins available; sessions and CSRF wait on randomness + cookies. `@table` gives all four a **durable** store, where Fiber ships in-memory and expects Redis |
| Compression · SSE · byte ranges · chunked bodies | ⏸ all four sit on the parked streaming seam (`serialize()` always emits `Content-Length`). Chunked REQUEST bodies are deliberately refused today (`internal/parse.wo:153-157`, request-smuggling note) — that refusal must survive whoever implements them |
| Typed binding of query/params/form/headers | ⏸ Fiber's `Bind` reflects over struct tags; principle 13 forbids reflection, so the answer is [iteration 29's `@derive`](../../stories/language-runtime-database/29-compile-time-metaprogramming.md). JSON bodies already work via `json.decode(t) as T` |
| PATCH/OPTIONS/HEAD/ALL helpers · named routes · per-route body limit · request id · `Location`/`Vary`/`Attachment` | ⬜ [iteration 39](../../stories/language-runtime-database/39-web-framework-parity.md) — registration and response sugar; `BODY_MAX = 1048576` is currently one compile-time number for the whole server |
| `proxy` middleware | ⛔ impossible today — no `net.connect` anywhere in the runtime ([iteration 38](../../stories/language-runtime-database/38-content-platform-capabilities.md)) |
## Layout and privacy (iteration 17)
@ -167,7 +194,7 @@ naming the kind, unless a demo `main` is added (lib+bin is allowed).
- **`internal/` — not importable by a consumer.** The connection-level request
parser and carry-state record (`parse.wo`) and the serve loop, status text,
and response serializer (`serve.wo`) live here. A consuming app that writes
`use writeonce-framework/internal` gets **WO-E108** at that `use`. The rule
`use porch/internal` gets **WO-E108** at that `use`. The rule
is Go's: a path segment named `internal` is refused across the `[deps]`
boundary only — the framework's own modules import it freely.
@ -180,3 +207,20 @@ elimination); a consumer simply cannot name them.
`docs/examples/web-app` — a small storefront importing this framework
through `[deps]`. Its acceptance (`just web-app`) exercises the whole chain:
fetch → lock → build → serve → durable restart.
## Serving files
`StaticFiles { dir, max_bytes }` mounts a directory on a wildcard route:
```
app.get("/assets/*path", StaticFiles { dir: "assets", max_bytes: 2097152 })
app.get("/dl/*path", StaticFiles { dir: "dist", max_bytes: 16777216 })
```
Two rules, both refusals rather than repairs: a path containing `..` is a
404 and never reaches the filesystem, and `max_bytes` is a hard ceiling —
`fs.read_all` truncates above it, so set it above the largest file you
mean to serve. Content types come from the extension; archives
(`.tar.gz`, `.tgz`, `.zip`) also get `content-disposition: attachment`.
Text is binary-safe in this language, so archives and images travel
unchanged. Lifted out of the shop template 2026-08-25.

View file

@ -3,7 +3,7 @@
--
-- let app = App { middleware: [], gmw: [], afters: [], routes: [] };
-- app.use_mw(Mw { m: Auth { token: t } });
-- app.use_after(Aw { a: SecurityHeaders { pad: 0 } });
-- app.use_after(Aw { a: SecurityHeaders {} });
-- app.add(Route { method: "GET", pattern: "/products/:id", h: Show {} });
-- return app.serve("127.0.0.1", port);
--

View file

@ -0,0 +1,72 @@
-- http/files.wo — serving a file from disk, the framework's answer to
-- "let people download something". Lifted out of the shop template
-- 2026-08-25, which had carried its own copy and said in a comment that
-- this belonged here.
--
-- Mount it on a wildcard route and it answers that subtree:
--
-- app.get("/assets/*path", StaticFiles { dir: "assets", max_bytes: 2097152 })
-- app.get("/dl/*path", StaticFiles { dir: "dist", max_bytes: 8388608 })
--
-- Safety is two rules, both refusals rather than repairs: a path holding
-- `..` is a 404 and never reaches the filesystem, and a file bigger than
-- `max_bytes` is truncated by `fs.read_all` — so `max_bytes` is a real
-- ceiling you must set above the largest file you intend to serve, not a
-- hint. Text is binary-safe in this language, so archives and images
-- travel unchanged.
use fs
pub class StaticFiles {
dir: Text
max_bytes: Int
fn handle(req: Req) -> Resp {
let rel = req.params["path"];
if rel == nil {
return not_found();
}
-- Traversal: refuse, never normalise. A rewritten path is a second
-- chance to get it wrong.
if index_of("${rel}", "..") != -1 {
return not_found();
}
let body = try fs.read_all("${self.dir}/${rel}", self.max_bytes) catch (e) nil;
if body == nil {
return not_found();
}
let h: map<Text, Text> = {};
h["content-type"] = content_type("${rel}");
if is_download("${rel}") {
h["content-disposition"] = "attachment";
}
return Resp { status: 200, headers: h, body: "${body}" };
}
}
-- Extension to content type. Unknown extensions are octet-stream: a
-- wrong guess is worse than no guess.
pub fn content_type(name: Text) -> Text {
if ends_with(name, ".html") { return "text/html; charset=utf-8"; }
if ends_with(name, ".css") { return "text/css; charset=utf-8"; }
if ends_with(name, ".js") { return "text/javascript"; }
if ends_with(name, ".json") { return "application/json"; }
if ends_with(name, ".svg") { return "image/svg+xml"; }
if ends_with(name, ".png") { return "image/png"; }
if ends_with(name, ".webp") { return "image/webp"; }
if ends_with(name, ".ico") { return "image/x-icon"; }
if ends_with(name, ".woff2") { return "font/woff2"; }
if ends_with(name, ".txt") { return "text/plain; charset=utf-8"; }
if ends_with(name, ".sha256") { return "text/plain; charset=utf-8"; }
if ends_with(name, ".tar.gz") { return "application/gzip"; }
if ends_with(name, ".tgz") { return "application/gzip"; }
if ends_with(name, ".zip") { return "application/zip"; }
return "application/octet-stream";
}
-- Archives are offered as a save, not rendered into a tab.
fn is_download(name: Text) -> Bool {
if ends_with(name, ".tar.gz") { return true; }
if ends_with(name, ".tgz") { return true; }
if ends_with(name, ".zip") { return true; }
return false;
}

View file

@ -8,7 +8,6 @@
-- framework's standing decision), so Strict-Transport-Security belongs
-- in the proxy config next to the certificates.
pub class SecurityHeaders {
pad: Int
fn after(req: Req, mut r: Resp) {
r.headers["x-content-type-options"] = "nosniff";
r.headers["x-frame-options"] = "DENY";

View file

@ -37,6 +37,17 @@ pub fn ok_text(body: Text) -> Resp {
return Resp { status: 200, headers: h, body: body };
}
-- iteration 37: HTML is transport here, exactly like text and JSON —
-- the status line and the content-type, nothing about rendering. It
-- lives beside its two siblings because both HTML apps had hand-rolled
-- the identical four lines; writeonce-view stays a pure Text library and never
-- learns what a Resp is.
pub fn ok_html(body: Text) -> Resp {
let h: map<Text, Text> = {};
h["content-type"] = "text/html; charset=utf-8";
return Resp { status: 200, headers: h, body: body };
}
pub fn ok_json(body: Text) -> Resp {
let h: map<Text, Text> = {};
h["content-type"] = "application/json";

View file

@ -47,10 +47,10 @@ pub class Mw {
}
-- Request-line logging, the one middleware every framework ships: method +
-- path to stderr, never short-circuits. `pad` is the record-class ctor
-- convention (every stateless handler carries one Int field).
-- path to stderr, never short-circuits. A stateless handler declares NO
-- fields and constructs as `Logging {}` — an earlier convention gave every
-- one of them a filler `pad: Int`, which the language never required.
pub class Logging {
pad: Int
fn before(mut req: Req) -> ?Resp {
print_err("${req.method} ${req.path}");
return nil;

View file

@ -1,4 +1,4 @@
name = "writeonce-framework"
name = "porch"
kind = "library"
version = "0.1.0"
description = "A web framework written in writeonce: HTTP/1.1 keep-alive server core, router with :param captures, Handler/Middleware structural interfaces (iteration 16)"
@ -9,4 +9,4 @@ wo = ">= 0.1"
# A LIBRARY project (declared above since iteration 17): no `fn main` here —
# the consuming app owns the entry. `woc <dir>` typechecks the whole project.
# Apps import this repo through `wo.toml [deps]` (iteration 15) and
# `use writeonce-framework` / `use writeonce-framework/http` / `.../router`.
# `use porch` / `use porch/http` / `use porch/router`.

View file

@ -0,0 +1,132 @@
# shop — the writeonce program template
A small store you can buy from, structured the way a real writeonce web
app should be. **Copy this directory to start a new app**; every file
has one concern, and the module system (one directory = one module,
`pub` = the export line) enforces the separation the layout promises.
## Run it
```
cd docs/examples/shop
woc . && WO_DATA=./data ./target/shop 8080 # durable store
./target/shop 8080 # RAM-only (dev)
```
Browse http://127.0.0.1:8080/ — products → product page → buy (stock
checked and decremented) → confirmation → /orders. With `WO_DATA`, kill
it and restart: the orders are still there (WAL replay).
The template builds and runs as written — the two `[deps]` resolve, the
seed lands, and every route answers.
## The view form
A `render()` body is one backtick raw text literal. Markup is markup:
real newlines, real double-quoted attributes, and the method's source
indentation removed at compile time, so the served bytes carry the
markup's own nesting and not the code's.
```
fn render() -> Text {
return `
<div class="card">
<h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
<p class="price">€ ${self.price}</p>
${stock}
</div>`;
}
```
Every `render()` makes its class a **component** — writeonce-view's structural
`Component` interface, satisfied by having the method, never declared.
Parent components hold children directly (`cards: multi Component`) and
render them with `render_all`, so `ProductListPage` knows nothing about
`ProductCard` beyond `render()`. The document itself is a component too:
`AppShell { title, content }` in `layout/app.wo`, which links a real
stylesheet rather than inlining one — that is why it is its own shell and
not writeonce-view's `Layout`.
Two holes, and the difference is the whole escaping story:
- `{{ expr }}` **HTML-escapes** — it compiles to a call to the `esc` in
scope (writeonce-view's, unless the app declares its own). Display data goes
here; a typo'd field is a compile error, not a broken page.
- `${ expr }` is **raw** — for markup you built yourself, like the
`${stock}` fragment above or `${content}` in the app shell.
Nothing is parsed at request time. The literal is a compile-time form:
it produces exactly the string constant and concatenation chain the old
hand-written version did, so there is no template engine to ship, warm
up, or sandbox.
## The file map (Angular equivalents)
| this template | concern | Angular analog |
| --- | --- | --- |
| `types.wo` | MODEL — `@table` classes ARE the WAL database | `models/*.ts` (+ the entire database) |
| `layout/app.wo` | app shell: document, header+footer composition, `ok_html`/`html_error` transport helpers | `app.component.html` |
| `layout/header.wo` / `footer.wo` | shared chrome fragments | `header.html` / `footer.html` |
| `product_list/view.wo` | VIEW — classes with `fn render() -> Text`, fields = exactly what is displayed | `product-list/view.html` |
| `product_list/controller.wo` | CONTROLLER — query the model, fill the view, answer a `Resp`; beside its view in the same module | component `.ts` + service |
| `product_page/`, `orders/` | one module per feature: `view.wo` + `controller.wo` | feature folders |
| `/assets/*` | served by the framework's `StaticFiles` — the template no longer carries its own copy | `angular.json` assets |
| `assets/style.css` | ONE real stylesheet, sectioned per feature | the `.scss` files |
| the `render()` bodies | ONE raw text literal each: real newlines, real double-quoted attributes, source indentation removed at compile time, `${}` raw holes and `{{ }}` auto-escaping ones | Vue `<template>` (in-SFC) |
| `layout/app.wo` → `AppShell` | the document, as a two-slot component | `app.component.html` |
| `main.wo` | bootstrap: seed, routes, serve — nothing else | `app-routing.module.ts` + `main.ts` |
Separation is compiler-enforced: one feature = one directory = one
module, holding that feature's view AND its controller. A module sees
only its own declarations plus what it `use`s, so a controller reaching
into another feature has to say so. The `@table` classes sit in
`types.wo` at the root and are reachable from every feature module
without export — see gap #1 below for why that is not the contradiction
it looks like.
## What is deliberately different (doctrine)
- **Templates compile or they don't exist (story 37).** See *The view
form* above: the markup is a compile-time literal, never a file
parsed per request. Gap #3 ("the language has NO multi-line
expression or literal", recorded while writing this template) is
CLOSED — the raw literal landed with story 37's compiler slice, and
this directory was its consumer. Styles stay a real CSS file, served
statically (there is no scss preprocessor).
- **No closures, no DI.** A view is a class with fields + `render()`
(writeonce-view's `Component`); a controller is a class satisfying `Handler`.
Capture = a field.
- **The MVC seam is enforced by where the query sits.** Controllers hold
every `from … select`; a view receives VALUES — for the list page, a
`multi Component` of already-filled cards. No view in this template
touches the database, and writeonce-view contains no query at all.
- **No sessions/cart yet.** Buying is per-product (qty → order). A cart
needs a session story that does not exist yet.
- **No client-side JS.** Every interaction is a form round trip.
- **`pub` + `@table` cannot combine (recorded gap #1) — and it does not
matter.** `pub` in front of an annotated class is a parse error
(`WO-E101`), but no `@table` needs it: a CLASS is reachable across
module lines without being exported. Verified 2026-08-25 by building
and running all three shapes — a feature module querying a root
`@table`, a feature module using a root plain class, and an `@table`
declared inside a `types/` module and queried from the root. What IS
module-scoped is a free `fn` (`WO-E210`), so a query shared by two
features belongs on a class as a `static fn`. An earlier revision of
this file claimed the gap forced controllers into the root module and
made a `types/` module impossible; both were wrong, and the layout
below is what the language actually allows.
- **`@view` projection classes (recorded gap #2):** today controllers
copy row fields into view classes by hand. The wished-for form —
`class ProductCard @view { ... }` filled by
`from p in Product select p.name, p.price` — needs projection
queries; recorded, not worked around.
## Judging the DX — what to look at
1. `types.wo` — the entire persistence layer is 20 lines.
2. `orders.controller.wo` — the whole buying flow (validate, stock
check, decrement, durable insert, render) with no framework magic.
3. `orders/view.wo` — the referendum, now answered: three render()
bodies, each one literal, no concatenation and no `esc()` calls, and
`OrdersPage` holding its rows as child components.
4. `main.wo` — the app at a glance: five routes, one middleware, serve.

View file

@ -0,0 +1,47 @@
/* shop/assets/style.css — one real stylesheet, sectioned per feature.
Served by static_files/controller.wo; AppShell links it. This file is
the .scss stand-in: the language ships no preprocessor, so styles are
plain CSS kept OUT of the markup code. */
/* ---- layout (app.wo, header.wo, footer.wo) ---- */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f9fafb; color: #111827; line-height: 1.6; }
.site-header { display: flex; align-items: center; justify-content: space-between;
padding: .75rem 1.5rem; background: #fff; border-bottom: 1px solid #e5e7eb;
position: sticky; top: 0; }
.brand { font-size: 1.25rem; font-weight: 700; color: #111827; text-decoration: none; }
.site-nav a { margin-left: 1rem; color: #2563eb; text-decoration: none; }
.site-nav a:hover { text-decoration: underline; }
.site-main { max-width: 64rem; margin: 0 auto; padding: 2rem 1rem; }
.site-footer { border-top: 1px solid #e5e7eb; color: #6b7280; font-size: .875rem;
padding: 2rem 1rem; text-align: center; margin-top: 4rem; }
h1 { margin-bottom: 1rem; }
/* ---- product_list ---- */
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: .5rem;
padding: 1.5rem; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
.card h3 { margin-bottom: .5rem; }
.card h3 a { color: #111827; text-decoration: none; }
.card h3 a:hover { color: #2563eb; }
.price { font-weight: 700; }
.stock { color: #6b7280; font-size: .875rem; }
.stock.out { color: #b91c1c; font-weight: 700; }
/* ---- product_page ---- */
.card.detail { max-width: 28rem; }
.card.detail form { margin-top: 1rem; display: flex; flex-direction: column; gap: .5rem; }
.card.detail label { font-size: .875rem; color: #374151; }
.field { display: block; width: 100%; border: 1px solid #e5e7eb; border-radius: .25rem;
padding: .5rem; font-size: .875rem; }
.btn { background: #2563eb; color: #fff; border: 0; border-radius: .25rem;
padding: .5rem 1rem; font-weight: 700; cursor: pointer; }
.btn:hover { background: #1d4ed8; }
/* ---- orders ---- */
table.orders { width: 100%; border-collapse: collapse; background: #fff;
border: 1px solid #e5e7eb; border-radius: .5rem; }
table.orders th, table.orders td { text-align: left; padding: .5rem .75rem;
border-bottom: 1px solid #e5e7eb; }
table.orders th { background: #f9fafb; font-size: .875rem; color: #374151; }

View file

@ -0,0 +1,50 @@
-- layout/app.wo — the app shell (app.html's analog): one full document
-- wrapping every page with the shared header and footer. Styles are NOT
-- inlined — pages link /assets/style.css (served by static_files), so
-- markup and styling stay separate files.
use porch/http
use view
-- The app shell as a COMPONENT (writeonce-view's `Component`: fields in, Text
-- out), the same shape as writeonce-view's own `Layout` — two slots instead of
-- four, and its own document rather than `page()`'s, because this
-- template deliberately LINKS a stylesheet instead of inlining one.
-- That is the whole reason it is not just `Layout`: markup and styling
-- stay separate files here.
pub class AppShell {
title: Text
content: Text
fn render() -> Text {
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ self.title }}</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
${header()}
<main class="site-main">${self.content}</main>
${footer()}
</body>
</html>`;
}
}
-- `ok_html` is the FRAMEWORK's (http/types.wo, beside ok_text/ok_json):
-- a status line and a content-type is transport, not rendering, and both
-- HTML samples had hand-rolled the identical four lines.
pub fn html_error(status: Int, title: Text, msg: Text) -> Resp {
let content = `
<h1>{{ title }}</h1>
<p>{{ msg }}</p>
<p><a href="/">Back to products</a></p>`;
let shell = AppShell { title: "shop — ${title}", content: content };
let h: map<Text, Text> = {};
h["content-type"] = "text/html; charset=utf-8";
return Resp { status: status, headers: h, body: shell.render() };
}

View file

@ -0,0 +1,4 @@
-- layout/footer.wo — the footer fragment (footer.html's analog).
pub fn footer() -> Text {
return `<footer class="site-footer">writeonce shop — one binary: server, database, these pages.</footer>`;
}

View file

@ -0,0 +1,8 @@
-- layout/header.wo — the header fragment (header.html's analog).
pub fn header() -> Text {
return `
<header class="site-header">
<a href="/" class="brand">writeonce shop</a>
<nav class="site-nav"><a href="/">Products</a> <a href="/orders">Orders</a></nav>
</header>`;
}

View file

@ -0,0 +1,51 @@
-- shop/main.wo — the bootstrap (app-routing.module's analog): seed the
-- store on first boot, wire routes to the feature controllers, serve.
-- No rendering and no queries here beyond the seed.
--
-- WO_DATA=./data ./target/shop 8080 (run from the shop directory:
-- /assets/* serves from ./assets)
use porch
use porch/http
use porch/router
use product_list
use product_page
use orders
fn seed_if_empty() {
let n = 0;
for p in from x in Product take 1 select x {
n = n + 1;
}
if n > 0 {
return;
}
insert Product { sku: "keyb-75", name: "75% mechanical keyboard", price: 89.0, stock: 12 };
insert Product { sku: "mug-wal", name: "WAL-backed coffee mug", price: 14.5, stock: 40 };
insert Product { sku: "tee-own", name: "Ownership-checked t-shirt", price: 24.9, stock: 25 };
insert Product { sku: "desk-pad", name: "Deskmat (one binary edition)", price: 19.0, stock: 0 };
}
fn main(args: multi Text) -> Int {
if len(args) < 1 {
print_err("usage: shop <port> (WO_DATA=<dir> makes the store durable)");
return 2;
}
let port = parse_int(args[0]);
if port == nil {
print_err("shop: <port> must be a number");
return 2;
}
seed_if_empty();
let app = App { middleware: [], routes: [] };
app.use_mw(Mw { m: Logging {} });
app.get("/", ListProducts {});
app.get("/p/:sku", ShowProduct {});
app.post("/orders/:sku", CreateOrder {});
app.get("/orders", ListOrders {});
-- the framework's file server (serve/http): 2 MiB ceiling is well
-- above this template's stylesheet.
app.get("/assets/*path", StaticFiles { dir: "assets", max_bytes: 2097152 });
return app.serve("127.0.0.1", port);
}

View file

@ -0,0 +1,53 @@
-- orders/controller.wo — the buying flow: stock-checked order creation
-- (decrement + insert are each WAL-committed before they acknowledge)
-- and the orders list (ref navigation: o.product.name).
use porch/http
use layout
use time
pub class CreateOrder {
fn handle(req: Req) -> Resp {
let sku = req.params["sku"];
if sku == nil {
return html_error(404, "No such product", "The order names no product.");
}
let f = form_values(req);
if f == nil {
return html_error(400, "Bad order", "The form did not arrive form-encoded.");
}
let qraw = f["qty"];
if qraw == nil {
return html_error(400, "Bad order", "How many? The qty field is missing.");
}
let qty = parse_int(trim("${qraw}"));
if qty == nil or qty < 1 {
return html_error(400, "Bad order", "qty must be a positive number.");
}
let hits = from p in Product where p.sku == sku take 1 select p;
if len(hits) == 0 {
return html_error(404, "No such product", "Nothing is listed under that sku.");
}
let p = hits[0];
if p.stock < qty {
return html_error(409, "Not enough stock", "Only ${p.stock} left of ${p.name}.");
}
let total = p.price * float(qty);
p.stock = p.stock - qty;
insert Order { product: p, qty: qty, total: total, placed: time.now(), status: "placed" };
let page = OrderOk { name: p.name, qty: qty, total: total };
let shell = AppShell { title: "shop — order placed", content: page.render() };
return ok_html(shell.render());
}
}
pub class ListOrders {
fn handle(req: Req) -> Resp {
let rows: multi Component = [];
for o in from x in Order select x {
push(rows, OrderRow { name: o.product.name, qty: o.qty, total: o.total, status: o.status });
}
let page = OrdersPage { rows: rows };
let shell = AppShell { title: "shop — orders", content: page.render() };
return ok_html(shell.render());
}
}

View file

@ -0,0 +1,54 @@
-- orders/view.wo — the confirmation page and the orders table.
-- Raw text literals for the bodies; see product_list/view.wo for the
-- convention.
use view
pub class OrderOk {
name: Text
qty: Int
total: Float
fn render() -> Text {
return `
<div class="card">
<h1>Order placed</h1>
<p>${self.qty} × {{ self.name }} — total € ${self.total}</p>
<p><a href="/orders">See all orders</a> · <a href="/">Keep shopping</a></p>
</div>`;
}
}
pub class OrderRow {
name: Text
qty: Int
total: Float
status: Text
fn render() -> Text {
return `
<tr>
<td>{{ self.name }}</td>
<td>${self.qty}</td>
<td>€ ${self.total}</td>
<td>{{ self.status }}</td>
</tr>`;
}
}
pub class OrdersPage {
rows: multi Component
fn render() -> Text {
if len(self.rows) == 0 {
return `
<h1>Orders</h1>
<p>No orders yet — <a href="/">go buy something</a>.</p>`;
}
return `
<h1>Orders</h1>
<table class="orders">
<tr><th>Product</th><th>Qty</th><th>Total</th><th>Status</th></tr>
${render_all(self.rows)}
</table>`;
}
}

View file

@ -0,0 +1,21 @@
-- product_list/controller.wo — the CONTROLLER for /: query the model,
-- fill the view components beside it, answer a Resp. One feature = one
-- directory = one module, view and controller together. The @tables it
-- queries live in the root module, which is fine: a CLASS is reachable
-- across module lines (only free `fn`s are module-scoped).
use porch/http
use layout
pub class ListProducts {
fn handle(req: Req) -> Resp {
-- The MVC seam: the query is HERE, and what crosses into the view is
-- a list of child components, never a cursor.
let cards: multi Component = [];
for p in from x in Product order by x.name select x {
push(cards, ProductCard { sku: p.sku, name: p.name, price: p.price, stock: p.stock });
}
let page = ProductListPage { cards: cards };
let shell = AppShell { title: "shop — products", content: page.render() };
return ok_html(shell.render());
}
}

View file

@ -0,0 +1,41 @@
-- product_list/view.wo — the VIEW. Classes with `fn render() -> Text`;
-- fields are exactly the values displayed, and a body is ONE raw text
-- literal: markup written as markup, attributes in real double quotes,
-- source indentation removed at compile time. `${}` interpolates raw,
-- `{{ }}` HTML-escapes — so display data goes through `{{ }}` and never
-- through a hand-written esc() call.
use view
pub class ProductCard {
sku: Text
name: Text
price: Float
stock: Int
fn render() -> Text {
let stock = `<p class="stock">${self.stock} in stock</p>`;
if self.stock == 0 {
stock = `<p class="stock out">sold out</p>`;
}
return `
<div class="card">
<h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
<p class="price">€ ${self.price}</p>
${stock}
</div>`;
}
}
-- A parent component: its `cards` are CHILDREN, held through the
-- structural interface, and its render calls theirs. Nothing here knows
-- that a card is a ProductCard — swapping in a different card component
-- is a controller change, not a view rewrite.
pub class ProductListPage {
cards: multi Component
fn render() -> Text {
return `
<h1>Products</h1>
<div class="grid">${render_all(self.cards)}</div>`;
}
}

View file

@ -0,0 +1,20 @@
-- product_page/controller.wo — the CONTROLLER for /p/:sku.
use porch/http
use layout
pub class ShowProduct {
fn handle(req: Req) -> Resp {
let sku = req.params["sku"];
if sku == nil {
return html_error(404, "No such product", "The address is missing a product.");
}
let hits = from p in Product where p.sku == sku take 1 select p;
if len(hits) == 0 {
return html_error(404, "No such product", "Nothing is listed under that sku.");
}
let p = hits[0];
let page = ProductPage { sku: p.sku, name: p.name, price: p.price, stock: p.stock };
let shell = AppShell { title: "shop — ${p.name}", content: page.render() };
return ok_html(shell.render());
}
}

View file

@ -0,0 +1,31 @@
-- product_page/view.wo — the product detail view with the order form.
-- Raw text literals for the bodies; see product_list/view.wo for the
-- convention.
use view
pub class ProductPage {
sku: Text
name: Text
price: Float
stock: Int
fn render() -> Text {
let action = `
<form method="POST" action="/orders/{{ self.sku }}">
<label>Quantity</label>
<input type="text" name="qty" value="1" class="field">
<button type="submit" class="btn">Buy</button>
</form>`;
if self.stock == 0 {
action = `<p class="stock out">sold out</p>`;
}
return `
<div class="card detail">
<h1>{{ self.name }}</h1>
<p class="price">€ ${self.price}</p>
<p class="stock">${self.stock} in stock</p>
${action}
</div>
<p><a href="/">← all products</a></p>`;
}
}

View file

@ -0,0 +1,29 @@
-- types.wo — the MODEL. Every @table class IS a WAL-backed table: rows
-- persist under WO_DATA and replay on restart; without WO_DATA the
-- store is RAM-only (handy while developing). Nothing else lives here —
-- no rendering, no request handling.
--
-- Root module by NECESSITY, not choice: `pub` and `@table` cannot
-- combine yet (recorded language gap), so tables cannot be exported to
-- other modules — everything that queries them (the controllers) lives
-- in the root module too. When the gap closes, this file becomes a
-- `types/` module and the controllers move into their feature folders.
@table(name: "products", index: [sku])
class Product {
sku: Text @unique
name: Text
price: Float
stock: Int
orders: backlink Order.product
}
@table(name: "orders", index: [product])
class Order {
product: ref Product
qty: Int
total: Float
placed: Int -- epoch ms (time.now at purchase)
status: Text -- "placed" in v1; a fulfilment flow would grow this
}

View file

@ -0,0 +1,12 @@
name = "shop"
version = "0.1.0"
description = "The writeonce program template: an MVC-separated shop — @table model, render() view classes, controller handlers, static assets"
[runtime]
wo = ">= 0.1"
# Two library dependencies, the site sample's proven shape. The [deps]
# KEY is the module name `use` imports.
[deps]
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }
view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }

View file

@ -1,36 +1,108 @@
# site — how it is put together
Written 2026-08-23, with the sample's landing. Three files, one binary.
Written 2026-08-23 with the sample's landing; restructured 2026-08-25
onto the program template's MVC layout (`docs/examples/shop`), so the two
samples now read the same way.
| file | what it owns |
| --- | --- |
| `main.wo` | the `Chapter` table, seed-if-empty, the HTML shell (header/nav), four handlers (Home, ShowChapter, AdminEdit, Health), `main` |
| `content.wo` | the nine chapter bodies as functions returning HTML fragments, and `seed_chapters()` — same directory, so it shares `main.wo`'s declarations without `use` |
| `wo.toml` | the two [deps]: `framework` (serving) and `html` (markup) |
## The layout
Decisions that are not obvious from the code:
| file | layer | what it owns |
| --- | --- | --- |
| `types.wo` | MODEL | the `Chapter` `@table`, the `ChapterLink` projection, `Chapters.links()`, and `seed_if_empty()` |
| `content.wo` | MODEL (content) | the nine chapter bodies as fragment-returning functions, plus `seed_chapters()` |
| `layout/app.wo` | VIEW (chrome) | `AppShell` — the component that fills writeonce-view's `Layout` — the two named widths, and `html_error` |
| `layout/header.wo`, `layout/footer.wo` | VIEW (chrome) | the shared nav bar (brand = mark + wordmark) and footer |
| `layout/logo.wo` | VIEW (chrome) | the mark as inline SVG, plus the `<head>` links |
| `install/view.wo`, `install/controller.wo` | VIEW + CONTROLLER | `/install` — the toolchain guide. Static copy, so `InstallPage` has no fields |
| `packages/view.wo`, `packages/controller.wo` | VIEW + CONTROLLER | `/packages` and `/packages/:name` — the catalogue, its cards, and per-package usage |
| `favicon/controller.wo` | CONTROLLER | `/favicon.svg` — builds its own `Resp` (image/svg+xml) |
| `home/view.wo` | VIEW | `HomePage` and the homepage's code showcase |
| `home/controller.wo` | CONTROLLER | `Home` — the `/` handler |
| `chapter/view.wo` | VIEW | `ChapterNav`, `ChapterPage` |
| `chapter/controller.wo` | CONTROLLER | `ShowChapter` — the `/ch/:slug` handler |
| `admin/controller.wo` | CONTROLLER | `AdminEdit` — bearer-gated edit, answers a redirect (no view: it redirects) |
| `health/controller.wo` | CONTROLLER | `Health` — the liveness probe (no view: it answers text) |
| `main.wo` | BOOTSTRAP | seed, routes, serve. Nothing else |
| `wo.toml` | — | the two `[deps]`: `framework` (serving) and `html` (markup) |
- **Chapters are rows, not constants.** `seed_if_empty()` inserts them only
when the table answers empty, so a WAL restart keeps admin edits instead
of reseeding over them — the sample's own proof of chapter 6's claim.
The seed bodies are BUILT with wo-html's builders at boot; after that
the table is the truth and the builders are never consulted again.
One feature = one directory = one module, holding that feature's view
and its controller together. A module sees its own declarations plus
what it `use`s, so `home/` reaching the chapter nav has to say `use
chapter`.
The model stays at the root and is reachable from everywhere: a CLASS
crosses module lines without being exported, and only a free `fn` is
module-scoped (`WO-E210`). That single rule explains the whole layout —
`Chapter` and `ChapterLink` are classes, so the feature modules just
name them; the shared query would have been a free fn, so it is a
`static fn` on `Chapters` instead. (`pub` cannot prefix an `@table`
class — recorded gap #1 — but nothing needs it to.)
## Decisions that are not obvious from the code
- **Chapters are rows, not constants.** `seed_if_empty()` inserts them
only when the table answers empty, so a WAL restart keeps admin edits
instead of reseeding over them — the sample's own proof of chapter 6's
claim. The seed bodies are BUILT with writeonce-view's builders at boot; after
that the table is the truth and the builders are never consulted again.
- **The seam is enforced by where the query sits.** `Chapters.links()`
lives with the MODEL and hands the view a `multi ChapterLink` —
a projection, not a cursor. No component in this sample touches the
database, which is what lets `ChapterNav` be the same component on the
homepage and on every chapter page, differing only by `current`.
- **`HomePage` and `ChapterPage` hold a `Component`, not chapter data.**
The nav arrives as an already-built child component in a slot, so
neither page knows what a chapter is. That is content projection —
Angular's `<ng-content>`, with the slot as an ordinary field.
- **Two widths, named once.** `AppShell` carries a `container` field and
`layout/app.wo` exports `reading_shell` / `wide_shell`. The Tailwind
class strings appear in exactly one place instead of being repeated at
every call site.
- **Auth is handler-side by doctrine.** The framework ships mechanism
(`bearer_token`, constant-time `ct_eq`); which routes are gated and by
which token is policy, so `AdminEdit` checks its own field. No global
middleware — the public pages stay public.
- **`ok_html` is the framework's**, beside `ok_text`/`ok_json`: a status
line plus a content-type is transport, not rendering.
- **`\$` in chapter code samples.** Chapter sources show interpolation
(`${port}`) inside string literals of a language that interpolates —
the lexer's `\$` escape keeps them literal; `code_block()` then
HTML-escapes the result.
- **One-line concat chains.** `..` does not straddle newlines (Go-style
implicit statement ends), so long fragments build accumulator-style
(`b = b .. "...";` per line) — the same shape serve.wo uses for
response heads.
- **wo-html's sheet is static.** Tailwind's class NAMES, one hand-written
HTML-escapes the result. This is also why those two samples stay
escaped `"..."` strings rather than becoming raw literals: a raw
literal has no escape character, so it cannot spell a literal `${`.
- **Concat spans lines two ways now.** A line ENDING in `..` continues on
the next (the one newline suppression in the language) — it never works
at the START of a line. For markup, prefer the backtick raw literal:
real newlines, real double-quoted attributes, source indentation
removed at compile time, `${ }` raw and `{{ }}` auto-escaping. The old
"`..` does not straddle newlines, so build accumulator-style" note is
obsolete and was removed.
- **The logo is inline SVG, authored once.** `logo_svg(px)` goes in the
nav brand and `favicon_svg()` is served at `/favicon.svg` — a dark tile
with a two-stroke "W", white then accent blue. No asset pipeline, no
binary in the repo, and it stays legible at 16px. The `<head>` link
reaches the document through `Layout`'s `head` slot.
- **A raw literal cannot contain a literal `{{`.** The packages page has
prose ABOUT `{{ }}` holes, and writing it directly would have made it a
hole; it is written with `&#123;` entities instead. This is the same
limitation the chapter code samples hit with `${`, and the reason both
doors exist.
- **Downloads are the framework's, not the site's.** `/dl/*path` is
`StaticFiles` mounted in `main.wo` with a 16 MiB ceiling — no
controller, because there is no decision to make. `WO_DIST` says where
the tarballs are (default `./dist`). The install page offers the GitHub
release as primary and this as a mirror, with the `.sha256` beside it.
- **The supported-systems list is read off the binaries**, not off a
wish list: `file` gives the triple, and the highest `GLIBC_` symbol
version they import gives the libc floor (2.38 today). Overstating
support costs a reader an afternoon.
- **`SITE_HOST` picks the interface.** Loopback by default — right behind
a proxy — with the env var for reaching a dev instance across the LAN.
The bound address is printed at startup.
- **writeonce-view's sheet is static.** Tailwind's class NAMES, one hand-written
CSS string inlined per page by `page()` — self-contained responses, no
toolchain; growing the sheet is appending a line in `tw_css()`.
Gate: `just site` — see scripts/site-accept.sh (11 checks; the restart
Gate: `just site` — see `scripts/site-accept.sh` (11 checks; the restart
leg polls `/health` instead of sleeping, so it does not share
web-app-accept's 0.5s boot race).

View file

@ -2,13 +2,13 @@
The language tutorial, served BY the language. One binary carries the HTTP
server, the router, the pages and the database; the chapters you read are
rows in a `@table`, the markup is built by the `wo-html` dependency, and
rows in a `@table`, the markup is built by the `writeonce-view` dependency, and
the whole thing is chapter 9's own example.
```
[deps]
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" }
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }
view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }
```
## Run it
@ -17,7 +17,21 @@ html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.
woc . && SITE_TOKEN=change-me WO_DATA=./data ./target/site 8080
```
- `GET /` — the chapter index; `GET /ch/<slug>` — one chapter.
It binds loopback by default. To reach it from another machine while
developing, name the interface:
```
SITE_HOST=0.0.0.0 SITE_TOKEN=change-me WO_DATA=./data ./target/site 8080
```
- `GET /` — the homepage; `GET /ch/<slug>` — one chapter.
- `GET /install` — the installation guide; `GET /packages` and
`GET /packages/<name>` — the package catalogue with copy-paste
`[deps]` lines and usage.
- `GET /favicon.svg` — the mark, inline SVG, no asset pipeline.
- `GET /dl/<file>` — release tarballs, served by the framework's
`StaticFiles` from `$WO_DIST` (default `./dist`, where `just dist`
writes them).
- `POST /admin/ch/<slug>` — edit a chapter (`title`/`body`, form-encoded,
`authorization: Bearer $SITE_TOKEN`). Edits are WAL-durable under
`WO_DATA` and replay on restart — that is chapter 6, demonstrated by
@ -28,6 +42,41 @@ The acceptance gate is `just site` (scripts/site-accept.sh): two file://
dep remotes, build, the page matrix, 401, an authed edit, SIGTERM, and
the edit surviving a restart.
## The file map
MVC, laid out exactly like the program template
([`docs/examples/shop`](../shop/README.md)) so the two read the same way:
| this app | layer |
| --- | --- |
| `types.wo` | MODEL — the `Chapter` `@table`, and seed-if-empty |
| `content.wo` | the nine chapter bodies + `seed_chapters()` |
| `layout/` | the chrome: `AppShell` (+ the two named widths), header, footer, `html_error` |
| `home/`, `chapter/`, `install/`, `packages/` | one module per feature: its `view.wo` (components: fields in, Text out) and its `controller.wo` (query the model, fill the components, answer a `Resp`) |
| `admin/`, `health/`, `favicon/` | controller-only features — a redirect, a text probe, an SVG |
| `layout/logo.wo` | the mark as inline SVG: one source for the nav brand and `/favicon.svg` |
| `main.wo` | bootstrap: seed, routes, serve — nothing else |
Every `render()` makes its class a component (writeonce-view's structural
`Component`). `HomePage` and `ChapterPage` each take the chapter nav as
an already-built child component in a slot, so neither knows what a
chapter is; `ChapterNav` is therefore literally the same component on the
homepage and on every chapter page, differing only by which `ord` is
current.
The seam that keeps it honest: **every query lives in a controller.**
`chapter_links()` sits in `chapter.controller.wo` and hands the views a
`multi ChapterLink` projection — no component in this sample touches the
database, and writeonce-view contains no query at all.
One feature = one directory = one module, holding that feature's view
and its controller. The `@table` lives in `types.wo` at the root and is
reachable from every feature module without being exported — a CLASS
crosses module lines, only a free `fn` is module-scoped (`WO-E210`).
That is why the query both pages need is `Chapters.links()`, a `static
fn` on a root class, rather than a free function one of them would have
to import from the other.
## writeonce.de deployment
The framework speaks HTTP/1.1 keep-alive and no TLS by design — terminate
@ -44,11 +93,48 @@ server {
Run the binary under systemd (`Restart=on-failure`, `Environment=SITE_TOKEN=...`,
`Environment=WO_DATA=/var/lib/writeonce-site`); SIGTERM drains cleanly.
### What to copy to the host
The binary is self-contained — VM, bytecode and database engine are
inside it — but two directories are read at RUNTIME and must travel
with it:
```
/srv/writeonce-site/
site the binary (docs/examples/site/target/site)
dist/ what /dl serves — the RELEASE assets:
writeonce-0.1.0-linux-amd64.tar.gz
writeonce-0.1.0-linux-amd64.tar.gz.sha256
data/ WO_DATA — the WAL; create it, keep it
```
```
SITE_TOKEN=<bearer for /admin> required, the process refuses to start without it
WO_DATA=/srv/writeonce-site/data durable chapters; omit for RAM-only
WO_DIST=/srv/writeonce-site/dist where /dl reads from (default ./dist)
SITE_HOST leave UNSET behind a proxy — loopback is the
right default; set it only to expose directly
```
Behind a proxy `SITE_HOST` stays unset, so the process binds
`127.0.0.1` and is unreachable except through nginx. It prints the
bound address at startup, which is the quickest way to confirm that.
**Keep `dist/` the published release, not a local build.** `just dist`
produces a different digest on every run, so a locally built tarball
would not match the `.sha256` the release publishes and would make the
mirror disagree with the GitHub download. Fetch the assets from the
release instead:
```
gh release download v0.1.0 -D dist -R shoneyJ/writeonce
```
## What it demonstrates
Chapters 1–9 teach the language (values, containers, classes, optionals,
tables, actors, deps, serving); the app itself exercises the framework's
routing/:params, the Logging middleware, bearer auth (mechanism from
`http/auth.wo`, policy here), `form_values`, `@table` + query + update by
assignment, and `wo-html`'s escaping/builders/Tailwind-style utility
assignment, and `writeonce-view`'s escaping/builders/Tailwind-style utility
sheet — self-contained pages, no CDN, no JS, no build step.

View file

@ -0,0 +1,33 @@
-- admin.controller.wo — POST /admin/ch/:slug: title/body update,
-- form-encoded, bearer-gated. Mechanism (bearer_token, constant-time
-- ct_eq) is the framework's; POLICY — which routes, which token — is
-- this app's, right here. No rendering: the answer is a redirect.
use porch/http
pub class AdminEdit {
token: Text
fn handle(req: Req) -> Resp {
let got = bearer_token(req);
if got == nil { return unauthorized(); }
if ct_eq("${got}", self.token) == false { return unauthorized(); }
let slug = req.params["slug"];
if slug == nil { return not_found(); }
let hits = from c in Chapter where c.slug == slug take 1 select c;
if len(hits) == 0 { return not_found(); }
let f = form_values(req);
if f == nil { return bad_request("body must be form-encoded (title, body)"); }
let title = f["title"];
let body = f["body"];
if title == nil and body == nil { return bad_request("nothing to update"); }
if title != nil {
let t = trim("${title}");
if t == "" { return bad_request("title must not be empty"); }
hits[0].title = t;
}
if body != nil {
hits[0].body = "${body}";
}
return redirect("/ch/${slug}");
}
}

View file

@ -0,0 +1,23 @@
-- chapter/controller.wo — the CONTROLLER for `/ch/:slug`. It queries the
-- model and fills the view components that sit beside it in this module;
-- a view receives VALUES, never a cursor.
use porch/http
use layout
pub class ShowChapter {
fn handle(req: Req) -> Resp {
let slug = req.params["slug"];
if slug == nil {
return html_error(404, "No such chapter", "The address names no chapter.");
}
let hits = from c in Chapter where c.slug == slug take 1 select c;
if len(hits) == 0 {
return html_error(404, "No such chapter", "Nothing is filed under that slug.");
}
let c = hits[0];
let nav = ChapterNav { items: Chapters.links(), current: c.ord };
let page = ChapterPage { ord: c.ord, title: c.title, body: c.body, chapter_nav: nav };
let shell = reading_shell("writeonce — ${c.title}", page.render());
return ok_html(shell.render());
}
}

View file

@ -0,0 +1,46 @@
-- chapter/view.wo — the VIEW for `/ch/:slug`, plus the chapter nav that
-- the homepage reuses.
--
-- The MVC seam in one place: these components RENDER, the controller
-- QUERIES, and the two never meet. ChapterNav holds VALUES (a list of
-- links), so it renders identically on the homepage and on a chapter
-- page — the only difference is which ord is `current`. Reuse is the
-- same component with different fields, never copied markup.
use view
-- `ChapterLink` is the MODEL's projection type (types.wo); a class is
-- reachable across module lines, so the view just names it.
pub class ChapterNav {
items: multi ChapterLink
current: Int
fn render() -> Text {
let out = "";
for c in self.items {
let label = `${c.ord}. {{ c.title }}`;
if c.ord == self.current {
out = out .. el("li", "mb-2 font-bold text-gray-900", label);
} else {
out = out .. el("li", "mb-2", link("/ch/${c.slug}", "", label));
}
}
return el("ul", "list-disc pl-6", out);
}
}
-- One chapter. `body` is site-authored HTML held in the row, so it goes
-- through the RAW hole; the title is data and goes through `{{ }}`.
pub class ChapterPage {
ord: Int
title: Text
body: Text
chapter_nav: Component
fn render() -> Text {
let head = el("h1", "text-3xl font-bold mb-4", `${self.ord}. {{ self.title }}`);
let art = el("div", "bg-white rounded-lg border shadow-sm p-6", head .. self.body);
let nav = el("div", "mt-8", el("h2", "text-lg font-bold mb-2", "Chapters") .. self.chapter_nav.render());
return art .. nav;
}
}

View file

@ -1,11 +1,12 @@
-- site/content.wo — the tutorial chapters, seeded into the Chapter table
-- on first boot (main.wo's seed_if_empty). Bodies are HTML fragments
-- BUILT with the wo-html dep — prose in el(), code samples through
-- content.wo — the tutorial chapters, seeded into the Chapter table on
-- first boot (types.wo's seed_if_empty). Model CONTENT, so it sits in
-- the root module beside types.wo: it inserts rows. Bodies are HTML fragments
-- BUILT with the writeonce-view dep — prose in el(), code samples through
-- code_block() which escapes them. Editing a chapter later (the admin
-- route) overwrites body/title in place; the WAL keeps the edit across
-- restarts, which is exactly chapter 6's lesson demonstrated by the
-- site that teaches it.
use html
use view
fn ch_hello() -> Text {
let b = el("p", "leading-relaxed mb-4",
@ -32,7 +33,7 @@ fn ch_containers() -> Text {
fn ch_classes() -> Text {
let b = el("p", "leading-relaxed mb-4",
"Classes hold fields and methods. There are NO function values and NO closures — a " .. "deliberate doctrine: behavior travels as a class satisfying an interface, and " .. "satisfaction is structural (same method name and shape, Go-style, no " .. "<code>implements</code>). This is how the web framework takes handlers.");
"Classes hold fields and methods. There are NO function values and NO closures — a " .. "deliberate doctrine: behavior travels as a class satisfying an interface, and " .. "satisfaction is structural (same method name and shape, Go-style, no " .. "<code>implements</code>). This is how <code>porch</code>, the web framework, takes handlers.");
b = b .. code_block("interface Handler {\n fn handle(req: Req) -> Resp\n}\n\nclass Hello {\n greeting: Text\n fn handle(req: Req) -> Resp {\n return ok_text(\"\${self.greeting}, \${req.path}\");\n }\n}\n\n-- any class with a matching handle() satisfies Handler\napp.get(\"/hello\", Hello { greeting: \"hi\" });");
return b;
}
@ -60,16 +61,26 @@ fn ch_actors() -> Text {
fn ch_deps() -> Text {
let b = el("p", "leading-relaxed mb-4",
"Dependencies are git repositories pinned in <code>wo.toml</code>; <code>wo.lock</code> " .. "records the exact revision, and locked builds work offline. The [deps] KEY names the " .. "module you <code>use</code>. This site has two: the web framework, and the wo-html " .. "library that rendered the page you are reading.");
b = b .. code_block("[deps]\nframework = { git = \"https://github.com/shoneyj/writeonce-framework\", rev = \"v0.1.0\" }\nhtml = { git = \"https://github.com/shoneyj/wo-html\", rev = \"v0.1.0\" }");
b = b .. code_block("use framework\nuse framework/http\nuse html\n\n-- html's builders + tailwind-style utilities, zero JS, no build step:\nlet body = el(\"h1\", \"text-3xl font-bold\", \"Hello\");\nreturn ok_html(page(\"Hello\", body));");
"Dependencies are git repositories pinned in <code>wo.toml</code>; <code>wo.lock</code> " .. "records the exact revision, and locked builds work offline. The [deps] KEY names the " .. "module you <code>use</code>. This site has two: <code>porch</code>, the writeonce web framework, and the writeonce-view " .. "library that rendered the page you are reading.");
b = b .. code_block(`
[deps]
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }
view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }`);
b = b .. code_block(`
use porch
use porch/http
use view
-- html's builders + tailwind-style utilities, zero JS, no build step:
let body = el("h1", "text-3xl font-bold", "Hello");
return ok_html(page("Hello", body));`);
return b;
}
fn ch_serving() -> Text {
let b = el("p", "leading-relaxed mb-4",
"The whole stack of this site: routes with <code>:param</code> captures, a middleware " .. "chain, handler classes, <code>@table</code> persistence, and server-rendered HTML — " .. "one binary behind a proxy. This is the site's own main, abbreviated:");
b = b .. code_block("fn main(args: multi Text) -> Int {\n seed_if_empty();\n let app = App { middleware: [], routes: [] };\n app.use_mw(Mw { m: Logging { pad: 0 } });\n app.get(\"/\", Home { pad: 0 });\n app.get(\"/ch/:slug\", ShowChapter { pad: 0 });\n app.post(\"/admin/ch/:slug\", AdminEdit { token: token });\n return app.serve(\"127.0.0.1\", port);\n}");
b = b .. code_block("fn main(args: multi Text) -> Int {\n seed_if_empty();\n let app = App { middleware: [], routes: [] };\n app.use_mw(Mw { m: Logging {} });\n app.get(\"/\", Home {});\n app.get(\"/ch/:slug\", ShowChapter {});\n app.post(\"/admin/ch/:slug\", AdminEdit { token: token });\n return app.serve(\"127.0.0.1\", port);\n}");
b = b .. el("p", "leading-relaxed mt-4",
"The admin route checks its bearer token in the handler — mechanism lives in the " .. "framework (<code>bearer_token</code>, constant-time <code>ct_eq</code>), POLICY stays " .. "in the app. Try editing this chapter: " .. "<code>curl -X POST -H \"authorization: Bearer ...\" -d \"title=...&amp;body=...\" /admin/ch/serving</code>.");
return b;

View file

@ -0,0 +1,13 @@
-- favicon/controller.wo — GET /favicon.svg. The one route that answers
-- something other than HTML or text, so it builds its own Resp.
use porch/http
use layout
pub class Favicon {
fn handle(req: Req) -> Resp {
let h: map<Text, Text> = {};
h["content-type"] = "image/svg+xml";
h["cache-control"] = "public, max-age=86400";
return Resp { status: 200, headers: h, body: favicon_svg() };
}
}

View file

@ -0,0 +1,9 @@
-- health.controller.wo — GET /health: the liveness probe the accept
-- script and any proxy poll. Text, not HTML, on purpose.
use porch/http
pub class Health {
fn handle(req: Req) -> Resp {
return ok_text("ok");
}
}

View file

@ -0,0 +1,19 @@
-- home/controller.wo — the CONTROLLER for `/`: query the model, fill the
-- view components that sit beside it, answer a Resp. One feature = one
-- directory = one module, view and controller together.
--
-- It reaches the chapter nav through `use chapter` and the query through
-- the model's `Chapters.links()` static — a class crosses module lines,
-- a free fn does not.
use porch/http
use layout
use chapter
pub class Home {
fn handle(req: Req) -> Resp {
let nav = ChapterNav { items: Chapters.links(), current: 0 };
let page = HomePage { chapter_nav: nav };
let shell = wide_shell("writeonce — learn the language", page.render());
return ok_html(shell.render());
}
}

View file

@ -0,0 +1,53 @@
-- home/view.wo — the VIEW for `/`. A component: fields in, Text out.
-- Everything on this page is static copy EXCEPT the chapter list, so
-- the one field is that list's already-built component — content
-- projection, the same slot pattern writeonce-view's `Layout` uses. HomePage
-- therefore knows nothing about chapters, the Chapter table, or how the
-- nav decides which entry is current.
use view
pub class HomePage {
chapter_nav: Component
fn render() -> Text {
-- hero: tagline + the two CTAs (the go.dev shape, no JS anywhere)
let h1 = el("h1", "text-4xl font-bold mb-4", "One language. One runtime.<br>One database. One binary.");
let sub = el("p", "text-lg text-gray-700 leading-relaxed mb-6", "writeonce is a language whose compiler, runtime, web server and " .. "database ship as a single never-stopping Linux binary. Ownership-" .. "checked memory, inferred GC where ownership cannot reach, actors " .. "on every core — and the page you are reading is served by it.");
let ctas = el("div", "flex items-center justify-center gap-4", btn_link("/ch/hello", "Get started", true) .. btn_link("https://github.com/shoneyJ/writeonce", "View source", false));
let hero = el("div", "text-center py-16", h1 .. sub .. ctas);
-- code showcase: a real flavor of the language
let show_head = el("h2", "text-2xl font-bold mb-2 text-center", "An actor per chat room, rows in the built-in database");
let show_cap = el("p", "text-sm text-gray-500 text-center mb-4", "No broker, no ORM, no async keyword — ownership moves the message, the WAL makes the row durable.");
let showcase = el("div", "mx-auto max-w-3xl mb-8", show_head .. show_cap .. home_snippet());
-- why-cards (2x2 grid, collapses on small screens)
let cards = card("One binary", "woc build emits a self-contained executable: VM, your bytecode, the database engine. Deploys are a file copy; the runtime swaps code in place.");
cards = cards .. card("Memory safety, no tax", "Rust-shaped ownership checked at compile time; where ownership cannot express the shape, the compiler infers GC — per shard, no global pause.");
cards = cards .. card("The database is built in", "Every class is a table. Inserts are WAL-logged before they acknowledge; restart replays. No server to operate, no connection string.");
cards = cards .. card("Actors on every core", "spawn returns an address, send moves ownership. Fibers park on io_uring instead of blocking threads — no async/await, ever.");
let grid = el("div", "grid grid-cols-2 gap-6 mb-8", cards);
-- chapters (the gate's anchor string lives here)
let learn = el("h2", "text-2xl font-bold mb-4", "Learn writeonce");
let learn_p = el("p", "leading-relaxed mb-4", "The tutorial is written in the language and stored in its tables — work through the chapters in order:");
let chapters = el("div", "bg-white rounded-lg border shadow-sm p-6 mb-8", learn .. learn_p .. self.chapter_nav.render());
return hero .. showcase .. grid .. chapters;
}
}
-- The homepage's code showcase: a real flavor of the language — an
-- actor per chat room, rows in the built-in database, one binary.
-- Page copy, so it lives with the page, not with the seed data.
fn home_snippet() -> Text {
let s = "@table\nclass Message {\n room: Text\n body: Text\n}\n\n";
s = s .. "class Room {\n name: Text\n fn receive(msg: Post) {\n";
s = s .. " insert Message { room: self.name, body: msg.body };\n";
s = s .. " print(\"[\${self.name}] \${msg.body}\");\n }\n}\n\n";
s = s .. "fn main() -> Int {\n";
s = s .. " let general: actor Post = spawn Room { name: \"general\" };\n";
s = s .. " send(general, Post { body: \"hello, writeonce\" });\n";
s = s .. " time.sleep(50);\n return 0;\n}";
return code_block(s);
}

View file

@ -0,0 +1,12 @@
-- install/controller.wo — GET /install. Nothing to query: the page is
-- static copy, so the controller only wraps it in the shell.
use porch/http
use layout
pub class ShowInstall {
fn handle(req: Req) -> Resp {
let page = InstallPage {};
let shell = reading_shell("writeonce — install", page.render());
return ok_html(shell.render());
}
}

View file

@ -0,0 +1,110 @@
-- install/view.wo — the VIEW for /install. Static copy: no fields, so
-- the component has none. It is still a component, and still renders
-- through the same interface as every other page.
use view
pub class InstallPage {
fn render() -> Text {
let head = `
<h1 class="text-3xl font-bold mb-4">Install writeonce</h1>
<p class="leading-relaxed mb-6">Two native binaries — <code>woc</code>,
the compiler, and <code>wovm</code>, the runtime VM. Both depend only on
the system C library. There is no package manager to install, no
language runtime to install on the machines you deploy to, and no
build toolchain beyond these two files.</p>`;
let sys = supported();
let s1 = section("1. Download",
`<p class="leading-relaxed mb-4">One tarball, two binaries. Take it from
the GitHub release, or from this site as a mirror — they are the same
bytes, and the checksum below proves it:</p>
<p class="mb-4">
<a class="btn no-underline" href="https://github.com/shoneyj/writeonce/releases/download/v0.1.0/writeonce-0.1.0-linux-amd64.tar.gz">Download 0.1.0 (linux-amd64)</a>
<a class="btn-outline no-underline" href="/dl/writeonce-0.1.0-linux-amd64.tar.gz">Mirror</a>
</p>
<p class="leading-relaxed mb-4">Verify it before you extract it. The
expected digest is served beside the archive at
<a href="/dl/writeonce-0.1.0-linux-amd64.tar.gz.sha256">.sha256</a>:</p>`,
code_block("curl -O https://writeonce.de/dl/writeonce-0.1.0-linux-amd64.tar.gz\ncurl -O https://writeonce.de/dl/writeonce-0.1.0-linux-amd64.tar.gz.sha256\nsha256sum -c writeonce-0.1.0-linux-amd64.tar.gz.sha256"));
let s2 = section("2. Extract it",
`<p class="leading-relaxed mb-4">Extract into <code>/usr/local</code>,
replacing any previous install. Run this as root, or through
<code>sudo</code>:</p>`,
code_block("rm -rf /usr/local/writeonce\ntar -C /usr/local -xzf writeonce-0.1.0-linux-amd64.tar.gz"));
let s3 = section("3. Put it on your PATH",
`<p class="leading-relaxed mb-4">Add one line to your
<code>$HOME/.profile</code> (or <code>/etc/profile</code> for every user
on the box), then restart your shell:</p>`,
code_block("export PATH=$PATH:/usr/local/writeonce/bin"));
let s4 = section("4. Check it",
`<p class="leading-relaxed mb-4">Both should print the same version.
<code>woc</code> finds <code>wovm</code> beside itself, so a tarball
install needs no further configuration.</p>`,
code_block("woc version # writeonce 0.1.0 linux/amd64\nwovm --version # wovm 0.1.0"));
let s5 = section("5. Your first project",
`<p class="leading-relaxed mb-4">A project is a directory with a
<code>wo.toml</code> manifest and one or more <code>.wo</code> files.
Nothing else — no lockfile to create by hand, no scaffolding step.</p>`,
code_block("mkdir hello && cd hello\n\ncat > wo.toml <<'EOF'\nname = \"hello\"\nversion = \"0.1.0\"\n\n[runtime]\nwo = \">= 0.1\"\nEOF\n\ncat > main.wo <<'EOF'\nfn main() -> Int {\n print(\"hello, writeonce\");\n return 0;\n}\nEOF"));
let s6 = section("6. Build and run",
`<p class="leading-relaxed mb-4"><code>woc &lt;dir&gt;</code> emits ONE
standalone binary at <code>target/&lt;name&gt;</code>. Copy that file to a
server and run it — the VM is inside it, and so is the database.</p>`,
code_block("woc .\n./target/hello # hello, writeonce"));
let s7 = section("7. Add a dependency",
`<p class="leading-relaxed mb-4">Dependencies are git repositories pinned
by revision. The <code>[deps]</code> KEY is the module name your code
<code>use</code>s. <code>woc</code> writes a <code>wo.lock</code> with the
exact revision, and a locked build works offline. See
<a href="/packages">Packages</a> for what is available.</p>`,
code_block("[deps]\nview = { git = \"https://github.com/shoneyj/writeonce-view\", rev = \"v0.1.0\" }"));
let note = el("div", "bg-white rounded-lg border shadow-sm p-6 mt-8",
el("h2", "text-lg font-bold mb-2", "Where things go") ..
`<p class="leading-relaxed">The tarball installs to
<code>/usr/local/writeonce</code>: <code>bin/woc</code>,
<code>bin/wovm</code>, and a <code>VERSION</code> file. To point
<code>woc</code> at a different VM, set <code>$WO_RUNTIME</code> or add a
<code>[build] runtime = "..."</code> key to <code>wo.toml</code>. A
manifest may also require a minimum toolchain with
<code>[runtime] wo = "&gt;= 0.1"</code>; <code>woc</code> refuses to build
a project that needs a newer toolchain than itself.</p>`);
return head .. sys .. s1 .. s2 .. s3 .. s4 .. s5 .. s6 .. s7 .. note;
}
}
-- What the release actually runs on. Every claim here is read off the
-- shipped binaries (`file`, and the highest GLIBC_ symbol version they
-- import), not off a wish list — an install page that overstates its
-- support costs someone an afternoon.
fn supported() -> Text {
return `
<div class="bg-white rounded-lg border shadow-sm p-6 mb-8">
<h2 class="text-2xl font-bold mb-2">Supported systems</h2>
<ul class="list-disc pl-6 leading-relaxed">
<li class="mb-2"><b>Linux on x86-64</b> — the only target built today. There is no ARM, macOS or Windows build.</li>
<li class="mb-2"><b>glibc 2.35 or newer.</b> The binaries link the system C library dynamically: <code>woc</code> imports symbols up to <code>GLIBC_2.35</code> and <code>wovm</code> up to <code>GLIBC_2.34</code>, read off the published v0.1.0 release. That covers Ubuntu 22.04+, Debian 12+ and Fedora 36+. On RHEL 9 (2.34) the runtime works but the compiler does not — build elsewhere and copy the binary over, which is the point of a self-contained executable.</li>
<li class="mb-2"><b>Not musl.</b> Alpine needs a build against musl, which does not exist yet.</li>
<li class="mb-2"><b>Nothing else.</b> No JVM, no Node, no Python, no package manager. The two binaries and libc are the whole dependency list.</li>
</ul>
<p class="leading-relaxed mt-4 text-gray-700">Check yours with
<code>ldd --version</code>. If it is older than 2.35, build from source
until a wider-compatibility release exists. The floor is set by the
machine that BUILT the release, not by the language — the CI runner is
pinned to Ubuntu 22.04 to keep it this low.</p>
</div>`;
}
-- One numbered step: heading, prose, and the commands to run.
fn section(title: Text, prose: Text, code: Text) -> Text {
return el("div", "mb-8",
el("h2", "text-2xl font-bold mb-2", title) .. prose .. code);
}

View file

@ -0,0 +1,49 @@
-- layout/app.wo — the app shell: one component wrapping every page with
-- the shared header and footer. Unlike the shop template, this site
-- INLINES its stylesheet (writeonce-view's `page()` does that), so the shell
-- fills writeonce-view's own `Layout` rather than writing its own document.
--
-- The only thing that varies between pages is the container width, so
-- that is the one extra slot — and the two widths are named once, here,
-- instead of as class strings scattered through the controllers.
use porch/http
use view
pub class AppShell {
title: Text
container: Text
content: Text
fn render() -> Text {
let l = Layout {
title: self.title,
head: head_links(),
nav: header(),
content: el("div", self.container, self.content),
footer: footer()
};
return l.render();
}
}
-- Chapter pages keep a reading width.
pub fn reading_shell(title: Text, content: Text) -> AppShell {
return AppShell { title: title, container: "mx-auto max-w-3xl px-4 py-8", content: content };
}
-- The homepage uses the wider container (the go.dev shape).
pub fn wide_shell(title: Text, content: Text) -> AppShell {
return AppShell { title: title, container: "mx-auto max-w-5xl px-4", content: content };
}
-- Every non-200 HTML answer goes through here, so an error page is a
-- real page: same chrome, same shell, just a different status.
pub fn html_error(status: Int, title: Text, msg: Text) -> Resp {
let content = el("h1", "text-2xl font-bold mb-4", title) ..
el("p", "", msg) ..
el("p", "", link("/", "", "Back to the chapters"));
let shell = reading_shell("writeonce — ${title}", content);
let h: map<Text, Text> = {};
h["content-type"] = "text/html; charset=utf-8";
return Resp { status: status, headers: h, body: shell.render() };
}

View file

@ -0,0 +1,6 @@
-- layout/footer.wo — the site footer, shared by every page.
use view
pub fn footer() -> Text {
return el("div", "footer", "writeonce.de — served by the language it teaches. " .. "One binary: compiler, runtime, database, this page.");
}

View file

@ -0,0 +1,13 @@
-- layout/header.wo — the site navigation bar, shared by every page.
use view
pub fn header() -> Text {
let links = link("/install", "text-gray-700", "Install");
links = links .. link("/ch/hello", "text-gray-700", "Tutorial");
links = links .. link("/packages", "text-gray-700", "Packages");
links = links .. link("https://github.com/shoneyJ/writeonce", "text-gray-700", "GitHub");
-- The brand is the mark plus the wordmark, one inline-flex row so the
-- tile and the text share a baseline at any font size.
let brand = el("span", "flex items-center gap-2", logo_svg(28) .. "<span>writeonce.de</span>");
return nav_bar("/", brand, links);
}

View file

@ -0,0 +1,23 @@
-- layout/logo.wo — the mark, authored as inline SVG so it needs no asset
-- pipeline and no second request: a dark tile with a two-stroke "W", the
-- first half white and the second the site's accent blue. One glyph, two
-- colours, still legible at 16px.
--
-- One source, two consumers: the nav brand embeds it, and
-- `favicon/controller.wo` serves the same bytes at /favicon.svg.
pub fn logo_svg(px: Int) -> Text {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 64 64" role="img" aria-label="writeonce"><rect width="64" height="64" rx="14" fill="#111827"/><path d="M14 20 L22 44 L32 28" fill="none" stroke="#ffffff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><path d="M32 28 L42 44 L50 20" fill="none" stroke="#2563eb" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
-- The favicon is the same mark without intrinsic width/height, so the
-- browser scales it to whatever the tab needs.
pub fn favicon_svg() -> Text {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#111827"/><path d="M14 20 L22 44 L32 28" fill="none" stroke="#ffffff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/><path d="M32 28 L42 44 L50 20" fill="none" stroke="#2563eb" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
-- What goes in <head>. Inline SVG, no request, no cache question.
pub fn head_links() -> Text {
return `<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<meta name="description" content="writeonce — one language, one runtime, one database, one binary.">`;
}

View file

@ -1,142 +1,30 @@
-- site — writeonce.de: the language tutorial, served BY the language.
-- Full stack in one binary: writeonce-framework ([deps]) for HTTP/routing/
-- auth, wo-html ([deps]) for server-rendered pages with Tailwind-style
-- Full stack in one binary: porch ([deps]) for HTTP/routing/
-- auth, writeonce-view ([deps]) for server-rendered pages with Tailwind-style
-- utilities, @table + WAL for the chapters themselves. The site is its own
-- final chapter: /ch/serving shows this file's shape.
--
-- SITE_TOKEN=... WO_DATA=./data ./site 8080
-- SITE_HOST=0.0.0.0 ... ./site 8080 (reachable from the network)
-- WO_DIST=/srv/dist ... (where /dl serves tarballs from)
--
-- Behind nginx/caddy for writeonce.de: the proxy terminates TLS and
-- forwards to 127.0.0.1:8080 (the framework speaks HTTP/1.1 keep-alive).
--
-- This file is the BOOTSTRAP and nothing else: seed, routes, serve. The
-- model is types.wo; every feature is a directory holding its view and
-- its controller.
use env
use framework
use framework/http
use framework/router
use html
-- Every chapter is a row: slug is the URL, ord orders the nav, body is a
-- server-rendered HTML fragment. Edits (the admin route) persist through
-- the WAL under WO_DATA and replay on restart.
@table(name: "chapters", index: [slug])
class Chapter {
slug: Text @unique
ord: Int
title: Text
body: Text
}
fn seed_if_empty() {
let n = 0;
for c in from x in Chapter take 1 select x {
n = n + 1;
}
if n == 0 {
seed_chapters();
}
}
-- ---- rendering ---------------------------------------------------------
fn ok_html(body: Text) -> Resp {
let h: map<Text, Text> = {};
h["content-type"] = "text/html; charset=utf-8";
return Resp { status: 200, headers: h, body: body };
}
fn site_header() -> Text {
let brand = link("/", "text-xl font-bold text-gray-900 no-underline", "writeonce.de");
let tag = el("span", "text-sm text-gray-500", "one language, one runtime, one database, one binary");
return el("div", "flex items-center justify-between mb-8", brand .. tag);
}
fn shell(title: Text, inner: Text) -> Text {
let body = el("div", "mx-auto max-w-3xl px-4 py-8", site_header() .. inner);
return page(title, body);
}
fn chapter_nav(current: Int) -> Text {
let items = "";
for c in from x in Chapter order by x.ord select x {
let label = "${c.ord}. ${esc(c.title)}";
if c.ord == current {
items = items .. el("li", "mb-2 font-bold text-gray-900", label);
} else {
items = items .. el("li", "mb-2", link("/ch/${c.slug}", "", label));
}
}
return el("ul", "list-disc pl-6", items);
}
-- ---- handlers ----------------------------------------------------------
class Home {
pad: Int
fn handle(req: Req) -> Resp {
let hero = el("h1", "text-3xl font-bold mb-4", "Learn writeonce");
let intro = el("p", "leading-relaxed mb-4", "A language where the runtime, the database and the web server are one thing. " .. "This site is written in it — every page you read here is a " .. "<code>Resp</code> built by <code>.wo</code> code, stored in the language's own " .. "tables, served by its own framework. Work through the chapters in order:");
let body = el("div", "bg-white rounded-lg border shadow-sm p-6", hero .. intro .. chapter_nav(0));
return ok_html(shell("writeonce — learn the language", body));
}
}
class ShowChapter {
pad: Int
fn handle(req: Req) -> Resp {
let slug = req.params["slug"];
if slug == nil { return not_found(); }
let hits = from c in Chapter where c.slug == slug take 1 select c;
if len(hits) == 0 {
let msg = el("h1", "text-2xl font-bold mb-4", "No such chapter");
let back = el("p", "", link("/", "", "Back to the chapters"));
let h: map<Text, Text> = {};
h["content-type"] = "text/html; charset=utf-8";
let nf = shell("writeonce — not found", msg .. back);
return Resp { status: 404, headers: h, body: nf };
}
let c = hits[0];
let head = el("h1", "text-3xl font-bold mb-4", "${c.ord}. ${esc(c.title)}");
let art = el("div", "bg-white rounded-lg border shadow-sm p-6", head .. c.body);
let nav = el("div", "mt-8", el("h2", "text-lg font-bold mb-2", "Chapters") .. chapter_nav(c.ord));
return ok_html(shell("writeonce — ${c.title}", art .. nav));
}
}
-- POST /admin/ch/:slug — title/body update, form-encoded, bearer-gated.
-- Mechanism (bearer_token, constant-time ct_eq) is the framework's;
-- POLICY — which routes, which token — is this app's, right here.
class AdminEdit {
token: Text
fn handle(req: Req) -> Resp {
let got = bearer_token(req);
if got == nil { return unauthorized(); }
if ct_eq("${got}", self.token) == false { return unauthorized(); }
let slug = req.params["slug"];
if slug == nil { return not_found(); }
let hits = from c in Chapter where c.slug == slug take 1 select c;
if len(hits) == 0 { return not_found(); }
let f = form_values(req);
if f == nil { return bad_request("body must be form-encoded (title, body)"); }
let title = f["title"];
let body = f["body"];
if title == nil and body == nil { return bad_request("nothing to update"); }
if title != nil {
let t = trim("${title}");
if t == "" { return bad_request("title must not be empty"); }
hits[0].title = t;
}
if body != nil {
hits[0].body = "${body}";
}
return redirect("/ch/${slug}");
}
}
class Health {
pad: Int
fn handle(req: Req) -> Resp {
return ok_text("ok");
}
}
use porch
use porch/http
use porch/router
use home
use chapter
use install
use packages
use admin
use health
use favicon
fn main(args: multi Text) -> Int {
if len(args) < 1 {
@ -154,13 +42,38 @@ fn main(args: multi Text) -> Int {
return 2;
}
-- Bind to loopback unless SITE_HOST says otherwise. Behind a proxy
-- loopback is right; SITE_HOST=0.0.0.0 (or a LAN address) is how you
-- reach it from another machine while developing.
let host = "127.0.0.1";
let h = env.get("SITE_HOST");
if h != nil {
host = "${h}";
}
-- Where the release tarballs live. `just dist` writes them to ./dist;
-- a deployment points WO_DIST at wherever it keeps them.
let dist = "dist";
let d = env.get("WO_DIST");
if d != nil {
dist = "${d}";
}
seed_if_empty();
let app = App { middleware: [], routes: [] };
app.use_mw(Mw { m: Logging { pad: 0 } });
app.get("/", Home { pad: 0 });
app.get("/health", Health { pad: 0 });
app.get("/ch/:slug", ShowChapter { pad: 0 });
app.use_mw(Mw { m: Logging {} });
app.get("/", Home {});
app.get("/install", ShowInstall {});
app.get("/packages", ShowPackages {});
app.get("/packages/:name", ShowPackage {});
app.get("/health", Health {});
app.get("/favicon.svg", Favicon {});
-- 16 MiB ceiling: the toolchain tarball is under 1 MiB today, and
-- `max_bytes` is a hard truncation point, not a hint.
app.get("/dl/*path", StaticFiles { dir: dist, max_bytes: 16777216 });
app.get("/ch/:slug", ShowChapter {});
app.post("/admin/ch/:slug", AdminEdit { token: "${token}" });
return app.serve("127.0.0.1", port);
print_err("site: listening on ${host}:${port}");
return app.serve(host, port);
}

View file

@ -0,0 +1,90 @@
-- packages/controller.wo — GET /packages and /packages/:name.
--
-- The catalogue is static data, not rows, so it lives here on the DATA
-- side of the feature rather than in the view: the components are handed
-- values exactly as they would be if this were a table one day.
use porch/http
use view
use layout
typedef PackageInfo = {
name: Text,
summary: Text,
git: Text,
rev: Text,
what: Text,
usage: Text
}
pub class ShowPackages {
fn handle(req: Req) -> Resp {
let cards: multi Component = [];
for p in catalogue() {
push(cards, PackageCard { name: p.name, summary: p.summary });
}
let page = PackagesPage { cards: cards };
let shell = reading_shell("writeonce — packages", page.render());
return ok_html(shell.render());
}
}
pub class ShowPackage {
fn handle(req: Req) -> Resp {
let name = req.params["name"];
if name == nil {
return html_error(404, "No such package", "The address names no package.");
}
for p in catalogue() {
if p.name == name {
let page = PackagePage {
name: p.name, summary: p.summary, git: p.git, rev: p.rev,
what: p.what, usage: p.usage
};
let shell = reading_shell("writeonce — ${p.name}", page.render());
return ok_html(shell.render());
}
}
return html_error(404, "No such package", "Nothing is published under that name.");
}
}
-- The two libraries this site runs on. Both are `kind = "library"`
-- projects: no `fn main`, imported through `[deps]`.
fn catalogue() -> multi PackageInfo {
let out: multi PackageInfo = [];
push(out, PackageInfo {
name: "porch",
summary: "The writeonce web framework: an HTTP/1.1 keep-alive server core, a router with :param captures, and Handler/Middleware structural interfaces. Imported as <code>use porch</code>.",
git: "https://github.com/shoneyj/porch",
rev: "v0.1.0",
what: `
<ul class="list-disc pl-6 leading-relaxed">
<li class="mb-2"><code>App</code> — the route table and the middleware chains; <code>app.get</code>/<code>post</code>/<code>delete_</code>, <code>use_mw</code>, <code>use_after</code>, <code>mount</code>, <code>serve</code>.</li>
<li class="mb-2"><code>StaticFiles</code> — serve a directory over a wildcard route. Traversal is refused, not normalised; <code>max_bytes</code> is a hard ceiling. This site's <code>/dl</code> downloads run through it.</li>
<li class="mb-2"><code>Handler</code>, <code>Middleware</code>, <code>After</code> — structural interfaces. A handler is a CLASS; its fields are the closure this language does not have.</li>
<li class="mb-2"><code>Req</code>/<code>Resp</code> plus builders: <code>ok_text</code>, <code>ok_html</code>, <code>ok_json</code>, <code>created_json</code>, <code>not_found</code>, <code>bad_request</code>, <code>unauthorized</code>, <code>conflict</code>, <code>redirect</code>.</li>
<li class="mb-2">Auth MECHANISM only — <code>bearer_token</code>, <code>basic_credentials</code>, constant-time <code>ct_eq</code>. Which routes are gated stays your policy.</li>
<li class="mb-2">Bodies: <code>form_values</code>, <code>multipart_parts</code>, content negotiation, ETags, security headers, CORS, WebSocket frames.</li>
</ul>`,
usage: code_block("use porch\nuse porch/http\nuse porch/router\n\nclass Hello {\n fn handle(req: Req) -> Resp {\n return ok_text(\"hello\");\n }\n}\n\nfn main(args: multi Text) -> Int {\n let app = App { middleware: [], routes: [] };\n app.use_mw(Mw { m: Logging {} });\n app.get(\"/\", Hello {});\n return app.serve(\"127.0.0.1\", 8080);\n}")
});
push(out, PackageInfo {
name: "view",
summary: "Server-rendered HTML as plain Text: escaping, element builders, a component layer, and a Tailwind-style utility stylesheet inlined into every page.",
git: "https://github.com/shoneyj/writeonce-view",
rev: "v0.1.0",
what: `
<ul class="list-disc pl-6 leading-relaxed">
<li class="mb-2"><code>esc()</code> — escapes <code>&amp; &lt; &gt; "</code>. A <code>&#123;&#123; &#125;&#125;</code> hole in a raw text literal compiles to a call to it, so display data is escaped by construction. (Written with entities here: inside a raw literal, a real <code>&#123;&#123;</code> would BE a hole.)</li>
<li class="mb-2"><code>Component</code> — a class with fields and <code>fn render() -&gt; Text</code>, satisfied structurally. <code>multi Component</code> holds children directly; <code>render_all</code> renders them in order.</li>
<li class="mb-2"><code>Layout</code> — content projection: title, head, nav, content and footer as pre-rendered slots.</li>
<li class="mb-2">Builders: <code>el</code>, <code>link</code>, <code>card</code>, <code>nav_bar</code>, <code>code_block</code>, <code>form_post</code>, <code>text_input</code>, <code>text_area</code>, <code>submit_btn</code>, <code>btn_link</code>.</li>
<li class="mb-2"><code>tw_css()</code> + <code>page()</code> — one hand-written utility sheet, inlined, so a page is one self-contained response. No CDN, no build step, no JS.</li>
</ul>`,
usage: code_block("use view\n\nclass Card {\n name: Text\n fn render() -> Text {\n return `\n <div class=\"card\">\n <h3>{{ self.name }}</h3>\n </div>`;\n }\n}\n\n-- in a handler:\nlet c = Card { name: user_supplied };\nreturn ok_html(page(\"Hello\", c.render()));")
});
return out;
}

View file

@ -0,0 +1,61 @@
-- packages/view.wo — the VIEW for /packages and /packages/:name.
--
-- Three components: a card for the index, the index itself (holding its
-- cards as CHILDREN through the structural interface), and the detail
-- page. None of them knows where the catalogue came from.
use view
pub class PackageCard {
name: Text
summary: Text
fn render() -> Text {
return `
<div class="bg-white rounded-lg border shadow-sm p-6">
<h3 class="text-lg font-bold mb-2"><a href="/packages/{{ self.name }}">{{ self.name }}</a></h3>
<p class="leading-relaxed text-gray-700">{{ self.summary }}</p>
</div>`;
}
}
pub class PackagesPage {
cards: multi Component
fn render() -> Text {
return `
<h1 class="text-3xl font-bold mb-4">Packages</h1>
<p class="leading-relaxed mb-6">A package is a git repository with a
<code>wo.toml</code> that says <code>kind = "library"</code>. There is no
registry and no publish step: you depend on a URL and a revision, and
<code>wo.lock</code> pins exactly what you built against. These are the
libraries this site itself is built on.</p>
<div class="grid grid-cols-2 gap-6 mb-8">${render_all(self.cards)}</div>`;
}
}
pub class PackagePage {
name: Text
summary: Text
git: Text
rev: Text
what: Text
usage: Text
fn render() -> Text {
let dep = code_block("[deps]\n" .. self.name .. " = { git = \"" .. self.git .. "\", rev = \"" .. self.rev .. "\" }");
let head = `
<h1 class="text-3xl font-bold mb-2">{{ self.name }}</h1>
<p class="leading-relaxed text-gray-700 mb-6">{{ self.summary }}</p>
<h2 class="text-2xl font-bold mb-2">Install</h2>
<p class="leading-relaxed mb-4">Add it to your <code>wo.toml</code>. The
KEY is the module name — that is what <code>use</code> imports.</p>`;
let what = `
<h2 class="text-2xl font-bold mb-2 mt-8">What you get</h2>
${self.what}`;
let usage = `
<h2 class="text-2xl font-bold mb-2 mt-8">Usage</h2>
${self.usage}`;
let back = el("p", "mt-8", link("/packages", "", "← all packages"));
return head .. dep .. what .. usage .. back;
}
}

View file

@ -0,0 +1,52 @@
-- types.wo — the MODEL. Every @table class IS a WAL-backed table: rows
-- persist under WO_DATA and replay on restart; without WO_DATA the
-- store is RAM-only. Nothing else lives here — no rendering, no request
-- handling.
--
-- Root module by NECESSITY, not choice: `pub` and `@table` cannot
-- combine yet (recorded language gap), so tables cannot be exported to
-- other modules — everything that queries them (the controllers) lives
-- in the root module too.
-- Every chapter is a row: slug is the URL, ord orders the nav, body is a
-- server-rendered HTML fragment. Edits (the admin route) persist through
-- the WAL under WO_DATA and replay on restart.
@table(name: "chapters", index: [slug])
class Chapter {
slug: Text @unique
ord: Int
title: Text
body: Text
}
-- One nav entry: a PROJECTION of a Chapter row, not the row itself. It
-- lives with the model because that is what it is — the views merely
-- consume it, and never hold a database handle.
typedef ChapterLink = { ord: Int, title: Text, slug: Text }
-- The chapter queries, on a class so every feature module can reach
-- them: a free `fn` is scoped to the module that declares it (WO-E210),
-- but a CLASS — and its statics — is reachable across module lines.
-- That is what lets `home/` and `chapter/` share one query without one
-- importing the other.
class Chapters {
-- Every chapter as a nav entry, ordered. Two pages call this.
static fn links() -> multi ChapterLink {
let items: multi ChapterLink = [];
for c in from x in Chapter order by x.ord select x {
push(items, ChapterLink { ord: c.ord, title: c.title, slug: c.slug });
}
return items;
}
}
-- First boot only: an empty table gets the tutorial (content.wo).
fn seed_if_empty() {
let n = 0;
for c in from x in Chapter take 1 select x {
n = n + 1;
}
if n == 0 {
seed_chapters();
}
}

View file

@ -1,13 +1,13 @@
name = "site"
version = "0.1.0"
description = "writeonce.de — the language tutorial served by the language: framework + wo-html [deps], @table chapters, server-rendered pages"
description = "writeonce.de — the language tutorial served by the language: framework + writeonce-view [deps], @table chapters, server-rendered pages"
[runtime]
wo = ">= 0.1"
# Two real dependencies (the gate substitutes file:// remotes built from
# docs/examples/writeonce-framework and docs/examples/wo-html, so CI never
# docs/examples/porch and docs/examples/writeonce-view, so CI never
# touches the network). The [deps] KEY is the module name `use` imports.
[deps]
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
html = { git = "https://github.com/shoneyj/wo-html", rev = "v0.1.0" }
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }
view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }

View file

@ -1,7 +1,7 @@
# web-app — the storefront sample
A small store: `Product`/`Order` as `@table` classes, JSON routes, one auth
middleware — built on [`writeonce-framework`](../writeonce-framework/), which
middleware — built on [`porch`](../porch/), which
it imports **through `[deps]`** (iteration 15). This app is iteration 16's
acceptance workload: `just web-app` runs the whole chain — fetch → lock →
build → serve → curl matrix → restart persistence → SIGTERM.

View file

@ -1,4 +1,4 @@
-- web-app — the storefront: writeonce-framework (via [deps]) + @table
-- web-app — the storefront: porch (via [deps]) + @table
-- persistence. Every handler is a class satisfying Handler; the auth gate is
-- a Middleware; the data layer is the language's own database — no ORM, no
-- separate process, one binary.
@ -6,9 +6,9 @@ use env
use json
use net
use time
use framework
use framework/http
use framework/router
use porch
use porch/http
use porch/router
-- decode target for POST /products, encode shape for every product answer
typedef ProductView = { name: Text, price: Float, stock: Int }
@ -19,7 +19,6 @@ fn view_json(name: Text, price: Float, stock: Int) -> Text {
}
class ListProducts {
pad: Int
fn handle(req: Req) -> Resp {
let body = "[";
let first = true;
@ -33,7 +32,6 @@ class ListProducts {
}
class ShowProduct {
pad: Int
fn handle(req: Req) -> Resp {
let name = req.params["name"];
if name == nil { return bad_request("no name"); }
@ -54,7 +52,6 @@ fn create_product(name: Text, price: Float, stock: Int) -> Resp {
}
class CreateProduct {
pad: Int
fn handle(req: Req) -> Resp {
if media_type(req) == "multipart/form-data" {
let ps = multipart_parts(req);
@ -95,7 +92,6 @@ class CreateProduct {
}
class CreateOrder {
pad: Int
fn handle(req: Req) -> Resp {
let v = json.decode(req.body) as NewOrder;
if v == nil { return bad_request("body must be {product, qty}"); }
@ -108,7 +104,6 @@ class CreateOrder {
}
class DeleteProduct {
pad: Int
fn handle(req: Req) -> Resp {
let name = req.params["name"];
if name == nil { return bad_request("no name"); }
@ -146,7 +141,6 @@ class ConnWorker {
-- a deliberately slow route: the concurrency proof's workload
class Slow {
pad: Int
fn handle(req: Req) -> Resp {
time.sleep(400);
return ok_text("slow done");
@ -157,7 +151,6 @@ class Slow {
-- wildcard capture: GET /files/*path echoes the rest
class EchoPath {
pad: Int
fn handle(req: Req) -> Resp {
let p = req.params["path"];
if p == nil { return ok_text("path="); }
@ -167,7 +160,6 @@ class EchoPath {
-- group middleware writes the request-scoped ctx bag; the handler reads it
class StampCtx {
pad: Int
fn before(mut req: Req) -> ?Resp {
req.ctx["via"] = "api-group";
return nil;
@ -175,7 +167,6 @@ class StampCtx {
}
class ApiPing {
pad: Int
fn handle(req: Req) -> Resp {
let via = req.ctx["via"];
if via == nil { return ok_text("pong via="); }
@ -185,7 +176,6 @@ class ApiPing {
-- ETag + conditional: same body = same tag; If-None-Match collapses to 304
class EtagProbe {
pad: Int
fn handle(req: Req) -> Resp {
return with_etag(req, ok_json("{\"v\":1}"));
}
@ -193,7 +183,6 @@ class EtagProbe {
-- response-side negotiation: JSON or nothing
class NegoProbe {
pad: Int
fn handle(req: Req) -> Resp {
if accepts(req, "application/json") == false {
let h: map<Text, Text> = {};
@ -256,20 +245,20 @@ fn build_app(token: Text) -> App {
app.use_mw(Mw { m: BearerAuth { token: "${token}", principal: "api" } });
-- the response half: security headers + the CORS origin stamp on every
-- response that leaves dispatch (404/405/401 included)
app.use_after(Aw { a: SecurityHeaders { pad: 0 } });
app.use_after(Aw { a: SecurityHeaders {} });
app.use_after(Aw { a: Cors { allow_origin: "*" } });
app.get("/products", ListProducts { pad: 0 });
app.get("/products/:name", ShowProduct { pad: 0 });
app.post("/products", CreateProduct { pad: 0 });
app.post("/orders", CreateOrder { pad: 0 });
app.delete_("/products/:name", DeleteProduct { pad: 0 });
app.get("/files/*path", EchoPath { pad: 0 });
app.get("/etag-probe", EtagProbe { pad: 0 });
app.get("/nego", NegoProbe { pad: 0 });
app.get("/slow", Slow { pad: 0 });
app.get("/products", ListProducts {});
app.get("/products/:name", ShowProduct {});
app.post("/products", CreateProduct {});
app.post("/orders", CreateOrder {});
app.delete_("/products/:name", DeleteProduct {});
app.get("/files/*path", EchoPath {});
app.get("/etag-probe", EtagProbe {});
app.get("/nego", NegoProbe {});
app.get("/slow", Slow {});
let g = Group { prefix: "/api" };
g.use_mw(Mw { m: StampCtx { pad: 0 } });
g.get("/ping", ApiPing { pad: 0 });
g.use_mw(Mw { m: StampCtx {} });
g.get("/ping", ApiPing {});
app.mount(g);
return app;
}

View file

@ -1,6 +1,6 @@
name = "web-app"
version = "0.1.0"
description = "Storefront sample: consumes writeonce-framework through [deps]; @table persistence; iteration 16's acceptance workload"
description = "Storefront sample: consumes porch through [deps]; @table persistence; iteration 16's acceptance workload"
[runtime]
wo = ">= 0.1"
@ -11,9 +11,14 @@ wo = ">= 0.1"
# The framework is a real dependency, never a relative path — extraction of
# the framework to its own repository changes only this URL. The acceptance
# gate (scripts/web-app-accept.sh) substitutes a run-time file:// remote
# built from docs/examples/writeonce-framework, so CI never needs the
# built from docs/examples/porch, so CI never needs the
# network and this repo never carries .wo-deps/wo.lock artifacts.
# The [deps] KEY is the module name `use` imports (hyphens are not identifier
# characters, so the key is `framework` while the repository keeps its name).
# The [deps] KEY is the module name `use` imports — here `porch`, so the source
# says `use porch`. The key must be a legal identifier, which per
# compiler/src/lexer.ml's `is_ident_cont` DOES include `-` (an internal dash is
# part of the identifier: `a-b` is one name, which is why binary minus needs
# spaces around it). So a hyphenated key like `wo-serve` would be legal too;
# `porch` is simply shorter. An earlier version of this comment claimed hyphens
# were illegal and named a key this file has never used — both wrong.
[deps]
framework = { git = "https://github.com/shoneyj/writeonce-framework", rev = "v0.1.0" }
porch = { git = "https://github.com/shoneyj/porch", rev = "v0.1.0" }

View file

@ -1,123 +0,0 @@
-- wo-html — server-rendered HTML as plain Text. Three layers, all pure:
-- esc() HTML-escape untrusted text (the ONLY defense: use it on
-- everything that did not come from your own code)
-- el()/... element builders — `el("h1", "text-3xl font-bold", t)`
-- tw_css() a Tailwind-style utility stylesheet: the same class
-- names Tailwind popularized, hand-written as one static
-- sheet, inlined by page() so a page is one self-contained
-- response — no CDN, no build step, no JS
--
-- The language has no varargs and no closures (doctrine), so builders
-- take exactly (tag, classes, inner) and pages compose by `..` and by
-- functions returning Text. That constraint is the demo: an HTML layer
-- in writeonce is ordinary code, not a template dialect.
-- HTML-escape: & < > " (the four that matter in text and attributes).
pub fn esc(t: Text) -> Text {
let out = "";
let i = 0;
let n = len(t);
while i < n {
let b = byte_at(t, i);
if b == 38 { out = out .. "&amp;"; }
else {
if b == 60 { out = out .. "&lt;"; }
else {
if b == 62 { out = out .. "&gt;"; }
else {
if b == 34 { out = out .. "&quot;"; }
else { out = out .. substr(t, i, 1); }
}
}
}
i = i + 1;
}
return out;
}
-- One element. Empty class list = no attribute. The inner text is the
-- CALLER's business: pass esc(user_text) for data, raw markup for
-- fragments you built yourself.
pub fn el(tag: Text, cls: Text, inner: Text) -> Text {
if cls == "" { return "<${tag}>${inner}</${tag}>"; }
return "<${tag} class=\"${cls}\">${inner}</${tag}>";
}
-- An anchor: href is attribute context, so it is escaped here.
pub fn link(href: Text, cls: Text, label: Text) -> Text {
if cls == "" { return "<a href=\"${esc(href)}\">${label}</a>"; }
return "<a href=\"${esc(href)}\" class=\"${cls}\">${label}</a>";
}
-- A code block: content is ALWAYS escaped — code samples are exactly the
-- text that breaks HTML otherwise.
pub fn code_block(src: Text) -> Text {
return el("pre", "code-block", el("code", "", esc(src)));
}
pub fn text_input(name: Text, value: Text) -> Text {
return "<input type=\"text\" name=\"${esc(name)}\" value=\"${esc(value)}\" class=\"field\">";
}
pub fn text_area(name: Text, value: Text, rows: Int) -> Text {
return "<textarea name=\"${esc(name)}\" rows=\"${rows}\" class=\"field\">${esc(value)}</textarea>";
}
pub fn submit_btn(label: Text) -> Text {
return "<button type=\"submit\" class=\"btn\">${esc(label)}</button>";
}
pub fn form_post(action: Text, inner: Text) -> Text {
return "<form method=\"POST\" action=\"${esc(action)}\" class=\"flex flex-col gap-2\">${inner}</form>";
}
-- The utility sheet. Tailwind's names, one hand-written static sheet —
-- only the utilities this ecosystem's pages actually use; growing it is
-- adding a line, not adopting a toolchain. `.code-block`, `.field` and
-- `.btn` are the three composites the builders above rely on.
pub fn tw_css() -> Text {
let c = "*{box-sizing:border-box;margin:0;padding:0}";
c = c .. "body{font-family:system-ui,sans-serif;background:#f9fafb;color:#111827;line-height:1.6}";
c = c .. ".mx-auto{margin-left:auto;margin-right:auto}";
c = c .. ".max-w-3xl{max-width:48rem}";
c = c .. ".p-4{padding:1rem}.p-6{padding:1.5rem}.px-4{padding-left:1rem;padding-right:1rem}";
c = c .. ".py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}";
c = c .. ".mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}";
c = c .. ".mt-4{margin-top:1rem}.mt-8{margin-top:2rem}";
c = c .. ".flex{display:flex}.flex-col{flex-direction:column}";
c = c .. ".items-center{align-items:center}.justify-between{justify-content:space-between}";
c = c .. ".gap-2{gap:.5rem}.gap-4{gap:1rem}";
c = c .. ".text-sm{font-size:.875rem}.text-lg{font-size:1.125rem}";
c = c .. ".text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.text-3xl{font-size:1.875rem}";
c = c .. ".font-bold{font-weight:700}.font-mono{font-family:ui-monospace,monospace}";
c = c .. ".text-gray-500{color:#6b7280}.text-gray-700{color:#374151}.text-gray-900{color:#111827}";
c = c .. ".text-blue-600{color:#2563eb}.text-white{color:#fff}";
c = c .. ".bg-white{background:#fff}.bg-gray-50{background:#f9fafb}";
c = c .. ".bg-gray-900{background:#111827}.bg-blue-600{background:#2563eb}";
c = c .. ".rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}";
c = c .. ".border{border:1px solid #e5e7eb}.shadow-sm{box-shadow:0 1px 2px rgba(0,0,0,.05)}";
c = c .. ".block{display:block}.w-full{width:100%}.leading-relaxed{line-height:1.75}";
c = c .. ".underline{text-decoration:underline}.no-underline{text-decoration:none}";
c = c .. ".list-disc{list-style:disc}.pl-6{padding-left:1.5rem}";
c = c .. "a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}";
c = c .. ".code-block{background:#111827;color:#e5e7eb;padding:1rem;border-radius:.5rem;";
c = c .. "overflow-x:auto;font-size:.875rem;line-height:1.6;margin:1rem 0}";
c = c .. ".code-block code{font-family:ui-monospace,monospace;white-space:pre}";
c = c .. ".field{display:block;width:100%;border:1px solid #e5e7eb;border-radius:.25rem;";
c = c .. "padding:.5rem;font-family:ui-monospace,monospace;font-size:.875rem}";
c = c .. ".btn{background:#2563eb;color:#fff;border:0;border-radius:.25rem;";
c = c .. "padding:.5rem 1rem;font-weight:700;cursor:pointer}";
return c;
}
-- One full document: the sheet inlined, viewport set, body handed in.
-- Self-contained by construction — view-source shows everything.
pub fn page(title: Text, body: Text) -> Text {
let d = "<!doctype html><html><head><meta charset=\"utf-8\">";
d = d .. "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">";
d = d .. "<title>${esc(title)}</title>";
d = d .. "<style>${tw_css()}</style></head><body>";
d = d .. body;
d = d .. "</body></html>";
return d;
}

View file

@ -0,0 +1,108 @@
# writeonce-view — server-rendered HTML as plain Text
> Renamed 2026-08-25: this library was `wo-html`, imported as `use html`.
> Stories, specs and plans dated before that still say the old name — they
> are dated records and were left as written.
A view library, not a framework and not a template engine. Everything in
it is a pure function or a class with a `render()`; nothing here opens a
socket, reads a file, or touches the database.
```
[deps]
view = { git = "https://github.com/shoneyj/writeonce-view", rev = "v0.1.0" }
```
## The four layers
| layer | what it is |
| --- | --- |
| `esc()` | HTML-escape `& < > "`. A `{{ }}` hole in a raw text literal compiles to a call to this, so display data is escaped by construction; calling it by hand is the fallback, not the norm |
| `el()`, `link()`, `card()`, `form_post()`, … | element builders — `el("h1", "text-3xl font-bold", t)` |
| `Component` / `Layout` / `render_all()` | the view unit and its composition |
| `tw_css()` / `page()` | a hand-written Tailwind-style utility sheet, inlined into one self-contained document — no CDN, no build step, no JS |
## Components
A component is a class with fields and `fn render() -> Text`. Nothing
declares that it implements `Component` — satisfaction is **structural**,
exactly like the framework's `Handler`. Its fields ARE its inputs; the
language has no closures, so a field is the capture.
```
class ProductCard {
sku: Text
name: Text
fn render() -> Text {
return `
<div class="card">
<h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
</div>`;
}
}
```
Composition is nesting — a parent holds children and calls their render:
```
pub class ProductListPage {
cards: multi Component
fn render() -> Text {
return `<div class="grid">${render_all(self.cards)}</div>`;
}
}
```
`multi Component` holds a heterogeneous list **directly**; no wrapper
record is needed (the framework's `Mw`/`Aw` wrappers are not a language
requirement). `render_all(cs)` renders children in order.
`Layout { title, head, nav, content, footer }` is content projection —
Angular's `<ng-content>` with the slots as ordinary pre-rendered `Text`.
The caller passes `child.render()`, a raw literal, or a builder's output;
the layout never learns which, which is precisely why it never needs the
child's type. A page wanting a fixed-width column wraps its content
before handing it over — deliberately no container knob here. `head` is
the one slot that is not body markup: a favicon link or a meta tag has
nowhere else to go, and `""` is the ordinary value (`page()` passes it
for you).
`Layout` renders through `page()`, so it inlines the utility sheet. An
app that links a real stylesheet instead writes its own two-slot shell
component — `docs/examples/shop/layout/app.wo` is that case, and it is a
component like any other.
## The MVC seam
| | where it lives |
| --- | --- |
| **Model** | `@table` rows, queried in the HANDLER. This library contains no `from … select` anywhere and must not grow one |
| **View** | components: fields in, Text out. No hidden state, no globals — a page is byte-deterministic from its fields |
| **Controller** | the framework's `Handler`: it queries, fills the component's fields, and answers `ok_html(c.render())` |
`ok_html` is the **framework's** (`framework/http`, beside `ok_text` and
`ok_json`): a status line plus a content-type is transport, not
rendering, so writeonce-view never learns what a `Resp` is.
The seam is what makes a view testable without a server and a query
testable without markup. Breaking it looks like one convenience — a
component that queries "just this once" — and costs both.
## Deliberately absent
Client-side anything (change detection, event bindings, two-way binding,
SPA routing, hydration): the no-JS posture stands, and interactivity is
form round trips. A runtime template engine: reflection-free means an
untyped `map<Text, Text>`, so templates compile to code at build time or
they do not exist. Dependency injection: components are data-in,
Text-out. Structural directives (`w:if` / `w:for`): the language's own
`if` and `for` compose literals, and no sample has yet proven the need
for a second control-flow dialect. Scoped CSS: the utility sheet stays
one static string until the pain is measured.
## Consumers
- [`docs/examples/site`](../site) — the tutorial site: `Layout` +
a `ChapterNav` component reused on the homepage and every chapter page.
Gated by `just site`.
- [`docs/examples/shop`](../shop/README.md) — the program template: its
own `AppShell` component, page components holding child components.

View file

@ -0,0 +1,239 @@
-- writeonce-view — server-rendered HTML as plain Text. Four layers, all pure:
-- esc() HTML-escape untrusted text. `{{ }}` in a raw text
-- literal compiles to a call to this, so display data is
-- escaped by construction; esc() by hand is the fallback
-- el()/... element builders — `el("h1", "text-3xl font-bold", t)`
-- Component the view unit: a class with fields + `fn render() ->
-- Text`, satisfied structurally (iteration 37)
-- tw_css() a Tailwind-style utility stylesheet: the same class
-- names Tailwind popularized, hand-written as one static
-- sheet, inlined by page() so a page is one self-contained
-- response — no CDN, no build step, no JS
--
-- The language has no varargs and no closures (doctrine), so builders
-- take exactly (tag, classes, inner) and pages compose by `..` and by
-- functions returning Text. That constraint is the demo: an HTML layer
-- in writeonce is ordinary code, not a template dialect.
--
-- ---- the MVC seam this library sits on --------------------------------
--
-- MODEL `@table` rows. Queried in the HANDLER, never here — this
-- library has no `from ... select` anywhere in it and must
-- not grow one. A component receives VALUES, not a cursor.
-- VIEW components: fields in, Text out, no hidden state and no
-- globals, so a page is byte-deterministic from its fields.
-- CONTROLLER the framework's `Handler`: it queries, fills the component's
-- fields, and answers `ok_html(c.render())`.
--
-- The seam is what makes a view testable without a server and a query
-- testable without markup. Breaking it looks like one convenience —
-- a component that queries "just this once" — and costs both.
-- HTML-escape: & < > " (the four that matter in text and attributes).
pub fn esc(t: Text) -> Text {
let out = "";
let i = 0;
let n = len(t);
while i < n {
let b = byte_at(t, i);
if b == 38 { out = out .. "&amp;"; }
else {
if b == 60 { out = out .. "&lt;"; }
else {
if b == 62 { out = out .. "&gt;"; }
else {
if b == 34 { out = out .. "&quot;"; }
else { out = out .. substr(t, i, 1); }
}
}
}
i = i + 1;
}
return out;
}
-- One element. Empty class list = no attribute. The inner text is the
-- CALLER's business: pass esc(user_text) for data, raw markup for
-- fragments you built yourself.
pub fn el(tag: Text, cls: Text, inner: Text) -> Text {
if cls == "" { return `<${tag}>${inner}</${tag}>`; }
return `<${tag} class="${cls}">${inner}</${tag}>`;
}
-- An anchor: href is attribute context, so it is escaped here.
pub fn link(href: Text, cls: Text, label: Text) -> Text {
if cls == "" { return `<a href="{{ href }}">${label}</a>`; }
return `<a href="{{ href }}" class="${cls}">${label}</a>`;
}
-- A code block: content is ALWAYS escaped — code samples are exactly the
-- text that breaks HTML otherwise.
pub fn code_block(src: Text) -> Text {
return el("pre", "code-block", el("code", "", esc(src)));
}
pub fn text_input(name: Text, value: Text) -> Text {
return `<input type="text" name="{{ name }}" value="{{ value }}" class="field">`;
}
pub fn text_area(name: Text, value: Text, rows: Int) -> Text {
return `<textarea name="{{ name }}" rows="${rows}" class="field">{{ value }}</textarea>`;
}
pub fn submit_btn(label: Text) -> Text {
return `<button type="submit" class="btn">{{ label }}</button>`;
}
pub fn form_post(action: Text, inner: Text) -> Text {
return `<form method="POST" action="{{ action }}" class="flex flex-col gap-2">${inner}</form>`;
}
-- A sticky top navigation bar: brand on the left, a prebuilt row of
-- links on the right. The bar spans the viewport; the inner row shares
-- the page's container widths.
pub fn nav_bar(brand_href: Text, brand: Text, right: Text) -> Text {
let b = link(brand_href, "text-xl font-bold text-gray-900 no-underline", brand);
let row = el("div", "mx-auto max-w-5xl px-4 flex items-center justify-between", b .. el("div", "flex items-center gap-4", right));
return el("nav", "nav", row);
}
-- A titled card for feature grids.
pub fn card(title: Text, body: Text) -> Text {
let t = el("h3", "text-lg font-bold mb-2", title);
return el("div", "bg-white rounded-lg border shadow-sm p-6", t .. el("p", "leading-relaxed text-gray-700", body));
}
-- A link styled as a button; primary = filled, otherwise outline.
pub fn btn_link(href: Text, label: Text, primary: Bool) -> Text {
if primary {
return link(href, "btn no-underline", label);
}
return link(href, "btn-outline no-underline", label);
}
-- The utility sheet. Tailwind's names, one hand-written static sheet —
-- only the utilities this ecosystem's pages actually use; growing it is
-- adding a line, not adopting a toolchain. `.code-block`, `.field` and
-- `.btn` are the three composites the builders above rely on.
pub fn tw_css() -> Text {
let c = "*{box-sizing:border-box;margin:0;padding:0}";
c = c .. "body{font-family:system-ui,sans-serif;background:#f9fafb;color:#111827;line-height:1.6}";
c = c .. ".mx-auto{margin-left:auto;margin-right:auto}";
c = c .. ".max-w-3xl{max-width:48rem}";
c = c .. ".p-4{padding:1rem}.p-6{padding:1.5rem}.px-4{padding-left:1rem;padding-right:1rem}";
c = c .. ".py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}";
c = c .. ".mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}";
c = c .. ".mt-4{margin-top:1rem}.mt-8{margin-top:2rem}";
c = c .. ".flex{display:flex}.flex-col{flex-direction:column}";
c = c .. ".items-center{align-items:center}.justify-between{justify-content:space-between}";
c = c .. ".gap-2{gap:.5rem}.gap-4{gap:1rem}";
c = c .. ".text-sm{font-size:.875rem}.text-lg{font-size:1.125rem}";
c = c .. ".text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.text-3xl{font-size:1.875rem}";
c = c .. ".font-bold{font-weight:700}.font-mono{font-family:ui-monospace,monospace}";
c = c .. ".text-gray-500{color:#6b7280}.text-gray-700{color:#374151}.text-gray-900{color:#111827}";
c = c .. ".text-blue-600{color:#2563eb}.text-white{color:#fff}";
c = c .. ".bg-white{background:#fff}.bg-gray-50{background:#f9fafb}";
c = c .. ".bg-gray-900{background:#111827}.bg-blue-600{background:#2563eb}";
c = c .. ".rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}";
c = c .. ".border{border:1px solid #e5e7eb}.shadow-sm{box-shadow:0 1px 2px rgba(0,0,0,.05)}";
c = c .. ".block{display:block}.w-full{width:100%}.leading-relaxed{line-height:1.75}";
c = c .. ".underline{text-decoration:underline}.no-underline{text-decoration:none}";
c = c .. ".list-disc{list-style:disc}.pl-6{padding-left:1.5rem}";
c = c .. "a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}";
c = c .. ".code-block{background:#111827;color:#e5e7eb;padding:1rem;border-radius:.5rem;";
c = c .. "overflow-x:auto;font-size:.875rem;line-height:1.6;margin:1rem 0}";
c = c .. ".code-block code{font-family:ui-monospace,monospace;white-space:pre}";
c = c .. ".field{display:block;width:100%;border:1px solid #e5e7eb;border-radius:.25rem;";
c = c .. "padding:.5rem;font-family:ui-monospace,monospace;font-size:.875rem}";
c = c .. ".btn{background:#2563eb;color:#fff;border:0;border-radius:.25rem;";
c = c .. "padding:.5rem 1rem;font-weight:700;cursor:pointer}";
c = c .. "a.btn{color:#fff;display:inline-block}a.btn:hover{text-decoration:none;background:#1d4ed8}";
c = c .. ".btn-outline{display:inline-block;border:1px solid #2563eb;color:#2563eb;";
c = c .. "border-radius:.25rem;padding:.5rem 1rem;font-weight:700}";
c = c .. "a.btn-outline:hover{text-decoration:none;background:#eff6ff}";
c = c .. ".nav{position:sticky;top:0;background:#fff;border-bottom:1px solid #e5e7eb;";
c = c .. "padding:.75rem 0;z-index:10}";
c = c .. ".footer{border-top:1px solid #e5e7eb;color:#6b7280;font-size:.875rem;";
c = c .. "padding:2rem 1rem;text-align:center;margin-top:4rem}";
c = c .. ".max-w-5xl{max-width:64rem}.text-4xl{font-size:2.25rem;line-height:1.2}";
c = c .. ".py-16{padding-top:4rem;padding-bottom:4rem}.text-center{text-align:center}";
c = c .. ".mb-6{margin-bottom:1.5rem}.gap-6{gap:1.5rem}.justify-center{justify-content:center}";
c = c .. ".grid{display:grid}.grid-cols-2{grid-template-columns:repeat(2,1fr)}";
c = c .. "@media(max-width:640px){.grid-cols-2{grid-template-columns:1fr}}";
return c;
}
-- One full document: the sheet inlined, viewport set, body handed in.
-- Self-contained by construction — view-source shows everything.
pub fn page(title: Text, body: Text) -> Text {
return page_head(title, "", body);
}
-- The same document with extra <head> markup: a favicon link, a meta
-- description, whatever the app needs up there. Kept RAW (`${}`) — head
-- content is markup the app built, not data — and defaulted to "" by
-- `page()` so no consumer has to care.
pub fn page_head(title: Text, head: Text, body: Text) -> Text {
-- The whole document as one literal. Every newline here lands
-- inside <head>, where whitespace is insignificant; the body hole
-- and its closing tags share one line so nothing is inserted into
-- the rendered content.
return `
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ title }}</title>
${head}
<style>${tw_css()}</style>
</head><body>${body}</body></html>`;
}
-- ---- components (iteration 37) ----------------------------------------
-- The view twin of the framework's `Handler`: a class with fields and a
-- render, satisfied STRUCTURALLY — nothing declares that it implements
-- this, a class either has `fn render() -> Text` or it does not (a class
-- missing it is WO-E205 at the use site). Composition is nesting: a
-- parent's render calls its children's.
--
-- A component's fields ARE its inputs — the closure substitute this
-- language's no-function-values doctrine forces, and the same shape the
-- framework already proved for handlers. `multi Component` works
-- directly, so a heterogeneous list of children needs no wrapper record.
pub interface Component {
fn render() -> Text
}
-- Render children in order. The one place in this library where the
-- interface is genuinely load-bearing: this function knows nothing about
-- any of them beyond `render()`, and `multi Component` holds a
-- heterogeneous list directly — no wrapper record, which the framework's
-- Mw/Aw shape might lead you to expect.
pub fn render_all(cs: multi Component) -> Text {
let out = "";
for c in cs {
out = out .. c.render();
}
return out;
}
-- Content projection, writeonce-shaped: Angular's `<ng-content>` is a
-- slot the parent fills, and here a slot is just a pre-rendered `Text`
-- field. The caller passes `child.render()`, a raw literal, or a
-- builder's output — the layout never knows which, which is exactly why
-- it never needs to know the child's type.
--
-- Deliberately no container/width field: a page that wants its content
-- in a fixed-width column wraps it before passing it in. `head` is the
-- one non-body slot — a favicon link or a meta tag has nowhere else to
-- go, and "" is the ordinary value.
pub class Layout {
title: Text
head: Text
nav: Text
content: Text
footer: Text
fn render() -> Text {
return page_head(self.title, self.head, self.nav .. self.content .. self.footer);
}
}

View file

@ -1,4 +1,4 @@
name = "wo-html"
name = "writeonce-view"
kind = "library"
version = "0.1.0"
description = "HTML building for writeonce apps: escaping, element builders, and a Tailwind-style utility stylesheet — server-rendered pages as plain Text"
@ -7,4 +7,4 @@ description = "HTML building for writeonce apps: escaping, element builders, and
wo = ">= 0.1"
# A LIBRARY project: no `fn main`. Apps import it through wo.toml [deps]
# (the key names the module — `html = { git = ... }` gives `use html`).
# (the key names the module — `view = { git = ... }` gives `use view`).

View file

@ -0,0 +1,253 @@
# The writeonce language surface — everything a `.wo` file may contain
Derived from the front end as it stands on 2026-08-24 and re-verified
against it on 2026-08-26, by reading `compiler/src/lexer.ml`,
`parser.ml`, `ast.ml` and `types.ml` — not a spec. Where this disagrees
with the compiler, the compiler is right.
The normative companions are
[`08-builtin-surface.md`](../plan/oop-vm/08-builtin-surface.md) (what the
runtime offers) and [`01-error-catalog.md`](../plan/oop-vm/01-error-catalog.md)
(every diagnostic). The reasoning under the front end is
[`compiler/src/CODE-LOGIC.md`](../../compiler/src/CODE-LOGIC.md).
Read this as the answer to "what can I write?" — the last section is the
matching answer to "what will the compiler refuse?", which is just as
much part of the surface.
Every form listed below was compiled and run against `woc`/`wovm` while
writing this page, not read off the parser and hoped for — with the one
exception §6 calls out by name (`group … by … into` parses and is then
refused).
---
## 1. Lexical
| thing | form | notes |
| --- | --- | --- |
| comment | `-- to end of line` | the only comment form; no block comment |
| identifier | `[A-Za-z_][A-Za-z0-9_-]*` | **internal dashes are legal** — `a-b` is ONE identifier, so binary minus after an identifier needs spaces (`a - b`) |
| integer | `42`, `0xFF`, `0b1011`, `1_000_000` | `_` only BETWEEN digits; hex/binary accumulate in 63-bit OCaml int, so a full-width `0xFFFF…FFFF` is out of reach (`-1` spells all-ones) |
| float | `1.5`, `2e9`, `1.0e-3` | a bare digit run stays `Int`; only a fraction or exponent makes a `Float` |
| text | `"..."` or `'...'` | escapes `\n \t \r \0 \\ \" \'`; anything else after `\` is that literal character. A raw newline inside is **WO-E005** |
| interpolation | `"${expr}"` | desugars at parse time to a `..` chain; `\$` is a literal `$`, and a lone `$` not followed by `{` is literal |
| raw text literal | `` `...` `` | iteration 37 — content verbatim (NO escape processing), newlines are content, common source margin removed at compile time. Holes: `${e}` raw, `{{ e }}` HTML-escaped |
| booleans / nil | `true`, `false`, `nil` | |
| newline | significant | terminates a statement (or `;`). A line ending in `..` continues on the next — the one newline suppression |
| build flags | `#if name` / `#else` / `#end` | token-level filter, flag NAMES only (no expressions); nesting allowed; set with `woc -D name` |
**Keywords** (37): `type class interface fn let mut take return if else
while for in true false use spawn using pub break continue do const and
or not inline switch case default typedef try catch nil as INSERT
SELECT`.
Deliberately NOT keywords, so they lex as ordinary identifiers: `self`,
`me`, `subscribe`, `receive`, lowercase `insert`/`select`, and every
query clause word (`from`, `where`, `group`, `by`, `into`, `order`,
`desc`, `take`, `select`) plus every type constructor word (`ref`,
`multi`, `map`, `backlink`, `actor`).
## 2. File and module level
A directory is a module; `pub` is the export line. A file may contain,
in any order:
| declaration | form |
| --- | --- |
| import | `use fs` (reserved stdlib namespace) or `use shared/util` (project-relative path) |
| import + extension methods | `using shared/textutil` — the module's `pub` free fns whose first parameter matches a receiver become callable as methods on it (compile-time rewrite) |
| class | `[pub] class Name { fields, methods, consts }` |
| plain type | `[pub] type Name { ... }` — identical field grammar to `class`; methods parse for real in both |
| structural record | `typedef Name = { field: T, ... }` — fields only, and two records of the same SHAPE are the same type |
| tagged union | `type Name = A \| B \| C(x: Int, ...)` — bare variants lower to integer tags, payload variants to records. Construction is call-style and POSITIONAL: `C(1, 2)`, never `C { x: 1 }` |
| interface | `[pub] interface Name { fn sig(...) -> T }` — signatures only, no fields, no bodies. Satisfaction is STRUCTURAL |
| free function | `[pub] fn name(params) -> T { ... }` |
| constant | `const NAME = <literal>` — substituted by the parser before typecheck; a local of the same name shadows it |
The program entry is the free `fn main`, zero-argument or `fn
main(args: multi Text) -> Int`.
### Annotations
| annotation | where | effect |
| --- | --- | --- |
| `@table(name: "…", index: [a], index: [b, c])` | on a `class`/`type` | the class IS a WAL-backed table |
| `@unique` | on a field | uniqueness constraint |
| `@gc` | on a class | **rejected** — GC-ness is inferred, never declared |
Unknown annotation names parse and are ignored; argument lists on field
annotations are consumed and discarded.
## 3. Types
| kind | spelling |
| --- | --- |
| scalars | `Int`, `Float`, `Bool`, `Text`, `Bytes`, `Timestamp`, `Id` |
| nullable | `?T` — legal on any of the above and on heap shapes |
| list | `multi T` |
| map | `map<K, V>` |
| row reference | `ref C` |
| reverse relation | `backlink C.field` — the computed inverse of a `ref` |
| actor address | `actor M` — M is the message type, inferred from the class's `receive` |
| declared types | any `class` / `type` / `typedef` / union name |
| stdlib types | `json.Value`, `net.Conn` |
| predeclared records | `Stat`, `TimeParts`, `Proc`, `Error` — no source declares them; field ORDER is the contract with the C runtime |
### Fields and parameters
- Field: `name: T`, optionally `= <default>` and/or `@ann`.
- `pub(read) name: T` — readable outside the declaring class, writable
only inside it.
- Parameter conventions: **borrow is the default**; `mut x: T` for a
mutable borrow, `take x: T` to move ownership in. These apply to
NAMED parameters only.
- **`self` is implicit** — never written in the parameter list, and
always writable inside its own class's methods (`self.total += 1`
needs no annotation). `self` is an ordinary identifier, not a keyword.
- Methods may be `static fn`; class-level constants may be `static
const` or bare `const`.
## 4. Statements
| statement | form |
| --- | --- |
| binding | `let x = e`, `let x: T = e` |
| assignment | `x = e`, `obj.f = e`, `m[k] = e` |
| compound assignment | `x += e`, `-=`, `*=`, `/=`, `%=` — parse-time sugar for the written-out form (there are no bitwise compound assigns) |
| conditional | `if c { } else if c { } else { }` |
| while | `while c { }` |
| do-while | `do { } while c` — body always runs once |
| for | `for x in <multi \| query>`, `for k, v in <map>` |
| loop control | `break`, `continue` — owned values alive in the body are dropped at the jump site |
| return | `return` / `return e` |
| database write | `insert C { f: v, ... }`, `delete e` |
| expression | any expression in statement position |
## 5. Expressions
| form | spelling |
| --- | --- |
| literals | int, float, text, raw text, bool, `nil` |
| container literals | `[]`, `[a, b, c]` (a `multi`), `{}` (an empty `map`) — a fresh container needs a destination of declared type |
| constructor | `C { field: v, ... }` |
| access | `x.f`, `c[i]` (a `multi` — out of range traps), `m[k]` (a `map` — a missing key is **nil**) |
| call | `f(a, b)`, `x.m(a)`, `mod.member(a)` |
| unary | `-e`, `not e` |
| binary | see the ladder below |
| checked conversion | `e as T` — its ONE meaning is decoding JSON text, yielding `?T`. There is no reinterpret cast |
| actor spawn | `spawn C { fields }` → an `actor M` address |
| trapping guard | `try <expr> catch (e) <expr-or-block>` — an EXPRESSION; `e` binds the `Error` record `{ code, line, method, msg }` |
| multi-way choice | `switch e { case A: ...; default: ...; }` — an expression. Arms match VALUES; `case a, b:` fires for either. A union payload binds positionally: `case Rect(w, h): w * h`. `default` is required UNLESS the subject is a union whose variants are all covered |
| query | `from … select …`, see below |
| interpolation | inside `"…"` and `` `…` `` |
### Operator precedence, loosest to tightest
1. `or`
2. `and`
3. comparison — `== != < <= > >=`
4. concatenation — `..`
5. additive — `+ -` **and `|` `^`**
6. multiplicative — `* / %` **and `&` `<<` `>>`**
7. unary — `-`, `not`
8. `as` conversion — binds to a postfix expression, so tighter than unary
9. postfix — call, index, field
Bitwise operators do not get their own tiers: `|`/`^` ride the additive
rung and `&`/`<<`/`>>` the multiplicative one (Go's arrangement). They
are Int-only on BOTH sides — there is no Float twin — and a literal
shift count outside `0..63` is a compile error.
`+` is arithmetic ONLY, never string addition. `and`/`or` are
short-circuit, `Bool`-typed operands only — there is no truthiness, and
no `&&`/`||`/`!`/`~` anywhere in the language.
## 6. Queries (language-integrated, never SQL text)
```
from <var> in <source>
where <expr> -- zero or more
group <expr> by <key> into <gvar> -- PARSES, THEN REFUSED (see below)
order by <expr> [desc]
take <expr>
select <expr>
```
The source is either a table class (`from p in Product`) or a
navigation (`from s in dept.staff` — a `backlink` or a `multi`). Present
today: **from / where / order / take / select**. A query is an
expression and is also what `for x in <query>` iterates.
`group … by … into` is the one clause above that is grammar without
semantics: the parser accepts it (`parser.ml`) and the typechecker then
rejects it with WO-E250 — "group-by aggregation is not supported yet",
or "group-by on a navigation query is not supported yet" for the
navigation form (`types.ml`). It is listed because the syntax is
settled, not because it runs. Joins are not in the slice at all.
## 7. Concurrency
- `spawn C { fields }` constructs the actor's state (fields MOVE in) and
starts it; the value is an `actor M` address.
- `send(addr, msg)` — fire and forget; the message MOVES to the runtime.
- `call(addr, msg) -> R` — a send that parks the calling fiber until the
receive returns. Every `receive` program-wide must agree on `R`, and
`R` must be a copyable scalar. A dead callee traps, never hangs.
- A class becomes an actor by declaring `fn receive(msg: M)` — `receive`
is an ordinary identifier, not a keyword.
- Blocking stdlib calls park the fiber. There is no `async`, no `await`,
and no user-visible thread.
## 8. Builtins and the stdlib
**Free builtins** (a user-declared `fn` of the same name always wins):
`print`, `print_err`, `print_int`, `now`, `words`, `len`, `count`,
`byte_at`, `char_of`, `substr`, `trim`, `to_lower`, `starts_with`,
`ends_with`, `index_of`, `last_index_of`, `split`, `split_ws`, `join`,
`parse_int`, `int_to_text`, `multi_new`, `map_new`, `push`, `get`,
`set`, `has`, `remove`, `latest`, `pop`, `shift`, `slice`, `sort`,
`reverse`, `key_at`, `val_at`, `send`, `call`, `sha1`, `sha256`,
`hmac_sha256`, and the Float/Bytes bridges `float`, `trunc`,
`parse_float`, `float_to_text`, `float_cmp`, `bytes_len`, `bytes_at`,
`bytes_slice`, `bytes_eq`, `bytes_concat`, `bytes_of_text`,
`text_of_bytes`, `base64_encode`, `base64_decode`.
**Reserved module namespaces**, each resolving to builtins:
| module | covers |
| --- | --- |
| `fs` | `exists`, `list`, `stat`, `read_all`, `read_at`, `append` |
| `time` | `now`, `sleep`, `local`, `iso`, `ticks` |
| `env` | `get`, `stopping` (the SIGTERM/SIGINT latch) |
| `net` | `listen`, `listen_unix`, `accept`, `accept_dl`, `read`, `read_dl`, `write`, `write_dl`, `peer`, `close` |
| `proc` | `run` |
| `json` | `encode`, `decode` (paired with `as T`) |
A failing syscall traps `IO` with errno's message; **absence is never a
trap** — a missing path or an unset variable is nil.
## 9. What the language deliberately does NOT have
This list is doctrine, not a backlog. Each was considered and rejected.
- **Closures and function values.** Capture is a field on a class. This
is why there is no dependency injection and no callback API anywhere.
- **`&&`, `||`, `!`, `~`.** Word operators only (`and`, `or`, `not`);
complement is `-1 ^ x`.
- **Inheritance.** Interfaces are structural; there is no `extends`.
- **`inline fn`.** The keyword exists solely to produce a clear
rejection — optimization is the compiler's job.
- **A reinterpret cast.** `as` decodes JSON and nothing else.
- **Varargs, and generics beyond the built-in containers.**
- **Truthiness.** A condition must be `Bool`.
- **A block comment, and a `{{`/`${`/backtick escape inside a raw
literal.** Those three are written by concatenating an ordinary
`"..."` string with `..` — one greppable door.
- **`async`/`await`.** Fibers park; the shard runs someone else.
- **A runtime template engine.** Markup is a compile-time literal or it
does not exist.
- **Non-empty map literals** (`{ k: v }` in expression position) —
indistinguishable from a constructor literal without lookahead nothing
else needs.
- **A full-width 64-bit integer literal**, and **joins in queries** —
both real limits rather than doctrine.

410
docs/guides/releasing.md Normal file
View file

@ -0,0 +1,410 @@
# Cutting a release — building the tarball and publishing it on GitHub
The `/install` page on writeonce.de links a GitHub release asset by an
exact URL. Publishing is therefore not "upload a file somewhere": the
**asset filename has to match what the site links**, or the download
button 404s. This runbook keeps the two in step.
The URL the site links today:
```
https://github.com/shoneyj/writeonce/releases/download/v0.1.0/writeonce-0.1.0-linux-amd64.tar.gz
```
which decomposes as `<repo>/releases/download/<tag>/<asset-name>`. So the
tag must be `v0.1.0` and the asset must be named exactly
`writeonce-0.1.0-linux-amd64.tar.gz` — which is what `just dist` already
produces.
## Two routes
**Automated (preferred).** `.github/workflows/release.yml` builds,
verifies and publishes on a `v*` tag push. It needs no `gh auth login`
and no secret: GitHub injects a per-job `GITHUB_TOKEN`, and the single
line `permissions: contents: write` is what lets that token create a
release. The token expires when the job ends, so there is nothing to
rotate or leak. Skip to *Releasing from the pipeline* below.
**Manual.** Everything from §0 onward — the path for a first release, or
when the pipeline is broken and you need to ship anyway.
## Making the pipeline ready (first time only)
It does **not** build on your machine, and pushing to `master` does not
release anything. The job runs on a GitHub-hosted runner, and only a
`v*` TAG push starts it. Ordinary commits, PRs and branch pushes are
ignored by this workflow.
```
1 Get the workflow onto GitHub — Actions only sees files in the repo.
git checkout master
git merge site-homepage # or open a PR and merge it
git push origin master
2 Repo -> Actions tab. If it offers to enable workflows, enable them.
3 Repo -> Settings -> Actions -> General -> "Allow actions":
must permit actions/checkout and ocaml/setup-ocaml.
On "Allow select actions", add: ocaml/setup-ocaml@*
4 Same page -> "Workflow permissions". The workflow asks for
contents: write explicitly, which is normally enough. If the
publish step later fails with 403, come back and select
"Read and write permissions".
5 REHEARSE with a dry run — no tag, no publish, no cleanup.
Actions tab -> "release" -> "Run workflow" -> master.
A workflow_dispatch run skips the tag guard and the publish step,
so it builds, verifies, smoke-tests the extracted tarball and
reports the glibc floor, and stops there.
6 (nothing to undo — a dry run creates no tag and no release)
7 Watch it: the Actions tab, or `gh run watch` once gh is
authenticated.
8 Read the "Report the glibc floor" step. It prints what the
RUNNER-built binaries actually require. Expect 2.35-ish from
ubuntu-22.04, versus 2.38 from this dev machine.
9 Update docs/examples/site/install/view.wo's supported-systems list
to whatever step 8 printed, and the distros that follow from it.
Publishing binaries whose floor differs from the page is the one
failure a user cannot debug.
10 Ship for real:
git tag -a v0.1.0 -m "writeonce 0.1.0"
git push origin v0.1.0
11 Verify the link a stranger clicks (should print 200):
curl -sIL -o /dev/null -w '%{http_code}\n' \
https://github.com/shoneyj/writeonce/releases/download/v0.1.0/writeonce-0.1.0-linux-amd64.tar.gz
12 Refresh the site's mirror from $WO_DIST (section 7 below).
```
There is no rehearsal to clean up and no draft flag to remove: a
`workflow_dispatch` run creates neither a tag nor a release, and
`gh release create` in the workflow publishes directly — the file has never
carried `--draft`. If you *want* a draft first, add `--draft` to that step
yourself and remember to remove it again.
Known first-run risks, in the order they are likely to bite:
`ocaml/setup-ocaml@v3` resolving OCaml 4.14 on the 22.04 image; the
`objdump` in the glibc-floor step needing `binutils` (present on GitHub
runners, absent in slim containers); and a 403 on publish, which is
step 4.
## What the hosted runner costs
**Public repository: nothing.** Standard GitHub-hosted runners are free
with unlimited minutes on public repos. Only *larger* runners (4-core
and up) are billed there, and this workflow does not ask for one.
**Private repository:** each plan includes monthly minutes — 2,000 on
Free, 3,000 on Pro and Team, 50,000 on Enterprise Cloud — then bills
per minute. Linux counts ×1, Windows ×2, macOS ×10, so staying on Linux
is also the cheap choice. Each *job* is rounded up to the next minute.
This job is small either way. A cold `scripts/mkdist.sh` measured 3.4s
on a 20-core workstation; on a 2-core runner call it well under a
minute. The real cost is `ocaml/setup-ocaml`, which is minutes cold and
under one when its opam cache hits — so budget roughly 3–10 minutes per
release, and it only ever runs on a tag. Ten releases a month is a
couple of percent of the smallest free allowance.
Release assets do **not** count against Actions artifact storage; they
live with the release. (Rates and allowances change — check the current
billing page before making a decision that depends on them.)
## Why the release job is NOT on a self-hosted runner
A self-hosted runner would work — it needs no inbound ports, polls
GitHub over outbound HTTPS, and honours `HTTPS_PROXY`/`NO_PROXY`, so a
box behind a proxy is fine. Two reasons not to use one for THIS job:
**It defeats the point of pinning the runner.** The release binaries
link glibc dynamically, so the build host sets the floor every user
must clear. `ubuntu-22.04` was chosen to keep that floor near 2.35. A
runner on a developer machine (glibc 2.39 here) puts it back at 2.38+
and silently drops Ubuntu 22.04, Debian 12 and RHEL 9 — the exact
regression the pinned image prevents. If a self-hosted runner is
unavoidable, build inside a container pinned to the oldest glibc you
intend to support, not on the host.
**A release built on a workstation is unattested.** "It built on my
machine" is what a pipeline exists to stop being true.
### If you do self-host, what you are accepting
A runner executes workflow code **as the user that started it**, with
that user's filesystem and network reach. On a personal workstation
that means `~/.ssh`, `~/.config/gh`, cloud and cluster credentials,
browser profiles, and every host reachable from it — including LAN
services and anything named in `~/.ssh/config`, which on a work laptop
is usually production. A job does not need to be malicious to leak;
it needs to be careless once.
The risk is highest on a **public** repository, where a pull request
from a stranger can run arbitrary code on the runner. GitHub's own
guidance is not to use self-hosted runners with public repositories.
On a private repository the blast radius is smaller but not zero:
anyone with write access, or one compromised token, reaches the same
shell.
If it is still the right call, make it boring:
- a dedicated VM or container, never a workstation, on a network
segment that cannot reach production;
- a separate unprivileged user with no SSH keys, no cloud config, and
no credentials of its own;
- `--ephemeral` registration so each job gets a clean runner and a
poisoned toolchain cannot outlive one build;
- `.credentials` under the runner's home is a long-lived credential to
act as that runner — treat the box as holding a secret;
- restrict egress if the workload allows; the same outbound HTTPS the
runner needs is what exfiltration would use.
A reasonable split: self-hosted for tests that need private-network
access or unusual hardware, GitHub-hosted for the release artifact.
## Releasing from the pipeline
```
git tag -a v0.1.0 -m "writeonce 0.1.0"
git push origin v0.1.0
```
That is the whole release. The workflow then, in order: checks the tag
matches `VERSION`, builds via `scripts/mkdist.sh`, asserts the produced
filename is the one `/install` links, verifies the `.sha256`, extracts
the tarball and builds a hello project **with the binaries inside it**,
prints the glibc floor of what is about to ship, and publishes both
files with `gh release create`.
Two things about that file are deliberate:
- **`runs-on: ubuntu-22.04`, not `ubuntu-latest`.** The binaries link
glibc dynamically, so the build host's glibc caps the symbol versions
they can import — and that cap becomes the minimum glibc every user
needs. On 24.04 (glibc 2.39) the floor is 2.39; on 22.04 (2.35) it is
2.35. That is the difference between excluding and including Ubuntu
22.04, Debian 12 and RHEL 9. Changing the image changes who can run
the release, so change `install/view.wo`'s supported-systems list in
the same commit.
- **It fails rather than publishes** when the tag, `VERSION` and the
asset name disagree, because those three are what the download URL on
`/install` is built from.
### Other CI (GitLab, Jenkins, Buildkite)
No `gh auth login` there either — `gh` reads a token from the
environment:
```
GH_TOKEN=$MY_SECRET gh release create v0.1.0 dist/*.tar.gz dist/*.sha256
```
The secret is a PAT with `repo` scope (classic) or **Contents: read and
write** (fine-grained). Outside GitHub it is a long-lived credential you
own and must rotate — which is exactly the cost `GITHUB_TOKEN` avoids,
and the reason to prefer Actions for this one job even if the rest of
your CI lives elsewhere.
## 0. Authenticate `gh` (manual route only)
`gh` keeps its own credential, separate from git's. SSH keys let you
`git push`; they do **not** let `gh` call the API, so a machine that
pushes fine can still fail to create a release.
Check first — if this names your account, skip the rest of this section:
```
gh auth status
```
### The interactive flow (what to pick)
```
gh auth login
```
It asks five things:
| prompt | answer |
| --- | --- |
| What account do you want to log into? | **GitHub.com** |
| Preferred protocol for Git operations? | **SSH** — this repo's remote is already `git@github.com:shoneyJ/writeonce.git`, and answering HTTPS here rewrites how git talks to GitHub for every repo on the host |
| Upload your SSH public key? | pick `~/.ssh/id_ed25519.pub`, or **Skip** if that key is already on your account |
| How would you like to authenticate? | **Login with a web browser** |
| One-time code | gh prints something like `ABCD-1234`; press Enter, paste it at <https://github.com/login/device>, authorise |
The token lands in the system credential store, or in a plain file if
there is no store (`gh auth status` prints the location; `--insecure-storage`
forces the file). `-w`/`--web` skips straight to the browser step.
**In a Claude Code session, prefix it with `!`** — `! gh auth login` — so
the prompts are yours to answer and the output lands in the conversation.
An agent cannot complete a device flow on your behalf.
### No browser on that machine (server, container, CI)
Create a personal access token at <https://github.com/settings/tokens>.
Classic tokens need the scopes `repo`, `read:org` and `gist`;
a fine-grained token needs **Contents: read and write** on the repo,
which is what release assets are written through. Then:
```
printf '%s' "$TOKEN" | gh auth login --with-token
```
Read it from a `chmod 600` file rather than typing it inline — an
argument on the command line lands in shell history and in `ps`. For
automation, skip login entirely and export `GH_TOKEN`; gh picks it up and
stores nothing.
### Verify before you rely on it
```
gh auth status
gh repo view shoneyJ/writeonce --json name,visibility
```
The second call is the real check: it proves the token can reach *this*
repo, which a valid-but-wrong-account token would not.
## 1. Before you build
Check the tree is clean and the version is what you mean to ship —
`VERSION` is the single source, and `mkdist.sh` refuses to build if
`woc version` or `wovm --version` disagree with it:
```
git status --porcelain # expect empty
cat VERSION # e.g. 0.1.0
```
## 2. Build the artifact
```
just dist
```
That builds both release binaries, runs the version drift guard, and
writes two files:
```
dist/writeonce-<ver>-linux-amd64.tar.gz
dist/writeonce-<ver>-linux-amd64.tar.gz.sha256
```
## 3. Verify it before anyone else can
Two checks, both cheap, both worth it. First the digest:
```
cd dist && sha256sum -c writeonce-0.1.0-linux-amd64.tar.gz.sha256 && cd ..
```
Then prove the tarball actually works, using the binaries INSIDE it —
not the ones in your build tree:
```
tmp=$(mktemp -d)
tar -C "$tmp" -xzf dist/writeonce-0.1.0-linux-amd64.tar.gz
export PATH="$tmp/writeonce/bin:$PATH"
woc version && wovm --version
mkdir -p "$tmp/hello" && cd "$tmp/hello"
printf 'name = "hello"\nversion = "0.1.0"\n\n[runtime]\nwo = ">= 0.1"\n' > wo.toml
printf 'fn main() -> Int {\n print("hello, writeonce");\n return 0;\n}\n' > main.wo
woc . && ./target/hello
```
If that prints `hello, writeonce`, the release is sound.
## 4. Tag the commit
The tag is what the download URL points at, so tag the exact commit the
binaries were built from:
```
git tag -a v0.1.0 -m "writeonce 0.1.0"
git push origin v0.1.0
```
## 5. Publish and upload
One command creates the release and attaches both files:
```
gh release create v0.1.0 \
dist/writeonce-0.1.0-linux-amd64.tar.gz \
dist/writeonce-0.1.0-linux-amd64.tar.gz.sha256 \
--title "writeonce 0.1.0" \
--notes-file - <<'EOF'
Linux x86-64, glibc 2.38 or newer. Two binaries — `woc` and `wovm` —
depending only on the system C library.
rm -rf /usr/local/writeonce
tar -C /usr/local -xzf writeonce-0.1.0-linux-amd64.tar.gz
export PATH=$PATH:/usr/local/writeonce/bin
Verify with the published `.sha256` before extracting.
EOF
```
Add `--draft` to stage it without publishing, or `--prerelease` to mark
it as one. If the release already exists and you only need to attach (or
replace) files:
```
gh release upload v0.1.0 dist/writeonce-0.1.0-linux-amd64.tar.gz --clobber
```
Without `gh`, the same thing through the web UI: the repo's **Releases**
page → *Draft a new release* → choose tag `v0.1.0` → drag both files
into the attachment box → *Publish release*. Uploading by hand is where
the filename usually drifts, so paste it rather than retyping it.
## 6. Check the link the site actually uses
```
curl -sIL -o /dev/null -w '%{http_code} %{url_effective}\n' \
https://github.com/shoneyj/writeonce/releases/download/v0.1.0/writeonce-0.1.0-linux-amd64.tar.gz
```
A `200` means `/install`'s download button works for a stranger. Anything
else means the tag, the asset name, or the repo path disagrees with the
link in `docs/examples/site/install/view.wo`.
## 7. Refresh the site's mirror
`/install` offers this site as a mirror beside the GitHub link, served by
the framework's `StaticFiles` out of `$WO_DIST` (default `./dist`). Copy
the same two files there so the mirror is not stale:
```
scp dist/writeonce-0.1.0-linux-amd64.tar.gz* user@host:/srv/writeonce/dist/
```
Both copies are the same bytes, and the published `.sha256` covers
either one — that is the point of shipping the digest beside the
archive.
## Notes
- **`dist/` is gitignored** (`.gitignore:103`). The tarball is a build
artifact; the release is where it lives, not the repository.
- **Repo path case.** The git remote is `shoneyJ/writeonce` while the
site links `shoneyj/writeonce`. GitHub paths are case-insensitive and
redirect, so both resolve — but keep them consistent when either
changes.
- **Bumping the version** means editing `VERSION`, rebuilding, and
updating every place the site names the current release:
`docs/examples/site/install/view.wo` (the download links, the tar
command, and the `.sha256` link). The version appears there as literal
text, so grep for the old number before you publish.
- **One target today.** `mkdist.sh` builds `linux-amd64` only; a cross
matrix is future work. Do not add architectures to the release notes
that no build produces.

View file

@ -154,10 +154,10 @@ Deliberately excluded: `.dev/reference/colibri`, `.dev/reference/llama-cpp`,
## 7. Governing docs
- Spec: [`docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`](../../superpowers/specs/2026-08-01-oop-compiler-vm-design.md)
- Plan 2 — compiler front: [`2026-08-01-woc-compiler-front.md`](./2026-08-01-woc-compiler-front.md)
- Plan 3 — emit + e2e + single binary: [`2026-08-01-wob-emit-e2e-single-binary.md`](./2026-08-01-wob-emit-e2e-single-binary.md)
- Plan 8 — Haxe-parity language surface: [`2026-08-01-haxe-parity-language.md`](./2026-08-01-haxe-parity-language.md)
- Format contract: [`docs/plan/oop-vm/00-wob-format.md`](../../plan/oop-vm/00-wob-format.md)
- Plan 2 — compiler front: [`2026-08-01-woc-compiler-front.md`](2026-08-01-woc-compiler-front.md)
- Plan 3 — emit + e2e + single binary: [`2026-08-01-wob-emit-e2e-single-binary.md`](2026-08-01-wob-emit-e2e-single-binary.md)
- Plan 8 — Haxe-parity language surface: [`2026-08-01-haxe-parity-language.md`](2026-08-01-haxe-parity-language.md)
- Format contract: [`docs/plan/oop-vm/00-wob-format.md`](../oop-vm/00-wob-format.md)
- VM counterpart (shipped): [`docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md`](../../superpowers/plans/2026-08-01-wob-format-and-vm-core.md)
> **Docs-location note:** compiler plan documents live here in

View file

@ -52,7 +52,36 @@ Status board: [`00-status.md`](../stories/00-status.md) · Doctrine: [`../00-pri
| **Per-example `principle.md` files** | 2026-08-08: one canonical repo-level [`docs/00-principles.md`](../00-principles.md) instead; examples link to it. |
| **Minimal 3-file log-watcher sample** | Breaks the file-for-file `.hx` → `.wo` mapping and leaves the "could not express" column unproven — which is the sample's entire acceptance criterion. |
| **Raw code in plan documents** | Plans carry concept, reason, and required behavior in words; the executor writes the code. |
| **`##ui` / `.htmlx` LiveView frontend track** | 2026-08-17: removed the 9-doc `exploration/ui/` design set, the `14-mvc-ui-implementation` plan, and the `ui-htmlx-live` plan. All were built on the non-advancing Rust runtime (`.dev/reference/crates/wo-htmlx`, `cargo run`, WebSocket live-patches) and contradict the current woc/wovm direction. The 13d pricing-UI row went with them. Revisit only if a UI story is re-opened on the woc/wovm stack. |
| **`##ui` / `.htmlx` LiveView frontend track** | 2026-08-17: removed the 9-doc `exploration/ui/` design set, the `14-mvc-ui-implementation` plan, and the `ui-htmlx-live` plan. All were built on the non-advancing Rust runtime (`.dev/reference/crates/writeonce-viewx`, `cargo run`, WebSocket live-patches) and contradict the current woc/wovm direction. The 13d pricing-UI row went with them. Revisit only if a UI story is re-opened on the woc/wovm stack. |
| **Old-runtime "front door" + v1 design docs** | 2026-08-17: removed `writeonce-pl.md`, `runtime/wo-language.md`, `future-scope/ai-agents-content-management.md`, the numbered v1 set `02-recovery`/`03-data`/`04-ui`/`05-datalayer`/`06-markdown-render`/`07-ssl`, and `runtime/database/05-go-sdk.md`. They pitched the old Rust `wo` runtime (REST + LiveView + SQL/Cypher) as the current language and contradicted the shipped woc/wovm toolchain. |
| **The 2026-08-01 shard-actor plan (epoll-based)** | 2026-08-21: [`superpowers/plans/2026-08-01-shard-actor-vm-runtime.md`](../superpowers/plans/2026-08-01-shard-actor-vm-runtime.md) marked discarded, file kept as reference. Superseded by the arc plan of record ([`2026-08-20-shard-fiber-arc.md`](../superpowers/plans/2026-08-20-shard-fiber-arc.md), stages 1+2 landed): io_uring is a MUST and the epoll-based approach is discarded — the old plan's "epoll now / io_uring later" premise is inverted, and its substrate (`runtime/wo-rt.c`) left with the Rust track 2026-08-18. |
| **The entire Rust `wo` runtime track** | 2026-08-18: removed `crates/` (the Stage-2 Rust runtime), `Cargo.toml`/`Cargo.lock`, `prototypes/` (wo-rt-c stale duplicate + wo-db C++ ref), the `rt-c-*` justfile recipes, the Rust engineering plans (`docs/plan/05..16`, `docs/plan/done/`), and `docs/runtime/` (the old runtime overview + 7-phase DB design series + async/fibers/gc/surreal essays). It was the prior, abandoned architecture — fully independent of the woc/wovm stack. Master now reflects only the current single-language project; the removed track lives in git history if ever needed as reference. Kept: the syscall/postgres/assembly/c-runtime **exploration studies** (they fed the current C runtime) and the discarded/learnings registers. |
### Successor map for the removed Rust-era plan paths
Added 2026-08-26. The exploration studies under
[`exploration/`](exploration/linux/00-linux.md) were written against the old
flat `docs/plan/NN-*.md` numbering and the `docs/runtime/database/` tree, both
removed with the Rust track above. Those 48 dangling links were **de-linked, not
re-pointed** — their prose names the retired plan by number ("plan 09a", "plan
11"), so aiming them at a story would have made the sentence lie. The studies
still read correctly; the names are now plain text. This table is where a reader
goes to find what took each one's place.
| Retired path | What carries that work now |
| --- | --- |
| `09-concurrency-scaleout.md` | [`08-shard-actor-runtime.md`](../stories/language-runtime-database/08-shard-actor-runtime.md) + [`11-fibers.md`](../stories/language-runtime-database/11-fibers.md) — the arc, landed 2026-08-21 |
| `10-storage-foundations.md`, `11-wal-and-recovery.md` | [`09-database-engine.md`](../stories/language-runtime-database/09-database-engine.md) (typed WAL + replay) and [`22-durability-throughput-scale.md`](../stories/language-runtime-database/22-durability-throughput-scale.md) (the measurements) |
| `12-engine-disk-cutover.md` | Nothing — RAM stays authoritative by doctrine (principle 7). The disk story is the WAL; reclamation is [`databasev2 3, WAL checkpoint`](../stories/databasev2/03-wal-checkpoint.md) |
| `13-class-model-live-pricing.md` | [`09b-table-relations-query.md`](../stories/language-runtime-database/09b-table-relations-query.md) — `@table`, `ref`/`backlink`, the compiler-checked query surface |
| `07-inotify-content-watcher.md` | [`07-logwatcher-proof.md`](../stories/language-runtime-database/07-logwatcher-proof.md) — the log-watcher sample polls via `fs.stat`; inotify was never surfaced as a builtin |
| `08-sendfile-static-assets.md` | Nothing. `sendfile` is not exposed; static assets are served as `Text` through `net.write` |
| `15-mcp-streamable-http.md` | [`28-skillhost-host-workload.md`](../stories/language-runtime-database/28-skillhost-host-workload.md) — MCP transport is that story's Blocker B |
| `16-postgres-mirror.md` | Nothing — the mirror-is-backup doctrine holds, but no iteration owns it and there are no outbound sockets to reach a mirror with ([`refine/38`](../stories/language-runtime-database/38-content-platform-capabilities.md)) |
| `02-event-loop-epoll.md`, `03-hand-rolled-http.md` | `runtime/src/park.c` (io_uring with an epoll fallback) and the `.wo` framework `porch` |
| `04-cutover-remove-tokio-axum.md` | Completed by the Rust-track removal itself — nothing left to cut over |
| `runtime/database/03-inmemory-engine.md` | [`database/src/CODE-LOGIC.md`](../../database/src/CODE-LOGIC.md) + [`plan/oop-vm/04-db-binding.md`](oop-vm/04-db-binding.md) |
| `runtime/database/02-wo-language.md` | [`docs/guides/language-surface.md`](../guides/language-surface.md) |
| `runtime/database/07-wo-seg-migration.md` | Nothing — segment migration was a Rust-engine concept with no analogue here |
| `prototypes/wo-db/` (C++ query-layer ref) | Removed with the Rust track. The query layer lives in `compiler/src/emit.ml`, lowered to engine builtins |
| `exploration/linux/07-splice.md` | Never written. Slot 07 is `07-io_uring.md` |

View file

@ -16,7 +16,7 @@ The biggest category. The language's calling convention — how arguments are pa
Atomics, memory barriers, and some hardware-accelerated primitives need specific instruction sequences. A compiler that sees `a = *b` can't know whether you wanted a relaxed load or an acquire fence without annotation — and the *right* instruction on x86 vs ARM vs RISC-V is different.
**Atomic CAS / load-acquire / store-release.** On x86 it's `LOCK CMPXCHG`; on ARM it's `LDXR` / `STXR` with a retry loop; on RISC-V it's `LR.W.AQ` / `SC.W.RL`. Go emits these from [`reference/go/src/runtime/atomic_amd64.s`](../../../.dev/reference/go/src/runtime/atomic_amd64.s) (and its per-arch siblings) because a portable compiler can't.
**Atomic CAS / load-acquire / store-release.** On x86 it's `LOCK CMPXCHG`; on ARM it's `LDXR` / `STXR` with a retry loop; on RISC-V it's `LR.W.AQ` / `SC.W.RL`. Go emits these from `reference/go/src/runtime/atomic_amd64.s` (and its per-arch siblings) because a portable compiler can't.
**Memory barriers.** `MFENCE`, `LFENCE`, `SFENCE` on x86; `DMB` / `DSB` / `ISB` on ARM. Used by Go's `publicationBarrier`, `procyield`, and friends. Per-arch asm files carry them.
@ -34,10 +34,10 @@ Go does these in asm because it cannot rely on libc — Go's scheduler needs to
**Rust + libc covers all three categories** for the specific workload the `rt` crate serves. No custom scheduler means no stack switching. `std::sync::atomic::*` emits the right arch-specific instructions per target. `libc::syscall(SYS_*, ...)` hits the kernel through glibc's own trampolines — we don't need our own because we don't need fine-grained control over scheduler park/unpark (there's no scheduler to park). `signalfd` (see [`../linux/04-signalfd.md`](../linux/04-signalfd.md)) makes signal-handler asm unnecessary.
The next document, [`01-go-runtime-asm.md`](./01-go-runtime-asm.md), catalogues Go's asm in concrete detail. The one after that, [`02-writeonce-stance.md`](./02-writeonce-stance.md), spells out the policy: **no custom assembly in `crates/rt`** — and lists the three edge cases where a future profiling run might force the decision.
The next document, [`01-go-runtime-asm.md`](01-go-runtime-asm.md), catalogues Go's asm in concrete detail. The one after that, [`02-writeonce-stance.md`](02-writeonce-stance.md), spells out the policy: **no custom assembly in `crates/rt`** — and lists the three edge cases where a future profiling run might force the decision.
## Reading order
1. **This doc** — the abstract "why asm exists in runtimes."
2. [`01-go-runtime-asm.md`](./01-go-runtime-asm.md) — concrete Go inventory with reference paths.
3. [`02-writeonce-stance.md`](./02-writeonce-stance.md) — the writeonce policy + escape hatches.
2. [`01-go-runtime-asm.md`](01-go-runtime-asm.md) — concrete Go inventory with reference paths.
3. [`02-writeonce-stance.md`](02-writeonce-stance.md) — the writeonce policy + escape hatches.

View file

@ -1,6 +1,6 @@
# 01 — Go's runtime assembly, catalogued
The Go runtime ships ~72 `TEXT` functions in `asm_amd64.s` alone, ~43 in `sys_linux_amd64.s`, and per-architecture variants of both for `386`, `arm`, `arm64`, `loong64`, `mips(64)x`, `ppc64x`, `riscv64`, `s390x`, `wasm`. This doc inventories them by purpose so a reader can map each Go asm concern to the writeonce equivalent (spoiler: usually "Rust stdlib does it"). Follow-on reading: [`02-writeonce-stance.md`](./02-writeonce-stance.md).
The Go runtime ships ~72 `TEXT` functions in `asm_amd64.s` alone, ~43 in `sys_linux_amd64.s`, and per-architecture variants of both for `386`, `arm`, `arm64`, `loong64`, `mips(64)x`, `ppc64x`, `riscv64`, `s390x`, `wasm`. This doc inventories them by purpose so a reader can map each Go asm concern to the writeonce equivalent (spoiler: usually "Rust stdlib does it"). Follow-on reading: [`02-writeonce-stance.md`](02-writeonce-stance.md).
All paths are inside [`reference/go/src/runtime/`](../../../../.dev/reference/go/src/runtime/).
@ -86,6 +86,6 @@ Hookable entry points for sanitisers. Not relevant to writeonce.
## What's NOT in asm
Everything else in Go's runtime is plain Go: the scheduler's policy (`proc.go`), the garbage collector (`mgc.go` etc.), `netpoll` dispatch (`netpoll.go` — *Go code*; the platform-specific backends like `netpoll_epoll.go` are also pure Go that call into the asm `epollwait` trampoline). The asm is strictly the three categories in [`00-overview.md`](./00-overview.md): calling-convention-breaking operations, arch-specific instructions, and syscall trampolines.
Everything else in Go's runtime is plain Go: the scheduler's policy (`proc.go`), the garbage collector (`mgc.go` etc.), `netpoll` dispatch (`netpoll.go` — *Go code*; the platform-specific backends like `netpoll_epoll.go` are also pure Go that call into the asm `epollwait` trampoline). The asm is strictly the three categories in [`00-overview.md`](00-overview.md): calling-convention-breaking operations, arch-specific instructions, and syscall trampolines.
The three categories writeonce **also** needs a solution for — but writeonce gets all three from Rust stdlib + libc. The next doc catalogues those mappings.

View file

@ -4,11 +4,11 @@
## Why the policy works
Each Go asm category from [`01-go-runtime-asm.md`](./01-go-runtime-asm.md) maps to a Rust-stdlib equivalent that is already correct on every supported architecture:
Each Go asm category from [`01-go-runtime-asm.md`](01-go-runtime-asm.md) maps to a Rust-stdlib equivalent that is already correct on every supported architecture:
| Go asm need | What writeonce uses | Why it covers the gap |
| --- | --- | --- |
| Scheduler stack switching (`gogo`, `mcall`, `systemstack`) | — nothing — | Single-threaded event loop through phases 02–08 (see Phase 2 [Concurrency Model](../../../runtime/database/02-wo-language.md#concurrency-model)). [Phase 09](../../09-concurrency-scaleout.md) introduces **thread-per-core** scaling for the 10k-user ecommerce workload — but still no Go-style stack switching: each thread runs its own event loop, connections are pinned for their lifetime, and cross-thread work is message-passing, not scheduler-stealing. No goroutines, no `g0`, even at scale. |
| Scheduler stack switching (`gogo`, `mcall`, `systemstack`) | — nothing — | Single-threaded event loop through phases 02–08 (see Phase 2 `Concurrency Model`). `Phase 09` introduces **thread-per-core** scaling for the 10k-user ecommerce workload — but still no Go-style stack switching: each thread runs its own event loop, connections are pinned for their lifetime, and cross-thread work is message-passing, not scheduler-stealing. No goroutines, no `g0`, even at scale. |
| Preemption (`asyncPreempt`) | — nothing — | No preemption through phases 02–08. Phase 09's thread-per-core model keeps this property: handlers run to completion on whichever thread owns their connection. |
| Atomic operations (`Load`, `Store`, `Cas`, `Xadd`, ...) | [`std::sync::atomic`](https://doc.rust-lang.org/std/sync/atomic/) | The compiler emits the right instruction per target — `LOCK CMPXCHG` on x86, `LDXR/STXR` on ARM, `LR.W/SC.W` on RISC-V. Ordering is in the type signature (`Ordering::Acquire`, `Release`, `SeqCst`). |
| Memory barriers (`MFENCE` etc.) | [`std::sync::atomic::fence(Ordering)`](https://doc.rust-lang.org/std/sync/atomic/fn.fence.html) | One call, one fence, arch-neutral. |
@ -69,7 +69,7 @@ No asm has been written under this policy yet. The expectation is it stays that
## Cross-references
- [`00-overview.md`](./00-overview.md) — why runtimes ever need asm at all (three categories).
- [`01-go-runtime-asm.md`](./01-go-runtime-asm.md) — Go's asm inventory, by file.
- [`00-overview.md`](00-overview.md) — why runtimes ever need asm at all (three categories).
- [`01-go-runtime-asm.md`](01-go-runtime-asm.md) — Go's asm inventory, by file.
- [`../linux/04-signalfd.md`](../linux/04-signalfd.md) — the specific primitive that obviates Go's `sigtramp` asm.
- [`../02-event-loop-epoll.md`](../../done/02-event-loop-epoll.md) — phase 02, where the `runtime/` module actually lands.
- `../02-event-loop-epoll.md` — phase 02, where the `runtime/` module actually lands.

View file

@ -2,7 +2,7 @@
> **Status: ✅ done** — phases A–F all shipped with measured exit evidence below. Board: [00-status.md](../../../stories/00-status.md)
**Context sources:** [`prototypes/wo-rt-c/wo-rt.c`](../../../../runtime/wo-rt.c) (phase 0 — the single-threaded epoll baseline), [`../../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md) (the thread-per-core doctrine every phase here miniaturizes), [`../../10-storage-foundations.md`](../../10-storage-foundations.md) / [`11-wal-and-recovery.md`](../../11-wal-and-recovery.md) / [`12-engine-disk-cutover.md`](../../12-engine-disk-cutover.md) (the storage track), kernel reference cards [`../linux/07-io_uring.md`](../linux/07-io_uring.md), [`08-mmap.md`](../linux/08-mmap.md), [`09-fallocate.md`](../linux/09-fallocate.md), [`12-pwrite-fsync.md`](../linux/12-pwrite-fsync.md), [`02-eventfd.md`](../linux/02-eventfd.md).
**Context sources:** [`prototypes/wo-rt-c/wo-rt.c`](../../../../runtime/wo-rt.c) (phase 0 — the single-threaded epoll baseline), `../../09-concurrency-scaleout.md` (the thread-per-core doctrine every phase here miniaturizes), `../../10-storage-foundations.md` / `11-wal-and-recovery.md` / `12-engine-disk-cutover.md` (the storage track), kernel reference cards [`../linux/07-io_uring.md`](../linux/07-io_uring.md), [`08-mmap.md`](../linux/08-mmap.md), [`09-fallocate.md`](../linux/09-fallocate.md), [`12-pwrite-fsync.md`](../linux/12-pwrite-fsync.md), [`02-eventfd.md`](../linux/02-eventfd.md).
## Goal
@ -21,7 +21,7 @@ Each phase is the executable proving ground for the matching Rust plan (09–12)
- *Consistency* — invariants checked in-thread before the RAM apply; an aborted op writes nothing.
- *Isolation* — one thread is one serial execution stream: per-shard serializability by construction.
- *Durability* — the HTTP ack is sent only after the fsync completion for the commit's WAL tick.
Cross-shard transactions (2PC) belong to the Rust track ([plan 09e](../../09-concurrency-scaleout.md)) — non-scope here.
Cross-shard transactions (2PC) belong to the Rust track (`plan 09e`) — non-scope here.
6. **Dual-write order.** Commit = apply to the RAM slot → append WAL record (write SQE) → one `fdatasync` SQE per loop tick covering all of that tick's commits (group commit) → ack. Boot = replay per-shard WAL/snapshot from disk into the arena, in parallel, before listeners open.
## Phase sequence
@ -29,14 +29,14 @@ Each phase is the executable proving ground for the matching Rust plan (09–12)
One phase per implementation pass; `make` + the smoke endpoints stay green after every phase.
### Phase A — thread-per-core skeleton — ✅ shipped
*Maps to [plan 09a](../../09-concurrency-scaleout.md); cards: `SO_REUSEPORT`, [`eventfd`](../linux/02-eventfd.md).*
*Maps to `plan 09a`; cards: `SO_REUSEPORT`, [`eventfd`](../linux/02-eventfd.md).*
`WO_THREADS` pthreads, each pinned to its core; per-thread epoll loop (io_uring arrives in C), per-thread `SO_REUSEPORT` listener on the same port (kernel balances accepts by 4-tuple), per-thread `conns[]` and request counters. Shutdown: thread 0 owns the `signalfd`; on signal it writes each thread's `eventfd`, every loop exits, `pthread_join` all. Store stays per-thread arrays until B. Responses gain `"shard":t`; `/` reports `"threads":N` and per-shard counters.
**Exit (met):** all endpoints green at `WO_THREADS=4`; `/proc/<pid>/task/*/status` shows each thread pinned to its own core (tid→cpu 0,1,2,3); `/` counters proved 200 concurrent requests spread `[68,36,44,53]` across 4 shards; SIGTERM broadcast joined all shards cleanly; `WO_THREADS=1` behaves like phase 0 (shard 0, sequential ids). Note ids are now interleaved per shard (t+1, t+1+N, …) for coordination-free global uniqueness.
### Phase B — the RAM arena — ✅ shipped
*Maps to [plan 10](../../10-storage-foundations.md); cards: [`mmap`](../linux/08-mmap.md), [`fallocate`](../linux/09-fallocate.md).*
*Maps to `plan 10`; cards: [`mmap`](../linux/08-mmap.md), [`fallocate`](../linux/09-fallocate.md).*
One arena: a header page (magic, version, shard count, slot geometry) + N shard slices of fixed-size row slots + a per-shard allocation bitmap. `mmap(MAP_ANONYMOUS|MAP_PRIVATE [|MAP_HUGETLB], MAP_POPULATE)` then `mlock` (graceful fallback + warning if `RLIMIT_MEMLOCK` refuses). Rows move from arrays into slots; every access is a typed pointer into the owning thread's slice — decision 2 made literal.
@ -50,21 +50,21 @@ Replace each thread's epoll loop with a raw ring: `io_uring_setup`, mmap SQ/CQ,
**Exit (met):** four requests over one socket (`curl` reported `num_connects: 1, 0, 0, 0`), and a create+list pair on one connection lands on the same shard with both rows visible; `strace -c` over 60 keep-alive requests showed **124 `io_uring_enter` and zero `epoll_wait`/`recvfrom`/`sendto`/`accept`** — the only `read`/`write` calls were the signalfd/eventfd shutdown path; SIGTERM broadcast joined all shards cleanly. Raw ring (`io_uring_setup` + SINGLE_MMAP rings + `io_uring_enter`), multishot accept with `CQE_F_MORE` re-arm, one outstanding SQE per connection, pipelined-tail carry-over.
### Phase D — WAL dual write: RAM first, then the hard drive — ✅ shipped
*Maps to [plan 11](../../11-wal-and-recovery.md) + 09c; cards: [`pwrite/fsync`](../linux/12-pwrite-fsync.md), [`fallocate`](../linux/09-fallocate.md).*
*Maps to `plan 11` + 09c; cards: [`pwrite/fsync`](../linux/12-pwrite-fsync.md), [`fallocate`](../linux/09-fallocate.md).*
Per-shard `shard-<t>.wal`, preallocated with `fallocate`. The commit path is decision 6 verbatim: RAM apply → framed record append (write SQE at the shard's tail offset) → one `fdatasync` SQE per tick → ack on the fsync CQE. CRC32 hand-rolled. Acks for all commits in a tick ride the same fsync — group commit, exactly the Rust runtime's doctrine.
**Exit (met):** crash test — 60 concurrent POSTs, `kill -9` mid-stream — showed **60/60 acked writes present and CRC-valid in the WALs, zero acked-but-missing** (offline verification via the new `wo-rt wal-check <file>` mode, which is also phase E's replay skeleton); a copy truncated mid-record reported `TORN at byte 2584 — 17 whole records before it`, dropping the partial whole; 200 concurrent durable commits in 102 ms (~1,960 commits/s, curl-process-bound — each tick's fsync covers every commit staged that tick via double-buffered write→fsync `IOSQE_IO_LINK` pairs). Failed fsync closes the batch's connections without acking — a client never sees a 201 for a non-durable write. Phase D boots with `O_TRUNC` (fresh log); replay lands in E.
### Phase E — first load: hard drive → RAM — ✅ shipped
*Maps to plans [11](../../11-wal-and-recovery.md)/[12](../../12-engine-disk-cutover.md).*
*Maps to plans `11`/`12`.*
Boot, before any listener opens: each thread replays its own WAL into its arena slice — parallel recovery — validating frame CRC + commit marker, truncating at the first torn record. Clean shutdown writes a snapshot (`pwrite` of live slots to `shard-<t>.data`) and truncates the WAL; boot prefers snapshot + WAL tail. Recovery time printed at startup.
**Exit (met):** the full cycle verified — (1) 40 writes + `kill -9` + restart: `shard_used [10,7,15,8]` identical, replayed from WAL alone (`recovered 0 snapshot rows + N wal records in 1 ms` per shard); (2) SIGTERM wrote four snapshots and truncated the WALs; (3) restart loaded the snapshots instantly; (4) 5 more writes + `kill -9` + restart recovered **snapshot + WAL tail combined** (`recovered 7 snapshot rows + 2 wal records`), totals exact at 45/45; (5) a restart with `WO_THREADS=8` against a 4-shard data dir **refuses to boot** (`meta` guard — resharding is 09f, never silent data loss). Recovery 1–3 ms at demo geometry; large-arena timing rides phase F's harness, which can generate volume natively (geometry is `-D` overridable: `SLOTS_PER_SHARD`/`SLOT_SIZE`).
### Phase F — million-scale harness + ACID verification — ✅ shipped
*Maps to [plan 09's verification-targets table](../../09-concurrency-scaleout.md).*
*Maps to `plan 09's verification-targets table`.*
`setrlimit(RLIMIT_NOFILE)` raised at boot. A small C load client under `prototypes/wo-rt-c/bench/` (keep-alive, pipelined GETs, latency timestamps — `wrk` would be an external dep). Measure honestly on the dev box and commit the numbers to the prototype README: aggregate read req/s across cores (goal order 10⁶/s on 8–16 cores), concurrent open connections (goal order 10⁵–10⁶; ~8 KB/conn + fd limits are the ceiling), commits/s under group fsync, p99 read latency under write load. ACID scripts: torn-WAL injection (atomicity), single-shard interleaving probe (isolation), the phase-D crash test under load (durability). A `just rt-c-bench` recipe runs it all.
@ -80,9 +80,9 @@ Boot, before any listener opens: each thread replays its own WAL into its arena
## Cross-references
- [`../../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md) — the doctrine; this prototype is its executable proving ground (A↔09a, C↔09 decision 4, D↔09c).
- [`../../10-storage-foundations.md`](../../10-storage-foundations.md), [`11-wal-and-recovery.md`](../../11-wal-and-recovery.md), [`12-engine-disk-cutover.md`](../../12-engine-disk-cutover.md) — the storage track phases B/D/E miniaturize.
- `../../09-concurrency-scaleout.md` — the doctrine; this prototype is its executable proving ground (A↔09a, C↔09 decision 4, D↔09c).
- `../../10-storage-foundations.md`, `11-wal-and-recovery.md`, `12-engine-disk-cutover.md` — the storage track phases B/D/E miniaturize.
- [`../../../../prototypes/wo-rt-c/README.md`](../../../../runtime/README.md) — current state and module map (phase 0).
- [`./01-architecture.md`](./01-architecture.md) — the target architecture traced through one memory address at million-connection concurrency, plus improvement proposals (seqlock reads, registered buffers, SEND_ZC, SQPOLL) that slot into phases C/F.
- [`./02-single-binary.md`](./02-single-binary.md) — the end goal: how the `wo build` single binary runs on this runtime environment (Go model, not JVM — the kernel is statically linked into every app; the embedding contract between compiler payload and runtime kernel).
- [`../../../../prototypes/wo-db/`](../../../../prototypes/wo-db/) — the query-layer sibling; one day a phase-G could splice its engine on top of this runtime.
- [`./01-architecture.md`](01-architecture.md) — the target architecture traced through one memory address at million-connection concurrency, plus improvement proposals (seqlock reads, registered buffers, SEND_ZC, SQPOLL) that slot into phases C/F.
- [`./02-single-binary.md`](02-single-binary.md) — the end goal: how the `wo build` single binary runs on this runtime environment (Go model, not JVM — the kernel is statically linked into every app; the embedding contract between compiler payload and runtime kernel).
- `../../../../prototypes/wo-db/` — the query-layer sibling; one day a phase-G could splice its engine on top of this runtime.

View file

@ -1,6 +1,6 @@
# wo-rt-c architecture — one memory address, two spaces, a million connections
This document defines the runtime's architecture by following **one memory address** through user space, kernel space, and hardware, under a million connections reading and writing it concurrently — then suggests improvements. Companion docs: [`00-plan.md`](./00-plan.md) (the phases that build this), [`README.md`](../../../../runtime/README.md) (phase-0 module map).
This document defines the runtime's architecture by following **one memory address** through user space, kernel space, and hardware, under a million connections reading and writing it concurrently — then suggests improvements. Companion docs: [`00-plan.md`](00-plan.md) (the phases that build this), [`README.md`](../../../../runtime/README.md) (phase-0 module map).
## The cast: one address
@ -14,7 +14,7 @@ Three facts define everything that follows:
1. **User space sees a virtual address.** `0x7f3a2c001000` is an entry in this process's page tables; the kernel resolved it to one physical RAM frame at fault time (`MAP_POPULATE` faults it in at boot, before any request).
2. **The kernel pins the frame.** `mlock` guarantees the physical page is never swapped — a load from this address is always a RAM access, never disk I/O in disguise.
3. **Exactly one thread owns writes to it.** The address lies inside shard 0's slice; thread 0 is the only code in the process that may store to it ([00-plan.md decision 2](./00-plan.md)). Data exists once — ownership, not copying, is the concurrency model.
3. **Exactly one thread owns writes to it.** The address lies inside shard 0's slice; thread 0 is the only code in the process that may store to it ([00-plan.md decision 2](00-plan.md)). Data exists once — ownership, not copying, is the concurrency model.
## The two spaces
@ -55,7 +55,7 @@ The arrows worth staring at: the thread's access to the database (`MOV`) and to
## Write path — the address changes
One of the million connections POSTs a new value. Dual-write order per [00-plan.md decision 6](./00-plan.md): RAM first, then the hard drive, ack only after the disk confirms.
One of the million connections POSTs a new value. Dual-write order per [00-plan.md decision 6](00-plan.md): RAM first, then the hard drive, ack only after the disk confirms.
```mermaid
sequenceDiagram
@ -110,7 +110,7 @@ do { v1 = atomic_load_acquire(&slot->ver); /* spin only while odd */
} while (v1 != v2 || (v1 & 1));
```
A hot row becomes readable by all N cores **with zero duplication — same physical frame, same address** — and writes stay serial, so ACID isolation is untouched. Cost: two atomic increments per write, a retry loop per read (C11 atomics, no library). Doctrine note: this relaxes "only the owner touches the slice" to "only the owner *writes* the slice"; contrast with [plan 13e's hot-row read replicas](../../13-class-model-live-pricing.md), which solve the same bottleneck by *copying* rows per thread — seqlock is the no-duplication answer the replica design isn't.
A hot row becomes readable by all N cores **with zero duplication — same physical frame, same address** — and writes stay serial, so ACID isolation is untouched. Cost: two atomic increments per write, a retry loop per read (C11 atomics, no library). Doctrine note: this relaxes "only the owner touches the slice" to "only the owner *writes* the slice"; contrast with `plan 13e's hot-row read replicas`, which solve the same bottleneck by *copying* rows per thread — seqlock is the no-duplication answer the replica design isn't.
### 2. Registered buffers and files (`IORING_REGISTER_BUFFERS` / `_FILES`)
@ -138,12 +138,12 @@ On multi-socket boxes, bind each shard slice's pages to the owning core's NUMA n
### Deliberately not suggested
Work stealing (breaks single-writer ACID), shared-heap locking (the doctrine exists to avoid it — and at 1M readers a mutex on the row would serialize everything the seqlock parallelizes), liburing (the prototype's value is the raw syscall sequence), and multi-node distribution (plan 09's single-box stance). See [plan 09 § Non-scope](../../09-concurrency-scaleout.md).
Work stealing (breaks single-writer ACID), shared-heap locking (the doctrine exists to avoid it — and at 1M readers a mutex on the row would serialize everything the seqlock parallelizes), liburing (the prototype's value is the raw syscall sequence), and multi-node distribution (plan 09's single-box stance). See `plan 09 § Non-scope`.
## Cross-references
- [`00-plan.md`](./00-plan.md) — phases A–F that build the architecture described here; improvements 1–7 slot into phases C/F or follow them.
- [`../../docs/plan/09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md) — the thread-per-core doctrine.
- [`00-plan.md`](00-plan.md) — phases A–F that build the architecture described here; improvements 1–7 slot into phases C/F or follow them.
- `../../docs/plan/09-concurrency-scaleout.md` — the thread-per-core doctrine.
- [`../../docs/plan/exploration/linux/07-io_uring.md`](../linux/07-io_uring.md), [`08-mmap.md`](../linux/08-mmap.md) — the two shared-page mechanisms.
- [`../../docs/plan/13-class-model-live-pricing.md`](../../13-class-model-live-pricing.md) — 13e's read-replica alternative, contrasted in improvement 1.
- `../../docs/plan/13-class-model-live-pricing.md` — 13e's read-replica alternative, contrasted in improvement 1.
- [`README.md`](../../../../README.md) — the C/assembly "one address" pedagogy the single-binary story extends to a full runtime.

View file

@ -1,6 +1,6 @@
# 02 — The end goal: the writeonce single binary on this runtime environment
**Context sources:** [`00-plan.md`](./00-plan.md) (the runtime-environment phases, A–B ✅), [`01-architecture.md`](./01-architecture.md) (the one-address trace), `../../../runtime/wo-language.md` ("one binary per project; no runtime to install on the target host"; `.wo` has "its own lexer, parser, analyzer, and bytecode"), [`../../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md)–[`12`](../../12-engine-disk-cutover.md) (the Rust product track this proves out), [`../../../runtime/database/02-wo-language.md`](../../../runtime/database/02-wo-language.md) (catalog + transaction semantics the payload carries).
**Context sources:** [`00-plan.md`](00-plan.md) (the runtime-environment phases, A–B ✅), [`01-architecture.md`](01-architecture.md) (the one-address trace), `../../../runtime/wo-language.md` ("one binary per project; no runtime to install on the target host"; `.wo` has "its own lexer, parser, analyzer, and bytecode"), `../../09-concurrency-scaleout.md`–`12` (the Rust product track this proves out), `../../../runtime/database/02-wo-language.md` (catalog + transaction semantics the payload carries).
## The end goal, stated once
@ -80,11 +80,11 @@ Steps 1–2 and 4–6 exist in `wo-rt-c` today with the notes store standing in
- **`wo-rt-c` (C)** — proves each kernel syscall sequence first: threads/arena (✅), io_uring, WAL, recovery, bench. It will never parse `.wo`; its notes store is the stand-in payload.
- **`crates/rt` (Rust)** — the product: owns the compiler front-end today (lexer→catalog, Stage 2 shipped) and absorbs each proven kernel sequence per plans 09–12, where ownership makes the shard discipline a compile-time guarantee.
- **Optional phase G** (named in [`00-plan.md`](./00-plan.md)): splice the [`wo-db`](../../../../prototypes/wo-db/) C++ query engine onto `wo-rt-c` as an end-to-end C-family demonstrator of this document — valuable as proof, never the product.
- **Optional phase G** (named in [`00-plan.md`](00-plan.md)): splice the `wo-db` C++ query engine onto `wo-rt-c` as an end-to-end C-family demonstrator of this document — valuable as proof, never the product.
## Cross-references
- [`00-plan.md`](./00-plan.md) — the kernel phases; [`01-architecture.md`](./01-architecture.md) — the one-address trace through the same stack.
- [`00-plan.md`](00-plan.md) — the kernel phases; [`01-architecture.md`](01-architecture.md) — the one-address trace through the same stack.
- [`README.md`](../../../../README.md) — the user-facing single-binary promise this document implements.
- [`../../13-class-model-live-pricing.md`](../../13-class-model-live-pricing.md) (13b methods) — the payload-side track.
- [`../../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md) — shard-key routing and 2PC the contract defers to.
- `../../13-class-model-live-pricing.md` (13b methods) — the payload-side track.
- `../../09-concurrency-scaleout.md` — shard-key routing and 2PC the contract defers to.

View file

@ -0,0 +1,207 @@
# Fiber parity study — what a mainstream Go web framework ships that `porch` does not
Reference: [gofiber/fiber](https://github.com/gofiber/fiber) **v3.5.0**, read
2026-08-26 from a shallow clone at `.dev/reference/fiber` (gitignored — re-clone
with `git clone --depth 1 https://github.com/gofiber/fiber .dev/reference/fiber`).
Read against `docs/examples/porch` as it stands the same day.
Why Fiber and not Express or Axum: it is the closest structural analogue in the
reference set. Compiled language, no runtime, one binary, an explicit
`Ctx`-per-request, and middleware as an ordered chain — the same shape
`porch` already has. Where it differs, the difference is a feature
decision rather than a paradigm gap, which is what makes the comparison useful.
Express would have contributed mostly "you have no closures".
What was actually read: `app.go` (routing methods, the 53-field `Config`),
`ctx.go` / `req.go` / `res.go` (the request and response surface), `bind.go`
(binding), `hooks.go` (lifecycle), and the `Config` struct of every one of the
**32** packages under `middleware/`.
**Headline: `porch` is further along than its size suggests.** Of
Fiber's 32 middleware packages, 9 already have a working `porch`
counterpart (CORS, basic auth, key/bearer auth, helmet-style security headers,
ETag, static files, logger, host authorization, recover-as-500). The gaps are
real but they are mostly *breadth*, and they cluster around four things:
**cookies** (absent entirely, and half the list sits on top of them),
**streaming** (absent, and SSE/compression/chunked all sit on that),
**binding** (blocked by doctrine until `@derive`), and **one missing runtime
primitive nobody had noticed**.
---
## 0. The blocker the existing ledger gets wrong
`docs/examples/porch/README.md`'s crypto row currently reads:
> Unlocks (signed cookies, CSRF, session integrity, webhook verification, JWT
> HS256) | ⬜ **UNBLOCKED** (the primitives exist since iteration 34)
**That is not true for three of the five.** SHA-256 and HMAC-SHA256 let you
*authenticate* a token. They do not let you *mint* one, because
**writeonce has no source of randomness at all** — `grep -inE
'random|rand|urandom|getrandom'` over `compiler/src/types.ml`,
`runtime/src/wob.h`, `sysio.c` and `crypto.c` returns nothing. There is no
`getrandom(2)`, no `/dev/urandom` read (`fs.read_at` could reach it, but `fs`
has no way to open a character device meaningfully and the result would be a
`Text` of raw bytes with no API contract), and no CSPRNG builtin.
Fiber's session store defaults its `KeyGenerator` to a UUID; its CSRF
middleware mints a token per request. Both are unguessability requirements, not
integrity requirements. An HMAC over a *predictable* session id is not a
session — it is a signed guess.
So the honest dependency order is: **a random-bytes builtin comes before
sessions and CSRF, not after them.** Signed cookies over an
application-supplied value and webhook verification (where the secret comes
from config and the nonce comes from the *sender*) genuinely are unblocked; JWT
HS256 is unblocked for verification and blocked for issuing anything with a
random `jti`.
This is the study's most valuable single finding and it is the reason iteration
39 leads with the primitive rather than the middleware.
---
## 1. Cookies — absent, and foundational
`porch` has **no cookie support in either direction**. `Req` has
`headers: map<Text, Text>` and nothing parses `Cookie:`; `Resp` has
`headers: map<Text, Text>` and there is no `Set-Cookie` builder — and because
`Resp.headers` is a *map*, it structurally cannot carry the two `Set-Cookie`
lines a login-plus-flash response needs. That map is a real design constraint
this iteration has to confront, not a missing function.
Fiber, for comparison: `Req.Cookies(key)`, `Res.Cookie(*Cookie)` with
`ClearCookie`, and a `Cookie` struct carrying Path, Domain, MaxAge, Expires,
Secure, HTTPOnly, SameSite, Partitioned and SessionOnly.
| Piece | Fiber | porch |
| --- | --- | --- |
| read request cookies | `Req.Cookies(key)` | — |
| set a response cookie | `Res.Cookie(&Cookie{...})` | — |
| clear | `Res.ClearCookie(key...)` | — |
| attributes | Path/Domain/MaxAge/Expires/Secure/HTTPOnly/SameSite/Partitioned | — |
| multiple `Set-Cookie` per response | native (header list) | **impossible** — `Resp.headers` is `map<Text,Text>` |
| signed / encrypted | `encryptcookie` middleware | — (HMAC exists; see §0 for the minting problem) |
Everything in §2 depends on this section landing first.
## 2. Session, CSRF, rate limiting, idempotency — the store-backed chain
All four are one shape in Fiber: a middleware plus a `Storage` interface. All
four are pure-`.wo` work in writeonce *once cookies and randomness exist*, and
writeonce has an unusual advantage here — `@table` gives a **durable,
WAL-backed, crash-recoverable** store for free, where Fiber ships an in-memory
default and makes you bolt on Redis for anything real.
| Middleware | Fiber's config knobs (the shape to translate) | writeonce status |
| --- | --- | --- |
| `session` | Storage, KeyGenerator, IdleTimeout, AbsoluteTimeout, CookieDomain/Path/SameSite/Secure/HTTPOnly/SessionOnly, Extractor | absent; needs §0 + §1 |
| `csrf` | Storage, Session, KeyGenerator, TrustedOrigins, SingleUseToken, CookieName + the cookie attrs, IdleTimeout, Extractor | absent; needs §0 + §1 |
| `limiter` | Storage, Max, Expiration, KeyGenerator, LimitReached, SkipFailed/SkipSuccessful, DisableHeaders | absent; needs only a store + `time.ticks` — **unblocked today** |
| `idempotency` | Storage, Lock, KeyHeader + validator, Lifetime, KeepResponseHeaders | absent; store + `time.ticks` — **unblocked today** |
`limiter` and `idempotency` are the two cheapest real wins in this whole
document: no new primitive, no cookie, just a `@table` and a clock that already
exists.
## 3. Streaming — absent, and three features sit on it
`internal/serve.wo` builds a whole response as one `Text` and writes it with a
single `net.write`; `serialize()` always emits `Content-Length`. There is no
flush, no chunked framing, no way to write a response incrementally.
`internal/parse.wo:153-157` **explicitly refuses** chunked request bodies, with
a correct note that silently treating a chunked request as body-less is request
smuggling — that refusal is good engineering and should stay until chunked is
implemented properly.
Blocked on this one seam:
- **SSE** (Fiber: `middleware/sse` with Retry, HeartbeatInterval, OnClose) —
the natural fit for writeonce's actor model, since a room actor already has
the fan-out shape. Wants iteration 24's chat work beside it.
- **Compression** (Fiber: `middleware/compress`, Level) — gzip/deflate/brotli.
Iteration 36 landed the bitwise operators, so a pure-`.wo` DEFLATE is now
*expressible*; whether it should be `.wo` or a C builtin is a genuine fork,
and the honest answer probably depends on whether anything else ever wants
zlib.
- **`SendFile` / `SendStream` / byte ranges** — Fiber's static ships ByteRange,
Browse, MaxAge, CacheDuration, IndexNames, Download. `http/files.wo` serves
whole small files only. Range requests are what make video and large
downloads work.
The framework README already lists "lazy body streaming + backpressure ·
streaming responses · explicit commit point" as ⏸ unblocked-by-the-arc. This
study's contribution is naming what *else* falls out of it.
## 4. Binding — blocked by doctrine, and that is fine
Fiber's `Bind` is 16 methods: `Body`, `JSON`, `XML`, `CBOR`, `MsgPack`, `Form`,
`Query`, `URI`, `Header`, `Cookie`, `RespHeader`, `All`, `Custom`, plus
validator hooks. It fills a struct by reflecting over tags.
writeonce has `json.decode(t) as T` for JSON bodies and **nothing** for query,
path params, form or header — those hand you `map<Text, Text>` and you assign
field by field. Principle 13 forbids reflection, so this cannot be closed the
way Fiber closes it.
The right owner is **[iteration 29, `@derive`](../../../stories/language-runtime-database/29-compile-time-metaprogramming.md)**:
compile-time generation from the class table gives typed binding with no
runtime reflection. This is a genuine parity gap with a real answer that is
already on the roadmap, so iteration 39 records it and does not attempt it.
Same verdict, same reason, for the codec spread: Fiber ships XML, CBOR and
MsgPack encoders/decoders in `Config`. writeonce ships JSON. That is the small
stdlib doing its job, not a defect.
## 5. Routing and response ergonomics — mostly sugar, cheap to close
| Gap | Fiber | porch |
| --- | --- | --- |
| method helpers | Get/Post/Put/Delete/Patch/Head/Options/Trace/Connect/All/Add | `get`/`post`/`put`/`delete_` only — a `Route { method: "PATCH" }` literal works, so this is registration sugar, but its absence is felt |
| route names + URL building | `Name()`, `GetRouteURL()` | — (no named routes, no reverse routing) |
| route introspection | `GetRoutes()`, `Stack()`, `HandlersCount()` | — |
| mount / sub-app | `Use(prefix, subApp)` | `Group { prefix }` covers the common case ✅ |
| case sensitivity / strict slash | `CaseSensitive`, `StrictRouting` | — (always case-sensitive, always lenient) |
| per-route body limit | `Config.BodyLimit` | `const BODY_MAX = 1048576` in `internal/parse.wo` — one compile-time number for the whole server |
| per-handler timeout | `middleware/timeout` (Timeout, OnTimeout) | conn-level `read_ms`/`idle_ms` only; bounding a *handler* needs cancellation, which the README already parks |
| `Location`, `Vary`, `Links`, `Append`, `Attachment`/`Download` | ✅ each a `Res` method | — (`set_header` by hand) |
| `Format`/`AutoFormat` content negotiation on the way out | ✅ | `accepts()` exists; no format dispatch helper |
| q-value **ranking** | ✅ | 🔶 already in the ledger: q-values stripped, not ranked |
| request id | `middleware/requestid` (Header, Generator) | `req.ctx` bag exists to carry it; no generator — and see §0 |
| healthcheck / favicon / redirect / rewrite / skip | five small middleware | — (each a handful of lines) |
| `earlydata`, `paginate`, `responsetime`, `envvar`, `expvar`, `pprof` | ✅ | — (`expvar`/`pprof` belong to iteration 30, which has no story file) |
| lifecycle hooks | 11 hook families (OnRoute, OnListen, OnPreShutdown, OnPostShutdown, …) | — README already notes "no user teardown hooks yet" 🔶 |
| `proxy` middleware | ✅ | **impossible today** — no `net.connect`; owned by [iteration 38](../../../stories/language-runtime-database/38-content-platform-capabilities.md) |
| `recover` with stack trace | `EnableStackTrace`, `StackTraceHandler` | trap → 500 and the server lives ✅; no backtrace primitive exists |
## 6. Deliberate divergences — listed so nobody re-opens them
Not gaps. Each was decided and the reasoning is on file.
| Fiber feature | writeonce position |
| --- | --- |
| `Views` / `Render` / `ReloadViews` / `PassLocalsToViews` — a runtime template engine | **Rejected.** Markup is a compile-time literal (iteration 37's raw text literal + `writeonce-view`'s `Component`) or it does not exist. A per-request file read is the already-rejected engine — see `docs/plan/discarded.md`. |
| TLS config, `SetTLSHandler`, HTTP/2 | **Proxy-terminated, forever** (principle: TLS is not the app's job). h2c stays parked behind iteration 23. |
| `adaptor` (net/http interop) | No FFI, no foreign handler ecosystem to adapt to. |
| `Concurrency`, `ReadBufferSize`, prefork/`OnFork` | The shard-actor runtime owns placement; there is no worker-pool knob to expose. |
| Closures as handlers | Handlers are classes satisfying `Handler` (`router/router.wo`'s own note: "no function values in this language, by doctrine — a handler is a CLASS, its fields are the closure substitute"). |
| `SharedState` / `Locals` as an untyped bag | `req.ctx` is `map<Text,Text>` on purpose; typed per-request state is a `@table` row or a field on the handler class. |
## 7. What this study feeds
The **[`porch` track](../../../stories/porch/00-story.md)** — eight iterations
numbered from 1, which superseded language iteration 39 on the day this study
was written. §0–§2 and the cheap half of §5 became porch 1–5, in dependency
order because §0 gates §2 and §1 gates most of it; §3's streaming seam and what
falls out of it became porch 6–8, so nothing in this study is now unscheduled
except what the table below hands to someone else.
Explicitly *not* iteration 39's, with owners:
- streaming, SSE, compression, byte ranges (§3) — [porch 6](../../../stories/porch/06-streaming-core.md)–[8](../../../stories/porch/08-static-and-lifecycle.md), which unparked them
- typed binding (§4) — [iteration 29](../../../stories/language-runtime-database/29-compile-time-metaprogramming.md)
- TTL cache middleware — [iteration 18](../../../stories/language-runtime-database/18-memory-db-features.md)
- `proxy` — [iteration 38](../../../stories/language-runtime-database/38-content-platform-capabilities.md)
- `pprof`/`expvar`/metrics — iteration 30
- everything in §6 — closed by doctrine

View file

@ -3,8 +3,8 @@
> Exploration/reference note (no status banner by board convention).
> The normative decisions live in the arc spec
> ([`2026-08-20-shard-fiber-arc-design.md`](../../../superpowers/specs/2026-08-20-shard-fiber-arc-design.md))
> and iterations [8](../../../stories/language-runtime-database/done/08-shard-actor-runtime.md) /
> [11](../../../stories/language-runtime-database/done/11-fibers.md); this
> and iterations [8](../../../stories/language-runtime-database/08-shard-actor-runtime.md) /
> [11](../../../stories/language-runtime-database/11-fibers.md); this
> page explains the WHY at doctrine depth. Written 2026-08-20, when this
> file was also the target of a dangling reference from iteration 11 —
> it exists now.

View file

@ -8,18 +8,18 @@ Each primitive has its own numbered file with the kernel source path (into [`ref
| # | Primitive | Used by |
| --- | --- | --- |
| [01](./01-epoll.md) | `epoll` — event-driven I/O multiplexing | every runtime phase |
| [02](./02-eventfd.md) | `eventfd` — counter as fd, cross-flow wake | phase 02, subscription wakeup |
| [03](./03-timerfd.md) | `timerfd` — timers as fds | phase 02, phase 07 debounce |
| [04](./04-signalfd.md) | `signalfd` — signals as fds, graceful shutdown | phase 04 |
| [05](./05-inotify.md) | `inotify` — filesystem events as fds | phase 07, future register! subscription |
| [06](./06-sendfile.md) | `sendfile` — zero-copy file → socket | phase 08 |
| [07](./07-io_uring.md) | `io_uring` — async I/O ring buffers | phase 3 (WAL fsync), future HTTP |
| [08](./08-mmap.md) | `mmap` + `madvise` — memory-mapped files, page-cache hints | phase 3 (storage engine) |
| [09](./09-fallocate.md) | `fallocate` + `pread` + `pwritev2` — positional I/O & pre-allocation | phase 3 (WAL + SSTables) |
| [10](./10-pidfd.md) | `pidfd` — process as fd, race-free supervision | future supervisor |
| [11](./11-memfd_create.md) | `memfd_create` — anonymous shared memory | phase 3 (index build) |
| [12](./12-pwrite-fsync.md) | `pwrite` + `fsync`/`fdatasync`/`sync_file_range`/`posix_fadvise` — durability barriers | phases 10, 11, 12 (storage foundations, WAL, engine cutover) |
| [01](01-epoll.md) | `epoll` — event-driven I/O multiplexing | every runtime phase |
| [02](02-eventfd.md) | `eventfd` — counter as fd, cross-flow wake | phase 02, subscription wakeup |
| [03](03-timerfd.md) | `timerfd` — timers as fds | phase 02, phase 07 debounce |
| [04](04-signalfd.md) | `signalfd` — signals as fds, graceful shutdown | phase 04 |
| [05](05-inotify.md) | `inotify` — filesystem events as fds | phase 07, future register! subscription |
| [06](06-sendfile.md) | `sendfile` — zero-copy file → socket | phase 08 |
| [07](07-io_uring.md) | `io_uring` — async I/O ring buffers | phase 3 (WAL fsync), future HTTP |
| [08](08-mmap.md) | `mmap` + `madvise` — memory-mapped files, page-cache hints | phase 3 (storage engine) |
| [09](09-fallocate.md) | `fallocate` + `pread` + `pwritev2` — positional I/O & pre-allocation | phase 3 (WAL + SSTables) |
| [10](10-pidfd.md) | `pidfd` — process as fd, race-free supervision | future supervisor |
| [11](11-memfd_create.md) | `memfd_create` — anonymous shared memory | phase 3 (index build) |
| [12](12-pwrite-fsync.md) | `pwrite` + `fsync`/`fdatasync`/`sync_file_range`/`posix_fadvise` — durability barriers | phases 10, 11, 12 (storage foundations, WAL, engine cutover) |
The list below is the original overview kept for context and for a handful of adjacent primitives (`fanotify`, `splice`/`tee`) that don't yet have their own reference card.

View file

@ -68,7 +68,7 @@ unsafe {
## Used by
Every runtime phase that touches I/O: [`02-event-loop-epoll.md`](../../done/02-event-loop-epoll.md), [`03-hand-rolled-http.md`](../../done/03-hand-rolled-http.md), [`07-inotify-content-watcher.md`](../../07-inotify-content-watcher.md), [`08-sendfile-static-assets.md`](../../08-sendfile-static-assets.md).
Every runtime phase that touches I/O: `02-event-loop-epoll.md`, `03-hand-rolled-http.md`, `07-inotify-content-watcher.md`, `08-sendfile-static-assets.md`.
## v1 port source

View file

@ -55,11 +55,11 @@ unsafe {
- **Always 8-byte `read` / `write`.** Short reads/writes return `EINVAL` — the counter is `u64`, full word or nothing.
- **Writing `u64::MAX`** returns `EINVAL`; the counter can't hold more than `u64::MAX - 1`.
- **Multiple writers are OK**; the kernel serialises. But reads race — use `EFD_SEMAPHORE` if you want one consumer per write.
- **Not async-signal-safe.** Don't `write(fd, ...)` from a signal handler; use `signalfd` instead (see [04-signalfd.md](./04-signalfd.md)).
- **Not async-signal-safe.** Don't `write(fd, ...)` from a signal handler; use `signalfd` instead (see [04-signalfd.md](04-signalfd.md)).
## Used by
[`02-event-loop-epoll.md`](../../done/02-event-loop-epoll.md) — wake the loop for shutdown or internal work. Future `sub` crate ([`09-native-subscriptions`], not yet planned) uses it to signal that a subscriber queue has drained.
`02-event-loop-epoll.md` — wake the loop for shutdown or internal work. Future `sub` crate ([`09-native-subscriptions`], not yet planned) uses it to signal that a subscriber queue has drained.
## v1 port source

Some files were not shown because too many files have changed in this diff Show more