diff --git a/docs/00-status.md b/docs/00-status.md
index c5d28c0..963cb40 100644
--- a/docs/00-status.md
+++ b/docs/00-status.md
@@ -19,41 +19,25 @@ Statuses: โ
**done** ยท ๐ **in progress** ยท โฌ **pending** ยท โธ **hold*
## โถ NEXT PLAN
-**The web framework becomes a first-class library โ iteration 17.** The goal
-shifted 2026-08-20: the previous NEXT PLAN ("make log-watcher executable โ
-nothing else") was met in full 2026-08-15 (milestone record below), and the
-driving workload is now `docs/examples/writeonce-framework` consumed by
-`docs/examples/web-app` through `[deps]`. Iteration 17's forks are settled
-(decisions + framework/compiler/VM/GC impact in
-[the iteration](stories/language-runtime-database/17-library-projects-internal.md));
-what remains, in order:
+**Framework v1 โ a polished micro-framework (routing, middleware,
+`Req`/`Resp`), nothing MVC-scale.** Directive 2026-08-20: iteration 17
+(library kind + `internal/`) is **parked** โ spec + plan approved and ready
+on branch `library-internal` โ and the framework itself is the work. The
+v1-polish slice landed the same day (branch `framework-v1`): registration
+helpers `get/post/put/delete_` (the take-Handler shape, probe-proven),
+405 + `Allow` on wrong-method hits, HEAD served as GET with the body
+suppressed, a `Logging` middleware, `set_header`; `just web-app` grew to
+16/0. En route it exposed and fixed a real emitter bug: a Text-typed
+single-segment interpolation of a place (`"${r.method}"`, `r` a loop
+borrow) crossed `let`/assignment boundaries uncopied โ aliased the field,
+crashed the release build; `copy_place_text` now sees through `Interp`
+exactly as `drop_fresh_text` does, pinned by
+`tests/corpus/run/interp-borrowed-field`.
-1. Merge the `web-framework` branch to master (iterations 15โ17 docs + code,
- local only). Why first: everything iteration 17 edits exists ONLY on that
- branch โ the framework and web-app sources, the `[deps]` resolution code
- in `compiler/bin/main.ml` (17's `internal/` rule lands in that exact
- code), and the `just web-app` regression gate. Starting 17 unmerged means
- stacking a branch on an unreviewed branch; merged, 17 is a small clean
- diff off master.
-2. Spec + plan from the settled decisions (prose only, per convention).
-3. `kind = "library"` in `wo.toml` (default `"program"`): `woc
` on a
- library runs the FULL pipeline as a check โ parse, typecheck, borrow
- check, GC inference โ with no entry required; an explicit build still
- works when a `main` exists (dual lib+bin).
-4. The `internal/` rule at the `[deps]` boundary: a consumer `use` of a dep
- path containing the segment `internal` is a new WO-E1xx at the `use`,
- naming the dependency; inside the dep it stays legal.
-5. Framework reorg: request-parser and serve-loop plumbing move under
- `internal/`; the public surface (`Handler`, `Middleware`, `App`,
- `Req`/`Resp`, builders) does not move.
-6. Gate: `just web-app` stays 14/0, plus new checks โ library check without
- an entry succeeds, a consumer `internal` import is refused, and the
- iteration-16 `--emit` verification workaround is deleted.
-
-The VM and GC are untouched by design โ visibility is compile-time name
-resolution, libraries compile whole-program into the consumer's image, and
-GC inference stays whole-program (app usage may promote dep classes; that is
-intended).
+Next per the implementation order (17 parked): finish the half-done
+database branches โ 9c (ipc-attach: manifest + binding) and 9d
+(keypair-auth: manifest) โ both need their forks brainstormed before
+planning.
---
@@ -167,8 +151,8 @@ that sequences its tasks. Read one, approve, then the next starts.
| 13 | [Compile-time metaprogramming](stories/language-runtime-database/13-compile-time-metaprogramming.md) | โฌ needs a spec first |
| 14 | [skillhost host workload](stories/language-runtime-database/14-skillhost-host-workload.md) | โฌ gaps recorded (branch query-grammar found skillhost needs no new query grammar); each gap a candidate iteration |
| 15 | [deps: `wo.toml [deps]`](stories/language-runtime-database/15-deps-package-manager.md) | โ
**landed 2026-08-18** (branch web-framework): [deps] inline tables, git-binary fetch, wo.lock pinning, offline-when-locked, --update-deps, WO-E106/E107; `just deps-accept` 8/0 |
-| 16 | [web framework](stories/language-runtime-database/16-web-framework.md) | โ
**landed 2026-08-19** โ writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; `just web-app` 14/0; h2c parked (ยงC) behind 8/9f/11 |
-| 17 | [library projects + `internal/`](stories/language-runtime-database/17-library-projects-internal.md) | โฌ **forks settled 2026-08-20, awaiting spec/plan**: kind = "library" manifest key; Go internal/ rule, dep-boundary-only; lib+bin dual; VM/GC untouched by design (impact analysis in the iteration) |
+| 16 | [web framework](stories/language-runtime-database/16-web-framework.md) | โ
**landed 2026-08-19** โ writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; h2c parked (ยงC) behind 8/9f/11. **v1 polish landed 2026-08-20** (branch framework-v1): get/post/put/delete_ helpers, 405+Allow, HEAD, Logging middleware, set_header; `just web-app` 16/0; fixed the interp-borrowed-field emitter crash en route |
+| 17 | [library projects + `internal/`](stories/language-runtime-database/17-library-projects-internal.md) | โธ **PARKED 2026-08-20** (developer directive; framework v1 first) โ forks settled, spec + plan approved and ready on branch `library-internal`: kind = "library" key; Go internal/ rule, dep-boundary-only; lib+bin dual; VM/GC untouched by design |
---
@@ -176,10 +160,11 @@ that sequences its tasks. Read one, approve, then the next starts.
| Track | Item | Where |
| -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- |
-| Language | Iteration 17 โ web framework as first-class library: spec/plan from the settled forks, then `kind = "library"` + `internal/` + framework reorg | [iteration 17](stories/language-runtime-database/17-library-projects-internal.md) |
+| Language | nothing active โ the framework v1-polish slice landed 2026-08-20 (branch framework-v1, awaiting merge); next per the order: brainstorm 9c/9d's forks | [order](#implementation-order-re-sequenced-2026-08-20--framework-goal) |
-Off-goal work is parked; the goal shifted 2026-08-20 from log-watcher (met)
-to the web framework as a first-class library.
+Off-goal work is parked; the goal (2026-08-20) is the web framework as a
+polished micro-framework v1 โ iteration 17 (library kind + `internal/`) is
+parked with its spec + plan ready on branch `library-internal`.
### Landed 2026-08-14 โ the compile-and-run milestone
@@ -353,9 +338,10 @@ parked behind 8/9f/11; the post-12 parked list stays parked by the
2026-08-08 scope directive. (Iteration 7 dropped from this list โ landed
2026-08-15.)
-1. **17** โ THE goal slice: `kind = "library"` + `internal/` + framework
- reorg; forks settled, compiler-driver-only, no runtime deps. Merge the
- `web-framework` branch first.
+1. ~~**17**~~ โ **PARKED 2026-08-20** (developer directive: framework v1
+ first); spec + plan approved, ready on branch `library-internal` for
+ whenever it unparks. The framework v1-polish slice took its place and
+ landed the same day (branch framework-v1, `just web-app` 16/0).
2. **9c then 9d** โ finish the half-done branches (ipc-attach: manifest +
binding; keypair-auth: manifest) before they rot; 9d folds into 9c's
plan; both must precede 10.
diff --git a/docs/examples/web-app/main.wo b/docs/examples/web-app/main.wo
index b0d349a..082f950 100644
--- a/docs/examples/web-app/main.wo
+++ b/docs/examples/web-app/main.wo
@@ -108,10 +108,10 @@ fn main(args: multi Text) -> Int {
let app = App { middleware: [], routes: [] };
app.use_mw(Mw { m: Auth { token: token } });
- app.add(Route { method: "GET", pattern: "/products", h: ListProducts { pad: 0 } });
- app.add(Route { method: "GET", pattern: "/products/:name", h: ShowProduct { pad: 0 } });
- app.add(Route { method: "POST", pattern: "/products", h: CreateProduct { pad: 0 } });
- app.add(Route { method: "POST", pattern: "/orders", h: CreateOrder { pad: 0 } });
- app.add(Route { method: "DELETE", pattern: "/products/:name", h: DeleteProduct { pad: 0 } });
+ 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 });
return app.serve("127.0.0.1", port);
}
diff --git a/docs/examples/writeonce-framework/README.md b/docs/examples/writeonce-framework/README.md
index 526e97c..a8f014c 100644
--- a/docs/examples/writeonce-framework/README.md
+++ b/docs/examples/writeonce-framework/README.md
@@ -20,7 +20,16 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework",
other client, so closing is the correct shape until shards/fibers (8/11).
A proxy in front simply reconnects.
- **Router** (`router/`): method + path table with `:param` captures into
- `req.params`; first match wins; no match is the framework's 404.
+ `req.params`; first match wins; a known path with the wrong method is
+ **405 with the `Allow` header** (registration order); no matching path is
+ the framework's 404. **HEAD is served free**: routed as GET, body
+ suppressed, `Content-Length` still names the body a GET would carry.
+- **Registration helpers**: `app.get/post/put/delete_(pattern, handler)`
+ push the route for you (`delete_` because `delete` is the query
+ keyword); `app.add(Route { ... })` stays for anything else. A
+ request-line `Logging` middleware ships in `router/`, and
+ `set_header(resp, name, value)` is the escape hatch for headers the
+ builders don't set.
- **Handlers without closures**: the language has no function values by
doctrine, so a route handler is a class satisfying the `Handler` interface
(`fn handle(req: Req) -> Resp`), dispatched structurally โ a
diff --git a/docs/examples/writeonce-framework/app.wo b/docs/examples/writeonce-framework/app.wo
index 72106c5..2abb0cc 100644
--- a/docs/examples/writeonce-framework/app.wo
+++ b/docs/examples/writeonce-framework/app.wo
@@ -25,22 +25,44 @@ pub class App {
push(self.routes, r);
}
+ -- Registration helpers โ the ctor-literal-into-take shape, per method.
+ fn get(pattern: Text, take h: Handler) {
+ push(self.routes, Route { method: "GET", pattern: pattern, h: h });
+ }
+
+ fn post(pattern: Text, take h: Handler) {
+ push(self.routes, Route { method: "POST", pattern: pattern, h: h });
+ }
+
+ fn put(pattern: Text, take h: Handler) {
+ push(self.routes, Route { method: "PUT", pattern: pattern, h: h });
+ }
+
+ fn delete_(pattern: Text, take h: Handler) {
+ push(self.routes, Route { method: "DELETE", pattern: pattern, h: h });
+ }
+
fn dispatch(mut req: Req) -> Resp {
for mw in self.middleware {
let short = mw.m.before(req);
if short != nil { return short; }
}
+ -- Path-first matching so a wrong-method hit on a known path answers
+ -- 405 with the Allow header (registration order) instead of a 404.
+ let allow = "";
for r in self.routes {
- if r.method == req.method {
- let params: map = {};
- if route_match(r.pattern, req.path, params) {
+ let params: map = {};
+ if route_match(r.pattern, req.path, params) {
+ if r.method == req.method {
-- captures land on the borrowed request itself (mut) โ a failed
-- match above never touched it, since captures collect locally
for k, v in params { req.params[k] = v; }
return r.h.handle(req);
}
+ if allow == "" { allow = "${r.method}"; } else { allow = "${allow}, ${r.method}"; }
}
}
+ if allow != "" { return method_not_allowed(allow); }
return not_found();
}
diff --git a/docs/examples/writeonce-framework/http/serve.wo b/docs/examples/writeonce-framework/http/serve.wo
index a900805..267d93b 100644
--- a/docs/examples/writeonce-framework/http/serve.wo
+++ b/docs/examples/writeonce-framework/http/serve.wo
@@ -20,13 +20,16 @@ fn status_text(code: Int) -> Text {
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 404: return "Not Found";
+ case 405: return "Method Not Allowed";
case 409: return "Conflict";
case 500: return "Internal Server Error";
default: return "Status";
}
}
-pub fn serialize(resp: Resp, keep: Bool) -> Text {
+-- head_only: HEAD answers โ full status line + headers (Content-Length of
+-- the body a GET would have sent) with the body itself suppressed.
+pub fn serialize(resp: Resp, keep: Bool, head_only: Bool) -> Text {
let head = "HTTP/1.1 ${resp.status} ${status_text(resp.status)}\r\n";
for k, v in resp.headers {
head = head .. "${k}: ${v}\r\n";
@@ -37,6 +40,7 @@ pub fn serialize(resp: Resp, keep: Bool) -> Text {
head = head .. "Connection: close\r\n";
}
head = head .. "Content-Length: ${len(resp.body)}\r\n\r\n";
+ if head_only { return head; }
return head .. resp.body;
}
@@ -54,13 +58,18 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int {
if p == nil { alive = false; continue; }
if p.closed { alive = false; continue; }
if p.ok == false {
- try net.write(c, serialize(bad_request("malformed request"), false)) catch (e) {}
+ try net.write(c, serialize(bad_request("malformed request"), false, false)) catch (e) {}
alive = false;
continue;
}
let r = p.req;
if r == nil { alive = false; continue; }
carry = p.rest;
+ -- HEAD is GET with the body suppressed: route and dispatch as GET,
+ -- serialize with head_only so Content-Length still names the body a
+ -- GET would have carried (RFC 9110 ยง9.3.2).
+ let is_head = r.method == "HEAD";
+ if is_head { r.method = "GET"; }
-- Connection policy for a single-threaded server: serve PIPELINED
-- requests on one connection (bytes already buffered), but close when
-- the client would idle โ a parked keep-alive connection would block
@@ -72,7 +81,7 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int {
if to_lower(conn) == "close" { keep = false; }
}
let resp = try d.dispatch(r) catch (e) server_error();
- try net.write(c, serialize(resp, keep)) catch (e) { alive = false; }
+ try net.write(c, serialize(resp, keep, is_head)) catch (e) { alive = false; }
if keep == false { alive = false; }
}
net.close(c);
diff --git a/docs/examples/writeonce-framework/http/types.wo b/docs/examples/writeonce-framework/http/types.wo
index 985c7ef..cb8afe0 100644
--- a/docs/examples/writeonce-framework/http/types.wo
+++ b/docs/examples/writeonce-framework/http/types.wo
@@ -61,6 +61,13 @@ pub fn conflict(msg: Text) -> Resp {
return Resp { status: 409, headers: h, body: "{\"error\":\"${msg}\"}" };
}
+pub fn method_not_allowed(allow: Text) -> Resp {
+ let h: map = {};
+ h["content-type"] = "application/json";
+ h["allow"] = allow;
+ return Resp { status: 405, headers: h, body: "{\"error\":\"method not allowed\"}" };
+}
+
pub fn server_error() -> Resp {
let h: map = {};
h["content-type"] = "application/json";
@@ -72,3 +79,9 @@ pub fn redirect(location: Text) -> Resp {
h["location"] = location;
return Resp { status: 302, headers: h, body: "" };
}
+
+-- Set (or overwrite) one header on a built response โ the escape hatch for
+-- anything the builders don't cover: cache-control, etag, custom headers.
+pub fn set_header(mut r: Resp, name: Text, value: Text) {
+ r.headers[name] = value;
+}
diff --git a/docs/examples/writeonce-framework/router/router.wo b/docs/examples/writeonce-framework/router/router.wo
index 1faa45d..3f6be22 100644
--- a/docs/examples/writeonce-framework/router/router.wo
+++ b/docs/examples/writeonce-framework/router/router.wo
@@ -30,6 +30,17 @@ pub class Mw {
m: Middleware
}
+-- 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).
+pub class Logging {
+ pad: Int
+ fn before(req: Req) -> ?Resp {
+ print_err("${req.method} ${req.path}");
+ return nil;
+ }
+}
+
-- Does `pattern` match `path`? Fills `params` with :name captures.
-- Segments split on '/', empties dropped (so "/a//b" == "/a/b" and the
-- root "/" is the empty segment list). First mismatch wins; a :segment
diff --git a/docs/stories/language-runtime-database/00-story.md b/docs/stories/language-runtime-database/00-story.md
index 6f911f1..76af02a 100644
--- a/docs/stories/language-runtime-database/00-story.md
+++ b/docs/stories/language-runtime-database/00-story.md
@@ -90,11 +90,15 @@ list, and a pointer to the plan document that already sequences its tasks.
Iterations 3 โ 4 โ 5 โ 6 โ 7 are the committed line; nothing off that
line lands before iteration 7's acceptance. Then 7b, then 8โ12 (with 9b after the database engine).
- **Goal shifted 2026-08-20: log-watcher (iteration 7, landed 2026-08-15)
- to the web framework as a first-class library.** Implementation order for
- everything still pending: 17 โ 9c/9d โ 9e โ 8 โ 9f โ 11 (+ h2c unparks) โ
- 10 โ 12 โ 14/9g โ 13 + parked drain. Rationale and forcing dependencies
- live in [`docs/00-status.md`](../../00-status.md) under "Implementation
- order".
+ to the web framework.** Same day, refined by directive: the framework
+ stays a polished MICRO-framework (routing, middleware, `Req`/`Resp`) โ
+ nothing MVC-scale โ and iteration 17 (library kind + `internal/`) is
+ **parked** with spec + plan ready on branch `library-internal`. The
+ v1-polish slice landed 2026-08-20 (`just web-app` 16/0). Implementation
+ order for everything still pending: 9c/9d โ 9e โ 8 โ 9f โ 11 (+ h2c
+ unparks) โ 10 โ 12 โ 14/9g โ 13 + parked drain. Rationale and forcing
+ dependencies live in [`docs/00-status.md`](../../00-status.md) under
+ "Implementation order".
- **Iteration 7b (inserted 2026-08-11)** sits after the critical path
deliberately: it delays nothing on the log-watcher line, and it must precede
iteration 8 because the collector should be settled before shards multiply.
diff --git a/docs/stories/language-runtime-database/16-web-framework.md b/docs/stories/language-runtime-database/16-web-framework.md
index c49403b..a43e166 100644
--- a/docs/stories/language-runtime-database/16-web-framework.md
+++ b/docs/stories/language-runtime-database/16-web-framework.md
@@ -13,6 +13,17 @@
> stored in containers now MOVE (`run/container-owned-move`), and a dep's
> internal `use` paths resolve dep-relatively.
>
+> **v1 polish LANDED 2026-08-20** (branch `framework-v1`, developer
+> directive: ship routing/middleware/req-resp as a polished micro-framework,
+> nothing MVC-scale): registration helpers `get/post/put/delete_` (the
+> take-Handler shape, probe-proven โ deviation 1 of the 16 plan retired),
+> 405 + `Allow` on wrong-method path hits, HEAD served as GET with the body
+> suppressed (RFC 9110 ยง9.3.2), a request-line `Logging` middleware,
+> `set_header`; `just web-app` 16/0. It exposed and fixed a third compiler
+> gap: a Text single-segment interpolation of a place crossed
+> `let`/assignment boundaries uncopied โ `copy_place_text` now sees through
+> `Interp` (`run/interp-borrowed-field`).
+>
> The framework is written IN writeonce and imported
> like any dependency (iteration 15 is the prerequisite). TLS terminates at a
> reverse proxy โ browsers get TLS+ALPN+h2 from nginx/caddy while the
diff --git a/docs/stories/language-runtime-database/17-library-projects-internal.md b/docs/stories/language-runtime-database/17-library-projects-internal.md
index 386ecf0..c4baab4 100644
--- a/docs/stories/language-runtime-database/17-library-projects-internal.md
+++ b/docs/stories/language-runtime-database/17-library-projects-internal.md
@@ -7,8 +7,13 @@
> as an iteration, do not implement yet).
>
> **Refined 2026-08-20: all four forks SETTLED** (developer decisions, no code
-> changed). See "Settled decisions" and "Impact analysis" below. Next step is
-> the spec/plan; implementation stays parked until asked.
+> changed). See "Settled decisions" and "Impact analysis" below.
+>
+> **โธ PARKED 2026-08-20** (developer directive: framework v1 work first).
+> The spec and plan were written and approved before parking; both sit ready
+> on branch `library-internal`
+> (`docs/superpowers/specs/2026-08-20-library-kind-internal-design.md`,
+> `docs/superpowers/plans/2026-08-20-library-kind-internal.md`).
## Why this iteration exists
diff --git a/scripts/web-app-accept.sh b/scripts/web-app-accept.sh
index 259599d..6fc1221 100755
--- a/scripts/web-app-accept.sh
+++ b/scripts/web-app-accept.sh
@@ -96,7 +96,57 @@ expect "unknown product is 404" "$(hit GET /products/none)" 404
expect "create order (FK)" "$(hit POST /orders '{"product":"mug","qty":2}')" 201
expect "delete restricted by FK (409), server survives" "$(hit DELETE /products/mug)" 409 "reference"
-# ---- 11. pipelined keep-alive: two requests, one connection ----
+# ---- 11. 405 on a known path with the wrong method (Allow header) ----
+ma="$(timeout 5 python3 - "$PORT" <<'PYEOF'
+import socket, sys
+port = int(sys.argv[1])
+s = socket.create_connection(("127.0.0.1", port), timeout=3)
+s.sendall(b"PUT /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\nconnection: close\r\ncontent-length: 0\r\n\r\n")
+d = b""
+while True:
+ got = s.recv(4000)
+ if not got: break
+ d += got
+head = d.split(b"\r\n\r\n")[0].decode()
+status = head.splitlines()[0].split(" ")[1]
+allow = [l.split(":", 1)[1].strip() for l in head.splitlines() if l.lower().startswith("allow")]
+print(status + "|" + (allow[0] if allow else ""))
+PYEOF
+)"
+[[ "$ma" == "405|GET, POST" ]] && ok "405 + Allow on wrong method" \
+ || bad "405" "got $ma (want 405|GET, POST)"
+
+# ---- 12. HEAD: GET's headers, no body ----
+hd="$(timeout 5 python3 - "$PORT" <<'PYEOF'
+import socket, sys
+port = int(sys.argv[1])
+def raw(req):
+ s = socket.create_connection(("127.0.0.1", port), timeout=3)
+ s.sendall(req)
+ d = b""
+ while True:
+ got = s.recv(4000)
+ if not got: break
+ d += got
+ s.close()
+ return d
+tail = b" /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\nconnection: close\r\ncontent-length: 0\r\n\r\n"
+g = raw(b"GET" + tail)
+h = raw(b"HEAD" + tail)
+ghead, _, gbody = g.partition(b"\r\n\r\n")
+hhead, _, hbody = h.partition(b"\r\n\r\n")
+def cl(head):
+ return [l.split(b":")[1].strip() for l in head.splitlines() if l.lower().startswith(b"content-length")][0]
+status = hhead.decode().splitlines()[0].split(" ")[1]
+print(f"{status}|{(cl(h[:len(hhead)]) == cl(g[:len(ghead)]))}|{len(hbody)}|{len(gbody)}")
+PYEOF
+)"
+IFS='|' read -r hs same hb gb <<<"$hd"
+[[ "$hs" == "200" && "$same" == "True" && "$hb" == "0" && "$gb" != "0" ]] \
+ && ok "HEAD answers GET's Content-Length with no body" \
+ || bad "HEAD" "status=$hs same-length=$same head-body=$hb get-body=$gb"
+
+# ---- 13. pipelined keep-alive: two requests, one connection ----
n="$(timeout 5 python3 - "$PORT" <<'PYEOF'
import socket, sys
port = int(sys.argv[1])
@@ -117,14 +167,14 @@ PYEOF
[[ "$n" == "2" ]] && ok "pipelined keep-alive (2 responses, 1 connection)" \
|| bad "pipelining" "expected 2 responses, got $n"
-# ---- 12. SIGTERM stops it ----
+# ---- 14. SIGTERM stops it ----
kill -TERM "$SRV"
stopped=1
for _ in $(seq 1 30); do kill -0 "$SRV" 2>/dev/null || { stopped=0; break; }; sleep 0.1; done
[[ $stopped -eq 0 ]] && ok "SIGTERM stops the server" || bad "stop" "still running"
SRV=""
-# ---- 13. restart persistence (WAL replay) ----
+# ---- 15. restart persistence (WAL replay) ----
WA_TOKEN=s3cr3t WO_DATA="$DATA" "$W/app/target/web-app" "$PORT" >>"$W/srv.out" 2>&1 &
SRV=$!
sleep 0.5