writeonce/docs/stories/porch/02-randomness-and-cookies.md
shoney.arickathil 1fe808b7a4 docs(stories): add readiness, retire status: refine, sweep all 47 iterations
- `readiness: ready | refine` is a SECOND axis, orthogonal to status.
  `ready` = the brainstorm is complete and the decisions are LOCKED (a spec
  approved, or the forks explicitly confirmed). `refine` = open forks remain
  and it cannot be planned yet
- `status: refine` RETIRED because it carried both meanings at once, so a held
  iteration with an approved spec (language 18, 26) was indistinguishable from
  one nobody had thought about. status is now purely where the WORK is:
  done | in-progress | pending | hold — `pending` was already the board's own
  rendering word, so nothing new was invented
- all 47 iterations classified from EVIDENCE in their own text, not by guess:
  "the four forks are SETTLED" / "spec + plan approved" / "Approved spec:" for
  ready; "Forks the spec must settle" / "no spec exists yet" for refine. Every
  shipped iteration is ready by definition. 19 done, 5 in-progress, 15
  pending, 8 hold; 27 ready, 20 refine
- two iterations moved refine -> in-progress rather than -> pending: language
  31 and 34 are absorbed into 24 and work on them is literally happening, which
  the board already showed as 🔄 while their frontmatter said otherwise. That
  disagreement is now gone
- board legend, board-views' frontmatter contract, and two new Dataview
  queries updated — the useful one being `readiness: ready AND status:
  pending`, the startable set

WHAT THE NEW AXIS IMMEDIATELY SURFACED: of 15 pending iterations, exactly ONE
is startable — databasev2 4, io_uring group-commit, whose forks were confirmed
settled 2026-08-20. Everything else pending needs a brainstorm first. That was
invisible while one key carried both meanings, and it is now on the board.

Also caught by the sweep, unrelated to readiness but found by cross-checking
frontmatter against the board: SIX duplicate rows. Every iteration moved into
databasev2 was still listed in the LANGUAGE pending table under its retired id
(23, 32, 33, 20, 21, 27) as well as its new one. Stale copies removed. And two
databasev2 rows made claims the sweep contradicts — iteration 1 was billed
"startable today" while its forks are open, and 6 still called itself the
ceiling-raiser after 2 took that role.

Docs only. linkcheck 0 broken / 0 anchors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:54:45 +02:00

9.1 KiB
Raw Blame History

track iteration status readiness
porch 2 pending 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/urandom read, 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 Resp structurally cannot express. Resp.headers is a map<Text, Text>. A login response setting a session cookie and a flash cookie needs two Set-Cookie lines 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 a Set-Cookie with 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_sha256 and ct_eq both 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 monitor and time.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-e2e green; 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/…) and serialize() in internal/serve.wo move 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 distinct Set-Cookie lines 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. Req already carries a ctx bag and a params map, 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-Length is the model.
  • Verify: values recovered exactly across the awkward cases above; malformed input refused.

Phase D — writing and signing cookies

  • A Set-Cookie builder covering the attribute set, with HttpOnly and SameSite defaulted 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 with ct_eq. Decide the encoding (base64 is 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 linkcheck all 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-Cookie headers reach the wire. This is the criterion today's map<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 Resp change 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 HttpOnly is on and SameSite is not None.

Out Of Scope

  • Encrypted cookies. Fiber's encryptcookie needs 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:

  1. What replaces Resp.headers: map<Text, Text>? Three candidate shapes: a multi Text of raw extra header lines beside the existing map; a dedicated typed cookies field on Resp that serialize() 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.
  2. 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 whether base64_encode is enough.
  3. Where do parsed cookies live? A field on Req parsed eagerly costs every request that has no cookies; a helper parsed on demand costs nothing but is easy to call twice. Req is a typedef record, so adding a field is cheap — the cost is the eager parse, not the shape.
  4. 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.