- docs/stories/porch/ — a TRACK folder, not a status folder: status still
lives only in frontmatter. Adds `track: porch` so a query over
docs/stories/ can tell a porch 3 from a language 3
- 00-story.md carries the sequence, the dependency graph, and a table of
what the track explicitly does NOT own (binding -> 29, cache -> 18,
proxy -> 38, metrics -> 30, TLS/templates -> doctrine)
- eight iterations, each with phases, per-phase tasks, Given/When/Then
criteria, out-of-scope and the forks a spec must settle:
1 store-backed middleware (limiter + idempotency — needs nothing new,
first on purpose so the store pattern is proven cheaply)
2 randomness + cookies (phase A is language-track: a CSPRNG builtin;
`Resp.headers` being a map cannot emit two Set-Cookie lines)
3 sessions 4 CSRF 5 routing/response ergonomics (independent)
6 streaming core (the seam 7 and 8 wait on; chunked-request refusal
must survive) 7 SSE + compression 8 static + lifecycle hooks
- language iteration 39 -> status: hold, retitled superseded, with a row
mapping each of its goals to the porch iteration that took it. Kept, not
deleted: the Fiber study cites it and its randomness argument is what
this track is built on
- board gains a porch section; board-views gains porch and both-track
Dataview queries; porch README and the Fiber study §7 point at the track
- no code blocks in any story (plans carry concept and actions in words);
linkcheck 0 broken / 0 anchors
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9 KiB
| track | iteration | status |
|---|---|---|
| porch | 2 | refine |
porch 2 — randomness and cookies: the foundation three iterations stand on
Part of Story —
porch, the writeonce web framework. Source: the Fiber parity study §0–§1.The study's sharpest finding lives here. porch's own README claimed signed cookies, CSRF and session integrity were "UNBLOCKED — the primitives exist since iteration 34". For CSRF and sessions that is wrong: SHA-256 and HMAC let a program authenticate a token, they cannot mint one, and writeonce has no source of randomness anywhere. An HMAC over a guessable session id is a signed guess. Phase A closes that before anything depends on it.
Goals
- A CSPRNG builtin in the runtime. No
getrandom, no/dev/urandomread, no CSPRNG builtin exists today — grep the runtime and nothing comes back. This is the one phase of this track that is language-track work (C, a new builtin id, a corpus fixture); it is here because porch is what needs it and splitting it across two tracks would hide the dependency. - Repeated response headers, which
Respstructurally cannot express.Resp.headersis amap<Text, Text>. A login response setting a session cookie and a flash cookie needs twoSet-Cookielines and the map can hold one. This is a change to porch's most public type, and deciding its shape is the real work of this iteration — the cookie formatting is the easy half. - Cookies in both directions. Parse a
Cookie:request header into something typed; build aSet-Cookiewith the attributes that matter for security —HttpOnly,Secure,SameSite,Max-Age,Path,Domain. - Signed cookies. HMAC-SHA256 over the value with constant-time comparison
on the way back (
hmac_sha256andct_eqboth already exist). This is the half that genuinely was unblocked by iteration 34, and it is what makes a cookie tamper-evident without a server-side lookup. - Correct the ledger row that started this. The README's crypto line, and anything else claiming the sessions/CSRF path was already open.
Phases
Phase A — the runtime primitive (language-track work)
- Add a random-bytes builtin at the next free id (89 and 90 are reserved holes
for language iteration 31's
monitorandtime.after, so this starts at 96). - Source it from the kernel. Decide the failure mode explicitly: if the source is unavailable the builtin refuses, loudly. A CSPRNG that quietly degrades to something weaker is worse than none, because every layer above it will assume it worked.
- Corpus fixtures: the builtin's arity and type contract, and the refusal path. Statistical quality is not a corpus concern — the kernel's guarantee is the guarantee.
- Document it in the builtin-surface contract and the error catalog if it adds a diagnostic, in the same change. Both of those went stale once before by not doing this.
- Verify:
oop-e2egreen; the value differs across separate processes and is not derivable from the clock.
Phase B — repeated response headers
- Settle fork 1 and change
Resp. Every response builder (ok_text/ok_json/ok_html/created_json/not_found/…) andserialize()ininternal/serve.womove with it. - Keep the single-value path ergonomic: the overwhelmingly common case is one value per header, and it must not get worse to write.
- Verify:
serialize()emits two distinctSet-Cookielines for one response; every existing header behaviour is byte-identical, proven by both serving gates passing unchanged.
Phase C — reading request cookies
- Parse the
Cookie:header: multiple pairs, quoted values, stray whitespace, and duplicate names. - Decide where parsed cookies live — a lazily-parsed field on
Req, or a helper the handler calls.Reqalready carries actxbag and aparamsmap, so the precedent exists either way. - A malformed header is a 400, not a silent partial parse. The parser's
existing discipline around duplicate
Content-Lengthis the model. - Verify: values recovered exactly across the awkward cases above; malformed input refused.
Phase D — writing and signing cookies
- A
Set-Cookiebuilder covering the attribute set, withHttpOnlyandSameSitedefaulted to the safe choice rather than the permissive one — a cookie API whose defaults are insecure is a footgun that ships. - Sign with
hmac_sha256, verify withct_eq. Decide the encoding (base64is already available) and the payload framing so a value containing the delimiter cannot forge a signature. - Clear-cookie support, which is its own case: the attributes must match or the browser keeps the old one.
- Verify: a one-bit change to value or signature is rejected; a valid cookie round-trips; a cleared cookie is actually gone.
Phase E — prove it and correct the record
- A login/logout flow in a sample exercising two cookies on one response, using the signed-cookie path only — no sessions yet, that is iteration 3.
- Correct porch's README crypto row and re-point the ledger rows this iteration touched.
- Verify:
just web-app,just site,oop-accept,just linkcheckall green.
Acceptance Criteria
- Given the random builtin, when many values are drawn across separate processes, then none repeats and none is derivable from the clock; and when the kernel source is unavailable, the builtin refuses loudly rather than returning weak bytes.
- Given a handler setting a session cookie and a flash cookie on one
response, when it is serialized, then two distinct
Set-Cookieheaders reach the wire. This is the criterion today'smap<Text, Text>provably cannot satisfy. - Given every pre-existing response shape, when both serving gates run,
then output is byte-identical to before phase B — the
Respchange is additive or it is wrong. - Given 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 yields 400, never a partial parse. - Given a signed cookie altered by one bit in either the value or the signature, when it is verified, then it is rejected in constant time.
- Given a signed value that itself contains the payload delimiter, when it round-trips, then it cannot be re-framed to forge a valid signature.
- Given cookie defaults, when a cookie is created without explicit
attributes, then
HttpOnlyis on andSameSiteis notNone.
Out Of Scope
- Encrypted cookies. Fiber's
encryptcookieneeds a symmetric cipher, and the runtime has digests only. Signed-and-readable is honest and sufficient for a session id; encrypting a payload is a separate ask with a separate primitive behind it. - Server-side session state — iteration 3. This iteration stops at a signed cookie carrying a value the app chose.
- CSRF — iteration 4, which needs both this and 3.
- JWT. Verification is already possible with
hmac_sha256; issuing needs phase A. Either way it is a library slice with a hard stop at HS256 — no RS256, no JOSE — and not this iteration. - Cookie-based cache keys. Fiber's cache middleware has
KeyCookies; the cache belongs to language iteration 18.
Info
Forks the spec must settle, in order of how much they move:
- What replaces
Resp.headers: map<Text, Text>? Three candidate shapes: amulti Textof raw extra header lines beside the existing map; a dedicated typedcookiesfield onRespthatserialize()renders; or a general repeated-header list replacing the map. The first is the smallest change, the second the most typed, the third the most honest about HTTP — and the third touches every builder and both consumers. This fork decides the size of the whole iteration, so settle it first. - What shape is the builtin? A bytes-returning primitive composes with
everything iterations 19 and 34 added (
Bytes,base64_encode,hmac_sha256), which argues for exactly one function and no convenience wrappers. Decide whether a hex/id helper rides along or whetherbase64_encodeis enough. - Where do parsed cookies live? A field on
Reqparsed eagerly costs every request that has no cookies; a helper parsed on demand costs nothing but is easy to call twice.Reqis atypedefrecord, so adding a field is cheap — the cost is the eager parse, not the shape. - Signed-cookie framing. Value-then-signature with a delimiter is the obvious encoding and the obvious place to get it wrong. Decide the framing so that a value containing the delimiter cannot shift the boundary.
Phase A is the only part of this track that is language-track work. It is
sequenced here rather than filed as a language iteration because nothing else
wants it yet, and a primitive with no consumer is how the Component interface
became decoration.