- 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>
191 lines
11 KiB
Markdown
191 lines
11 KiB
Markdown
---
|
||
iteration: "39"
|
||
status: refine
|
||
---
|
||
|
||
# Iteration 39 — web framework parity: randomness, cookies, and the store-backed middleware chain
|
||
|
||
> Format: `product/story-iteration-template`. Part of
|
||
> [Story — one language, one runtime, one database, one binary](00-story.md).
|
||
>
|
||
> **Inserted 2026-08-26** (developer ask: add gofiber/fiber as a reference and
|
||
> find the basic features the web framework lacks). Derived from
|
||
> [the Fiber v3.5.0 parity study](../../plan/exploration/fiber/00-fiber-parity.md),
|
||
> which read Fiber's routing surface, `Req`/`Res` API, binder and all 32 of its
|
||
> `middleware/` packages against `docs/examples/writeonce-serve`. This iteration
|
||
> takes that study's §0–§2 plus the cheap half of §5; the study names an owner
|
||
> for everything it leaves out.
|
||
|
||
## Goals
|
||
|
||
- **A random-bytes builtin, first, because it gates the rest.** The framework
|
||
README's crypto row claims signed cookies, CSRF and session integrity are
|
||
"UNBLOCKED — the primitives exist since iteration 34". For CSRF and sessions
|
||
**that is wrong**: SHA-256 and HMAC let a program *authenticate* a token, not
|
||
*mint* one, and writeonce has no source of randomness anywhere — no
|
||
`getrandom(2)`, no CSPRNG builtin, nothing. An HMAC over a guessable session
|
||
id is a signed guess. Close this before writing a line of session code, and
|
||
correct the ledger row that says otherwise.
|
||
- **Cookies, in both directions.** Absent entirely today: nothing parses a
|
||
`Cookie:` request header and there is no `Set-Cookie` builder. This is the
|
||
foundation the next goal stands on, and it forces a design decision the
|
||
framework has so far avoided — `Resp.headers` is a `map<Text, Text>`, so it
|
||
**structurally cannot** carry the two `Set-Cookie` lines a login-plus-flash
|
||
response needs. Deciding what replaces or supplements that map is the real
|
||
work of this goal; the parsing is the easy half.
|
||
- **The store-backed middleware chain, in dependency order**: rate limiting and
|
||
idempotency first (they need only a store and `time.ticks`, both of which
|
||
exist — the cheapest real wins available), then sessions, then CSRF. Each is
|
||
an ordinary `.wo` middleware class, and each gets a **durable** store for
|
||
free from `@table` — where Fiber ships an in-memory default and expects you
|
||
to bolt on Redis. That is a genuine writeonce advantage and the samples should
|
||
show it.
|
||
- **Close the routing and response sugar that is merely missing.** `PATCH` /
|
||
`OPTIONS` / `HEAD` / `ALL` registration helpers (a `Route { method: "PATCH" }`
|
||
literal already works, so this is registration ergonomics), a per-route body
|
||
limit instead of one compile-time `BODY_MAX = 1048576` for the whole server, a
|
||
request-id middleware, and the response helpers every framework has and this
|
||
one writes by hand: `Location`, `Vary`, `Attachment`/`Download`.
|
||
- **Say what is still missing, with an owner.** The study's §3–§6 stay out of
|
||
scope; this iteration's closing act is updating the framework README's ledger
|
||
so each row points at whoever owns it rather than reading as an oversight.
|
||
|
||
## Acceptance Criteria
|
||
|
||
- **Given** the random-bytes builtin, **when** the acceptance script draws many
|
||
values across separate processes, **then** no value repeats and none is
|
||
derivable from the clock — and the builtin is refused, loudly, if the kernel
|
||
source is unavailable rather than silently falling back to something weaker.
|
||
A CSPRNG that degrades quietly is worse than no CSPRNG.
|
||
- **Given** a login handler that sets a session cookie and a flash cookie on
|
||
one response, **when** the response is serialized, **then** **two** distinct
|
||
`Set-Cookie` headers reach the wire — the criterion the current
|
||
`map<Text, Text>` cannot satisfy, and the reason this iteration touches
|
||
`Resp`.
|
||
- **Given** a request carrying a `Cookie:` header with several pairs, quoted
|
||
values and stray whitespace, **when** it is parsed, **then** each value is
|
||
recovered exactly, and a malformed header is a 400 rather than a silent
|
||
partial parse.
|
||
- **Given** a session cookie whose HMAC is altered by one bit, **when** the
|
||
session middleware reads it, **then** the session is rejected in constant
|
||
time (`ct_eq`, which already exists) and the request proceeds unauthenticated
|
||
— never as a *different* user.
|
||
- **Given** the limiter configured to N requests per window, **when** a client
|
||
exceeds it, **then** it receives 429 with the rate-limit headers set, the
|
||
window expires on `time.ticks`, and the counters **survive a restart** — the
|
||
durability the `@table` store buys, proven by a restart in the gate.
|
||
- **Given** a CSRF-protected form flow, **when** a request arrives with a
|
||
missing, stale or foreign-origin token, **then** each is refused distinctly;
|
||
**when** the token is valid, the request proceeds. Single-use tokens are not
|
||
double-spendable.
|
||
- **Given** the same POST replayed with an identical idempotency key, **when**
|
||
it is handled, **then** the stored response is returned and the handler does
|
||
not run twice — proven by a side effect that would be visible if it had.
|
||
- **Given** a route registered with each new method helper and a per-route body
|
||
limit, **when** the matrix runs, **then** methods dispatch correctly, an
|
||
oversized body is refused per-route rather than per-server, and every standing
|
||
gate (`just web-app`, `just site`, `oop-accept`) is unchanged.
|
||
|
||
## Out Of Scope
|
||
|
||
Every item below is a real Fiber feature and a real writeonce gap. Each is
|
||
excluded because someone else owns it — the study's §7 is the full map.
|
||
|
||
- **Streaming, SSE, compression, byte-range requests** — all four sit on one
|
||
missing seam: `internal/serve.wo` builds a whole response as one `Text` and
|
||
`serialize()` always emits `Content-Length`. The framework README already
|
||
parks "lazy body streaming + backpressure · streaming responses · explicit
|
||
commit point"; these belong to that slice. Note `internal/parse.wo:153-157`
|
||
**deliberately refuses** chunked request bodies with a request-smuggling note
|
||
— that refusal is correct and must survive whoever implements chunked.
|
||
- **Typed binding of query / params / form / headers into a class** — Fiber's
|
||
`Bind` reflects over struct tags; principle 13 forbids reflection, so the
|
||
answer is compile-time generation:
|
||
[iteration 29's `@derive`](29-compile-time-metaprogramming.md). Recorded, not
|
||
attempted. Same for XML/CBOR/MsgPack codecs — JSON only is the small stdlib
|
||
working as intended.
|
||
- **TTL cache middleware** — [iteration 18](18-memory-db-features.md) owns it,
|
||
spec already approved.
|
||
- **`proxy` middleware** — needs an outbound socket, which does not exist:
|
||
[iteration 38](38-content-platform-capabilities.md).
|
||
- **`pprof` / `expvar` / metrics / stack traces on trap** — iteration 30
|
||
(observability, CI, fuzz — still no story file).
|
||
- **Everything the study's §6 lists as a deliberate divergence**: a runtime
|
||
template engine (markup is a compile-time literal or it does not exist), TLS
|
||
and HTTP/2 (proxy-terminated by doctrine), `net/http` interop (no FFI),
|
||
prefork and buffer-size knobs (the shard runtime owns placement), closures as
|
||
handlers (a handler is a class; its fields are the closure substitute). These
|
||
are settled — the study lists them so nobody re-opens them as "missing".
|
||
- **A radix-tree router.** Path matching is a linear scan, already 🔶 in the
|
||
ledger pending a *measurement*. Iteration 22 built the harness but benched the
|
||
database, not the router. Still waiting on a number, not on this iteration.
|
||
|
||
## Info
|
||
|
||
Fiber v3.5.0, `.dev/reference/fiber` (gitignored; the study carries the clone
|
||
command). Of its 32 middleware packages, **nine already have a working
|
||
`writeonce-serve` counterpart** — CORS, basic auth, key/bearer auth,
|
||
helmet-style security headers, ETag, static files, logger, host authorization,
|
||
and recover-as-500. The framework is further along than its size suggests; the
|
||
gaps are breadth, not foundations, with the single exception of randomness.
|
||
|
||
Dependency order, which is the whole point of the iteration:
|
||
|
||
```
|
||
random bytes ──▶ cookies ──▶ sessions ──▶ CSRF
|
||
│ │
|
||
│ └──▶ (signed cookies, JWT HS256 issuing)
|
||
└──▶ request-id
|
||
|
||
limiter, idempotency ──▶ need only @table + time.ticks (start here)
|
||
```
|
||
|
||
Forks the spec must settle:
|
||
|
||
1. **What replaces `Resp.headers: map<Text, Text>`?** Options: a
|
||
`multi Text` of raw header lines beside the map; a dedicated
|
||
`cookies: multi Cookie` field on `Resp` that `serialize()` renders; or a
|
||
general repeated-header list. The first is smallest, the second is the most
|
||
typed, the third is the most honest about HTTP. This fork decides how much
|
||
of the framework's public surface moves, so it comes first.
|
||
2. **What shape is the random builtin?** `random_bytes(n) -> Bytes` is the
|
||
obvious one and composes with everything iteration 19 and 34 added. Decide
|
||
whether a convenience `random_hex`/`random_id` rides along or whether
|
||
`base64_encode` is enough, and decide the failure mode when the kernel source
|
||
is unavailable — the criterion above says refuse, not degrade.
|
||
3. **Is there a `Store` interface, or does each middleware own its `@table`?**
|
||
Fiber abstracts `Storage` so the same middleware runs on memory or Redis.
|
||
writeonce has one store, and interfaces are structural — an abstraction with
|
||
exactly one implementor is decoration (iteration 37 learned this the hard way
|
||
about `Component`). Leaning: concrete `@table` per middleware until a second
|
||
backend actually exists.
|
||
4. **Where does session state live — cookie or table?** A signed cookie
|
||
carrying the whole payload needs no store and cannot be revoked; a table row
|
||
keyed by a random id can be revoked and costs a lookup. Leaning: table, since
|
||
`@table` is the language's whole thesis and revocation is not optional for a
|
||
real login.
|
||
5. **Does the request-id middleware trust an inbound header?** Fiber's
|
||
`requestid` accepts one by default. Behind a trusted proxy that is what you
|
||
want; on an open port it lets a client forge correlation ids. `client_ip` and
|
||
`net.peer` already exist for the trust decision — reuse that judgement rather
|
||
than inventing a second one.
|
||
|
||
## Proposed Solution
|
||
|
||
Wants a spec: fork 1 changes public framework types, and fork 2 adds a runtime
|
||
builtin. Order follows the dependency chain above, and the first slice is
|
||
deliberately the *cheapest* rather than the most foundational — limiter and
|
||
idempotency need nothing new, so they prove the store pattern and the gate shape
|
||
before the risky work starts.
|
||
|
||
Then: the random builtin (runtime, with its own corpus fixtures), cookies (the
|
||
`Resp` decision plus parse and serialize), sessions, CSRF, and the routing and
|
||
response sugar last since it is independent of everything else. Each slice ends
|
||
green on `just web-app` and `just site`, and the closing act corrects the
|
||
framework README's crypto row and re-points every ledger row this study touched
|
||
at its actual owner.
|
||
|
||
Off-chain and independent of the concurrency chain, with one caveat worth
|
||
stating: the SSE work this study defers is a natural companion to
|
||
[iteration 24's](24-chat-websocket-workload.md) chat sample, since a room actor
|
||
already has the fan-out shape. If 24 lands first, SSE gets cheaper.
|