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

171 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
track: porch
iteration: "2"
status: pending
readiness: refine
---
# porch 2 — randomness and cookies: the foundation three iterations stand on
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §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](03-sessions.md). This iteration
stops at a signed cookie carrying a value the app chose.
- **CSRF** — iteration [4](04-csrf.md), 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](../language-runtime-database/18-memory-db-features.md).
## 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.