docs(porch-store): correct the one-slot-pool advice, soften a gate-proof claim

- bullet 2 wrongly told app authors to hold a bare actor handle and
  re-wrap it as a forced ONE-slot Pool per connection -- that was my
  own advice, not the previous implementer's, and the reviewer showed
  WO-E222 fires on the class Pool, not on multi PoolSlot
- rewritten around the new pool_slots/pool_of pair: make_pool(n) once
  at process start, multi PoolSlot held directly in connection-actor
  state, a transient Pool rebuilt per use -- and states explicitly that
  calling make_pool per connection restores the lost-increment race
- disclose that the gate's own ConnWorker fixtures still build a
  deliberate one-slot Pool per leg, so no leg yet exercises real
  N-actor sharding through pool_slots/pool_of
- soften the rate-limiting row: saturation-503 is gate-proven only via
  Idempotent/pool_begin, not through Limiter's own try/catch arm

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 6d48dbca98d4ea4c6d93f470ae3aad0b00acd2a1)
This commit is contained in:
shoney.arickathil 2026-08-30 03:18:20 +02:00
parent 263cf61d95
commit 484402a156

View file

@ -152,7 +152,7 @@ first (pure `.wo` cannot express it yet).
| Item | State | | Item | State |
| --- | --- | | --- | --- |
| Rate limiting (fixed window, durable) | ✅ counting serializes through a per-key actor pool (`middleware/keypool.wo`, `limiter.wo`) — no handler-fiber read-modify-write left to lose an increment. Gate-proven: exact count under genuine concurrency (30 parallel requests, no lost increments), WAL-durable restart still limiting, `trust_proxy`'s peer fallback, and pool saturation failing closed (503, never a bypass) — [porch 1](../../stories/porch/01-store-backed-middleware.md) | | Rate limiting (fixed window, durable) | ✅ counting serializes through a per-key actor pool (`middleware/keypool.wo`, `limiter.wo`) — no handler-fiber read-modify-write left to lose an increment. Gate-proven: exact count under genuine concurrency (30 parallel requests, no lost increments), WAL-durable restart still limiting, and `trust_proxy`'s peer fallback — [porch 1](../../stories/porch/01-store-backed-middleware.md). Saturation failing closed (503) shares `limiter.wo`'s own `try/catch` code path with idempotency's, but is gate-proven only via `Idempotent`/`pool_begin`'s own saturation leg in `scripts/web-app-accept.sh` — no leg drives `Limiter`'s own 503 arm directly |
| Idempotent replay of unsafe requests | ✅ the pool actor runs the route's `Handler` itself (`middleware/idempotent.wo`), so a duplicate blocks in the actor's mailbox until the owner's row commits — no in-flight heuristic, no window where a duplicate can see "nothing yet". Gate-proven: byte-identical replay, digest-mismatch refusal (422), concurrent duplicates never double-executing, a transient 5xx never replayed (solo or concurrent), ephemeral rows not leaking, and pool saturation failing closed (503) — [porch 1](../../stories/porch/01-store-backed-middleware.md) | | Idempotent replay of unsafe requests | ✅ the pool actor runs the route's `Handler` itself (`middleware/idempotent.wo`), so a duplicate blocks in the actor's mailbox until the owner's row commits — no in-flight heuristic, no window where a duplicate can see "nothing yet". Gate-proven: byte-identical replay, digest-mismatch refusal (422), concurrent duplicates never double-executing, a transient 5xx never replayed (solo or concurrent), ephemeral rows not leaking, and pool saturation failing closed (503) — [porch 1](../../stories/porch/01-store-backed-middleware.md) |
| Transaction-per-request middleware (commit on 2xx, roll back otherwise) | ⏸ **v2** — needs iteration 18's `transaction { }` | | Transaction-per-request middleware (commit on 2xx, roll back otherwise) | ⏸ **v2** — needs iteration 18's `transaction { }` |
| Cancellation → rollback | ⏸ arc landed; still needs v2's `transaction { }` (iteration 18) | | Cancellation → rollback | ⏸ arc landed; still needs v2's `transaction { }` (iteration 18) |
@ -169,25 +169,41 @@ needs to know, found in the course of building them ([porch 1](../../stories/por
not via `app.use_mw`. This was forced, not stylistic: the actor has to be not via `app.use_mw`. This was forced, not stylistic: the actor has to be
handed the route's `Handler` so it can run it inside `receive`, and only the handed the route's `Handler` so it can run it inside `receive`, and only the
handler slot exposes it. handler slot exposes it.
- **`Pool` cannot live in actor state or in a message.** It is demand-promoted - **`Pool` cannot live in actor state or in a message — `multi PoolSlot` can,
to "traced" and WO-E222 refuses it there. A real fiber-per-connection porch and that's how a real app shards across MORE than one connection.** `Pool`
app holds the bare `actor PoolMsg` handle in its connection-worker state and is demand-promoted to "traced" the moment an app aliases it (a
re-wraps it as `Pool { actors: [PoolSlot { a: handle }] }` wherever a `Limiter`/`Idempotent`'s own `pool: Pool` field, read on every request) and
`Limiter` or `Idempotent` needs one — see `ConnWorker` in the accept gate's WO-E222 refuses a traced value in actor state or a message. `PoolSlot` (and
own limiter/idempotent/saturation checks (`scripts/web-app-accept.sh`). `multi PoolSlot`) never gets pulled into that traced set on its own — an
actor holding `slots: multi PoolSlot` directly is the same shape
`docs/examples/chat/main.wo`'s `Room { members: multi Mem }` already uses
for a multi of actor handles, and it compiles and runs. Call `make_pool(n)`
**exactly ONCE, at process start** — never per connection, which would give
every connection its own actors and silently restore the lost-increment
race this whole design exists to prevent — then hand `pool_slots(pool)`
(`middleware/keypool.wo`) to every connection actor's spawn. Each
connection rebuilds a transient `Pool` via `pool_of(self.slots)` wherever a
`Limiter` or `Idempotent` needs one. Disclosure: the accept gate's own
`ConnWorker` fixtures (`scripts/web-app-accept.sh`) still build a
deliberately ONE-slot `Pool { actors: [PoolSlot { a: slot }] }` per leg —
the limiter/idempotent legs are testing other properties, and the
saturation leg wants exactly one actor to force mailbox overflow — so no
gate leg yet exercises `pool_slots`/`pool_of` sharding N actors across
connections.
- **A `call` reply is a copyable scalar only (WO-E226), and every `receive` in - **A `call` reply is a copyable scalar only (WO-E226), and every `receive` in
the program must agree on one return type.** That is why the stored response the program must agree on one return type.** That is why the stored response
travels through the `@table` rather than the mailbox, and why outcome codes travels through the `@table` rather than the mailbox, and why outcome codes
are packed into an `Int` (`pool_pack`/`pool_count`/`pool_begin` in are packed into an `Int` (`pool_pack`/`pool_count`/`pool_begin` in
`middleware/keypool.wo`). `middleware/keypool.wo`).
- **Pool size is a capacity decision, not a default to ignore.** `make_pool(n)` - **Pool size is a capacity decision made ONCE, not a default to ignore or a
spawns `n` actors, sharded by hash of the key; a hot key's actor has a knob to re-tune per connection.** `make_pool(n)` — called once, per the
bounded mailbox (`WO_MAILBOX`, default 1024), and once it saturates under bullet above — spawns `n` actors, sharded by hash of the key; a hot key's
load every further request for that key answers 503 rather than being actor has a bounded mailbox (`WO_MAILBOX`, default 1024), and once it
served uncounted or queued indefinitely. Undersizing the pool produces more saturates under load every further request for that key answers 503 rather
503s under load — it does not silently let requests through uncounted, and than being served uncounted or queued indefinitely. Undersizing `n`
it does not silently overshoot the limiter's or idempotency store's produces more 503s under load — it does not silently let requests through
guarantees. uncounted, and it does not silently overshoot the limiter's or idempotency
store's guarantees.
### Security ### Security