writeonce/docs/plan/exploration/fiber/00-fiber-parity.md
shoney.arickathil c0b0dbb846 docs: audit all markdown against the code, fix findings, flatten status folders
- README: shipped concurrency/HTTP/WebSockets sat in the roadmap as "not yet
  available"; "no package manager" contradicted [deps]; the deps example
  would not have compiled (the key IS the module name)
- runtime/README: leads with wovm, wo-rt.c demoted to a historical section;
  dropped 2 nonexistent recipes, crates/rt, @gc refcounting, 13 suites -> 18
- employee + log-watcher READMEs claimed "does not compile"; both are gates
- error catalog: +10 emitted codes incl WO-E250, the only diagnostic the
  shipped query surface raises; recorded why the sweep rotted
- language-surface: group-by parses, then the typechecker refuses it
- 00-code-review + 00-link-audit re-run; history kept, not rewritten
- 48 dead Rust-era exploration links de-linked rather than re-pointed (their
  prose names the retired plan by number); successor map -> discarded.md
- 08-project-structure: compiler/plan/ never existed; corpus has 9 dirs, 5 empty
- releasing.md: dropped a --draft step the workflow never had
- new docs/00-doc-audit.md: findings + disposition, incl one row where the
  audit was wrong and the doc it accused was right
- status folders removed: 34 stories flat, status only in frontmatter; 252
  links recomputed from resolved paths; board/board-views/structure retaught
- story 24 -> in-progress, since frontmatter is now the only truth
- new iteration 38: fs mutation verbs + net.connect, the two capability
  families no iteration owned
- new iteration 39: gofiber/fiber v3.5.0 parity study. The ledger called
  CSRF/sessions unblocked by iteration 34's HMAC, but the runtime has no
  source of randomness at all
- linkcheck skips .dev/.superpowers: 0 broken paths, 0 bad anchors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:20:22 +02:00

13 KiB
Raw Blame History

Fiber parity study — what a mainstream Go web framework ships that writeonce-serve does not

Reference: 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/writeonce-serve 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 writeonce-serve 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: writeonce-serve is further along than its size suggests. Of Fiber's 32 middleware packages, 9 already have a working writeonce-serve 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/writeonce-serve/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

writeonce-serve 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 writeonce-serve
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: 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 writeonce-serve
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
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

Iteration 39 — web framework parity takes §0–§2 and the cheap half of §5, in that order, because §0 gates §2 and §1 gates most of it.

Explicitly not iteration 39's, with owners:

  • streaming, SSE, compression, byte ranges (§3) — the parked streaming slice
  • typed binding (§4) — iteration 29
  • TTL cache middleware — iteration 18
  • proxy — iteration 38
  • pprof/expvar/metrics — iteration 30
  • everything in §6 — closed by doctrine