oficina / home Public

Third review pass: a suite-wide sign-in DoS, and a second factor with a door open

My own diagnosis of the rate limit was wrong, and the truth is worse.
I said RemoteAddr is "" behind the unix socket so the IP budget is
skipped. Measured it: on Linux it is "@". SplitHostPort fails, clientIP
returns "@" unchanged, and Flow skips the budget only for exactly "".
So every request in the world shares the key ip:@ with a budget of 20
per 15 minutes — twenty POSTs to /signin lock out sign-in for the whole
suite for a quarter of an hour, on the service §2.1 already calls the
single point of failure. The carlos edge sets X-Forwarded-For on every
proxied request and strips inbound forwarded headers on unverified
hops, so the header is trustworthy at the app: take the last element,
fall back to RemoteAddr only when it is absent, and never pass "@" as a
limiter key.

The Gate was named only on the magic-link path, so a Keymail-upgraded
account with a passkey enrolled would have signed in with one factor.
In rastrillo both Verify and Callback funnel through one admit; this
plan has two entry points and needed the call twice. The test that
would have caught it — an enrolled person cannot get a session from the
first factor alone — is now Task 6 step 2, written before the wiring.

Task 5 dropped the Keymail upgrade in the step that was supposed to
deliver it: "303 to a check-your-mail page, always" is only the
NextMagicLink branch. On NextKeymail no mail is sent at all — Begin
returns Redirect and Pending, and the handler must set the pending
cookie and 303 there. The cookie's attributes are unexported in auth
and have to be replicated deliberately, because __Host-, Lax, HttpOnly
and cleared-on-read are precisely what the "callback may mint on GET"
argument rests on. The force escape hatch comes with it, or a failed
Keymail approval is a dead end against §5.1's every-address-has-a-path.

Task 6 was a stub with no steps at all — the only task without them,
because it had only ever been a placeholder for the ruling. It now has
files, the passkey.Schema merge Task 2 omitted (Gate INSERTs into
passkey_pending, so without it the first enrolled sign-in fails at the
database), the seven routes, and the confirm page that carries the
ceremony and the recovery form together.

"Nobody has to write it" was overstated twice. webauthn.mjs exports
bare navigator.credentials wrappers and makes no network call, so the
app writes glue. Both are static same-origin files — rastrillo's
default CSP forbids inline script, and ruling 21's carve-out is for
files, not blocks. And the allowlist now constrains what may load
rather than which screens are exempt: same-origin src only, no inline,
an assertion that the served CSP is untouched, and a script-stripped
confirm page whose recovery form still posts.

There is a fourth secret. Flow.Key seals the Keymail pending blob and
sealPending refuses a zero one, so the list Paul provisions is
HOME_DEK, HOME_PEPPER, HOME_SIGNIN_KEY and CARLOS_SMTP_PASS.

Plus the Task 4/5 restructure's own loose ends: the two tasks listed
each other's files, both claimed to produce (*App).links, the signin
dependency arrived a task after the interface that needs it,
sealtest.Token does not exist (Canaries does), three templates had no
renderer named, newTestApp had to wire a non-nil Flow.Keymail or the
classification test passes vacuously, RegisterConnectionHook is
process-global and needs a sync.Once, and signIn must send an Origin
header or csrf refuses every POST in the suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e55bd53564bdd371d029c90d0f2b9ac87c2b8f42 parent 3de521a
1 file changed, +194 −61
  • docs/superpowers/plans/2026-09-03-home-signin.md +194 −61
diff --git a/docs/superpowers/plans/2026-09-03-home-signin.md b/docs/superpowers/plans/2026-09-03-home-signin.md
index 9e234e1..138e290 100644
--- a/docs/superpowers/plans/2026-09-03-home-signin.md
+++ b/docs/superpowers/plans/2026-09-03-home-signin.md
@@ -103,6 +103,28 @@ mid-task.
| `capturedLink(t, app)` | Pulls the magic link out of the captured mail. |
| `signIn(t, app, addr)` | The whole ceremony: POST `/signin`, take the link, GET the confirm page, POST the form. Task 4's tests all go through it. |
| `passkeyConfig(t)` | Returns the `passkey.Config` the app builds, for Task 7's guard. |
+| `loadSecrets(t)` | `HOME_DEK`, `HOME_PEPPER` and `Flow.Key` from the environment, with the boot refusals below. |
+
+**Build these in Task 1 (`newTestApp`, `loadSecrets`) and Task 5
+(`signIn`), each with its own step and its own test.** An earlier draft
+listed them in this table and then had no step create any of them.
+
+**`newTestApp` must wire a non-nil `Flow.Keymail`.** `Begin` skips
+classification entirely when it is nil, so
+`TestFailedClassificationStillSendsAMagicLink` would pass vacuously —
+green, and proving nothing.
+
+**`RegisterConnectionHook` is process-global**, so guard it with a
+`sync.Once`; `newTestApp` is called by every test.
+
+**`signIn` must send an `Origin` header**, or `csrf.Protect` refuses its
+POSTs and every test fails for the wrong reason.
+
+**There is a fourth secret: `Flow.Key`.** It seals the Keymail pending blob,
+`sealPending` refuses a zero key, and §5.1's "must come from config, never
+an instance data key" applies — sign-in runs before any session exists. So
+the list Paul provisions is `HOME_DEK`, `HOME_PEPPER`, `HOME_SIGNIN_KEY`
+and `CARLOS_SMTP_PASS`.
| `templateNames(t)`, `renderTo(t, w, name, data)`, `sampleData(name)` | Task 7's scriptless sweep over every template. |
**`sessions` wiring**, which no step created:
@@ -165,7 +187,8 @@ internal/home/
migrations/0002_signin_links.sql
personref.go Mint and validate the opaque person ref.
signin.go Begin/Verify: sealed link state, one-time redemption, session mint.
- classify.go signin.Classifier wrapper. Fails open.
+ links.go signin.LinkStore. Seals on put, one
+ DELETE ... RETURNING on take.
render.go One parsed tree per page, ui.Funcs plus an asset func.
templates/ Scriptless screens. class="rst-*".
logtest/ Gate 9. Already on feat/gate-9-no-content-in-logs.
@@ -538,17 +561,18 @@ had Task 4 hand-roll `Begin` and `Verify` and Task 5 then replace them with
rate limit and no CRLF guard, and then rewritten the security-critical path.
Nothing here routes a handler.
-The task the whole step exists for, and where the blindness claim is either kept or quietly broken.
+Where the blindness claim is either kept or quietly broken.
**Files:**
-- Create: `internal/home/signin.go`, `internal/home/signin_test.go`
+- Create: `internal/home/links.go`, `internal/home/links_test.go`
- Create: `internal/home/migrations/0002_signin_links.sql`
-- Create: `internal/home/templates/signin.html`, `internal/home/templates/check_your_mail.html`
-- Modify: `internal/home/app.go`
+- Modify: `go.mod` — add `github.com/keymaildev/signin` (the `LinkStore`
+ interface is defined there, so the dependency arrives with the store,
+ not with the handlers)
**Interfaces:**
- Consumes: `seal.Sealer`, `NewPersonRef`, `sessions.Sessions`, `mail.Sender`.
-- Produces: `(*App).links`, a `signin.LinkStore`. **Routes nothing** — the handlers are Task 5's, at `POST /signin`, `GET /signin/verify` (renders), `POST /signin/verify` (redeems) and `GET /signin/callback`.
+- Produces: `(*App).links`, a `signin.LinkStore`. Consumes `seal.Sealer` (Task 1) and the database (Task 2). **Routes nothing, and needs no `sessions` or `mail.Sender`** — the handlers are Task 5's, at `POST /signin`, `GET /signin/verify` (renders), `POST /signin/verify` (redeems) and `GET /signin/callback`.
`0002_signin_links.sql`:
@@ -697,11 +721,17 @@ git commit -m "A sign-in link store that seals before it writes and takes exactl
### Task 5: `signin.Flow`, the sign-in handlers, and the Keymail upgrade
**Files:**
-- Create: `internal/home/links.go`, `internal/home/links_test.go`
-- Modify: `internal/home/signin.go`, `go.mod` — add `github.com/keymaildev/signin`
+- Create: `internal/home/signin.go`, `internal/home/signin_test.go`
+- Create: `internal/home/templates/signin.html`, `check_your_mail.html`, `signin_confirm.html`
+- Modify: `internal/home/app.go`
**Interfaces:**
-- Produces: `(*App).links` implementing `signin.LinkStore`; `(*App).flow` a `*signin.Flow`.
+- Produces: `(*App).flow` a `*signin.Flow`, and the four routes. Consumes `(*App).links` from Task 4.
+
+**`(*App).render` is Task 7's**, and these three templates are the first
+callers. Either land Task 7 first or build the minimal renderer here and let
+Task 7 extend it — but say which, because an earlier draft left three
+templates with nothing to render them.
**An earlier draft used `signin.Classifier` alone and dropped the Keymail
upgrade on the floor.** It computed a class no code path consumed, and did
@@ -752,16 +782,27 @@ blind-SSRF surface by design)". `csrf.Protect` is pure origin evidence
(`Sec-Fetch-Site`, then `Origin`, then `Referer`) and never consults a
session, so it works before anyone is signed in.
-**The per-IP budget does not exist as deployed, and the plan must not claim
-it.** `rastrillo.Serve` listens on a unix socket (`net.Listen("unix", …)`),
-so `r.RemoteAddr` for the peer is `""`, `auth`'s `clientIP` returns it
-unchanged, and `Flow.Begin` skips the IP budget entirely when `clientIP ==
-""`. Nothing in rastrillo or sheets-core reads a forwarded header. So either
-name the trusted platform header that carries the real client IP and use it,
-or pass `""` deliberately **and add a total-rate cap on `POST /signin`** —
-because with no IP key the only limit left is 5 per address, and §2.1 makes
-this service the suite's single point of failure. Decide it here rather than
-discovering it in production.
+**The per-IP budget collapses into a suite-wide sign-in DoS, and this is
+settled here rather than left to be found.** An earlier draft of this
+paragraph said `RemoteAddr` is `""` behind the unix socket and the budget is
+therefore skipped. **Measured, and it is worse than that:** over a Linux
+unix socket `r.RemoteAddr` is `"@"`. `net.SplitHostPort("@")` fails, `auth`'s
+`clientIP` returns `"@"` unchanged, and `Flow.Begin` skips the budget only
+when the key is exactly `""`. So **every request in the world shares the key
+`ip:@`, with a budget of 20 per 15 minutes** — twenty POSTs to `/signin` lock
+out sign-in for the entire suite for a quarter of an hour. rastrillo's own
+comment admits the shape ("collapsing the per-IP budget into a global one").
+§2.1 already names this service the suite's single point of failure; this
+would make it a trivially reachable one.
+
+**Take the client IP from `X-Forwarded-For`.** The carlos edge sets it on
+every proxied request (`SetXForwarded`) and strips any inbound
+`Forwarded`/`X-Forwarded-*` on unverified hops (`stripForwardedHeaders`), so
+the header is trustworthy at the app and a client cannot forge it. Use the
+last element; fall back to `RemoteAddr` only when the header is absent, and
+**never pass `"@"` as a limiter key**. Test both paths — a request with the
+header gets its own budget, one without does not silently share a global
+one.
**Classification fails open** (§5.1). The package already guarantees it —
"every failure mode — DNS, transport, malformed body, non-200 — answers
@@ -788,13 +829,13 @@ func TestFullSigninLeavesNoReadableAddress(t *testing.T) {
// Lowercase and address-shaped: the blind index and signin's rate
// limiter both key on strings.ToLower(address), so a canary that is
// only ever upper-case cannot detect a leak of the lowercased copy.
- signIn(t, app, "canary-"+sealtest.Token+"@example.invalid")
+ signIn(t, app, "canary-"+sealtest.Canaries[0]+"@example.invalid")
sealtest.AssertNoPlaintext(t, dbPath)
}
```
Plus the classification test, asserting on the flow's decision rather than
-on a constant. **`signin.ClassKeymail` (which turned out not to exist — `Class` is a struct) does not exist** — an earlier draft
+on a constant. **`signin.ClassKeymail` does not exist** — an earlier draft
invented it and flagged it as the one identifier not verified at source,
which was the right instinct and the wrong response. `Class` is a struct
`{Keymail bool; Server string; Reauth bool}`:
@@ -822,9 +863,30 @@ Expected: FAIL — no `/signin` route, no `flow` field.
- [ ] **Step 3: Wire the Flow and implement Begin**
-`POST /signin` calls `Flow.Begin`, then **303 to a "check your mail" page —
-always, and always the same page**, whether or not the address is known. A
-different response for a known address is an account-enumeration oracle.
+`POST /signin` calls `Flow.Begin` and then **branches on `next.Kind`** —
+an earlier draft said "303 to a check-your-mail page, always", which drops
+the Keymail upgrade this task exists to deliver:
+
+- `NextMagicLink` — the mail has already been sent by the time `Begin`
+ returns. 303 to the check-your-mail page.
+- `NextKeymail` — **no mail was sent.** `Begin` returns `Redirect` and
+ `Pending`; set the pending cookie and 303 to `next.Redirect`.
+
+**Both branches must be indistinguishable to an attacker probing for known
+addresses.** The Keymail redirect necessarily differs, but it is decided by
+the address's *domain*, which the submitter already knows — it discloses
+nothing about whether that person has an account here.
+
+**The pending cookie's attributes are load-bearing and `auth` keeps them
+unexported, so they must be replicated deliberately:** `__Host-` prefix,
+`SameSite=Lax`, `HttpOnly`, `Secure`, and **cleared on read**. They are
+exactly what the "callback may mint on GET" argument below rests on; get
+them wrong and that argument stops being true.
+
+**Carry the `force` escape hatch.** `auth.Callback` redirects to
+`?force=1&err=keymail` when the Keymail round trip fails, and without it a
+failed approval is a dead end — against §5.1's "every address has a working
+path". `Flow.Begin`'s last parameter is that override.
- [ ] **Step 4: Verify as GET-renders, POST-redeems**
@@ -898,43 +960,105 @@ git commit -m "Sign-in through signin.Flow, with the link redeemed on POST"
### Task 6: Passkeys and recovery codes
-**Unblocked 2026-09-05.** §11 has its carve-out (see "Three things to
-settle"), so this task proceeds: the enrolment and ceremony screens may
-load script, and nothing else may.
+**Unblocked 2026-09-05** by ruling 21: §11 has its carve-out, so the
+enrolment and ceremony screens may load script and nothing else may.
-**Correcting this plan's own earlier claim, which was false at source:**
-rastrillo *does* ship the client JavaScript. `webauthn/js.go` embeds
-`js/webauthn.mjs` and exports `webauthn.JS()`; the `passkey` package's doc
-says apps serve it as a static asset. So nobody has to write it, and the
-ruling is only whether **one** screen may require script. Everything else in
-this step stays scriptless either way.
+**Files:**
+- Create: `internal/home/passkey.go`, `internal/home/passkey_test.go`
+- Create: `internal/home/templates/passkey_confirm.html`, `passkey_enrol.html`, `recovery_codes.html`
+- Create: `static/passkey.js` — the app's own glue
+- Modify: `internal/home/app.go`, `internal/home/db.go` (schema merge)
+
+**Interfaces:**
+- Produces: `(*App).passkeys *passkey.Handlers`, mounted at the seven routes below.
Verified at v0.22.0 so the executor need not re-derive it:
- `recoveryCodesPerSet = 10` — matches §5.2's "ten" exactly.
-- Redemption is `if r.Method != http.MethodPost` then `r.PostFormValue("code")` — **a plain form POST with no JS**, matching §5.2 exactly. Mount `csrf.Protect` on it, as the package requires.
-- Codes are base32, 50 bits each, hashed with `sessions.HashToken`, redeemed with a single `DELETE … RETURNING`.
-- There is no attempt counter, and that is sound rather than an omission: `SignInRecovery` requires a live `passkey_pending` half-session, so the window is bounded by `pendingTTL` rather than by a counter.
-- Losing passkey and codes together is a permanent lockout. That is §5.2's disclosed outcome with no operator reset (§14) — not a gap to fill.
+- `SignInRecovery` is `r.PostFormValue("code")` — **a plain form POST with no JS**, matching §5.2. The package says why: "recovery is exactly the moment WebAuthn isn't working."
+- Codes are base32, 50 bits each, hashed with `sessions.HashToken`, redeemed with a single `DELETE … RETURNING`. No attempt counter, and that is sound rather than an omission: `SignInRecovery` needs a live `passkey_pending` half-session, so the window is bounded by `pendingTTL` rather than by a counter.
+- A successful step-up mints `Method` as the first factor plus `"+passkey"`, with a fresh `AuthTime` — which is what `RequireFresh` checks.
+- Losing passkey and codes together is a permanent lockout: §5.2's disclosed outcome with no operator reset (§14). Not a gap to fill.
-**Struck from the earlier draft:** *"If passkeys leave slice one, this task
+**Struck from an earlier draft:** *"If passkeys leave slice one, this task
becomes 'recovery codes as a second sign-in path' and stays scriptless."*
-That is a spec substitution, not a fallback. `SignInRecovery` needs a live
-half-session minted by `Gate` — the package's own doc is "it never signs
-anybody in from nothing" — and §5.2 defines codes as recovery **for the
-second factor**. Codes without a passkey are a first-factor bypass the spec
-never authorised. If passkeys leave slice one, recovery codes leave with
-them.
-
-**Attachment, which the earlier draft omitted entirely.** At the point
-`POST /signin/verify` would call `sessions.SignIn`, call
-`passkey.Handlers.Gate(w, r, sess)` exactly as `auth.admit` does. Because
-`sess.Subject` is already the person ref, `passkey_credentials`,
-`passkey_pending` and `passkey_recovery_codes` key on it, and the spec's
-worry about inheriting `auth`'s address-bearing subject dissolves — say so
-rather than leaving it to be rediscovered.
-
-**Serve `webauthn.JS()` as a static asset**, as the `passkey` package's doc
-says — do not vendor a copy, or it drifts from the module on every upgrade.
+That is a spec substitution, not a fallback. The Gate never signs anybody in
+from nothing, and §5.2 defines codes as recovery **for the second factor**;
+codes alone are a first-factor bypass the spec never authorised.
+
+- [ ] **Step 1: Merge `passkey.Schema`**
+
+Task 2's `migrate.Merge(sessions.Schema, Schema)` omits it, and `Gate`
+INSERTs into `passkey_pending`, so without this the first enrolled sign-in
+fails at the database. All four passkey tables key on `subject`, which is
+already the person ref — so the spec's worry about inheriting `auth`'s
+address-bearing subject dissolves. Say so; do not leave it to be
+rediscovered.
+
+- [ ] **Step 2: Write the failing test that one factor is not enough**
+
+```go
+// An enrolled person must NOT obtain a full session from the magic link
+// alone. This is the whole point of the Gate, and the test that would
+// have caught the callback bypass below.
+func TestEnrolledPersonCannotSignInWithOneFactor(t *testing.T)
+```
+
+- [ ] **Step 3: Put `Gate` on BOTH sign-in paths**
+
+At the point each handler would call `sessions.SignIn`, call
+`passkey.Handlers.Gate(w, r, sess)` — exactly as `auth.admit` does, and
+note that in rastrillo **both `Verify` and `Callback` funnel through that
+one `admit`.** This plan has two entry points, so it needs the call twice:
+
+- `POST /signin/verify` — the magic-link path.
+- `GET /signin/callback` — **the Keymail path.** An earlier draft named
+ only the first, which left a Keymail-upgraded account with a passkey
+ enrolled signing in with a single factor. That is the bug this task
+ exists to prevent, arriving through the door the task forgot.
+
+An account with no passkey passes the Gate untouched, so the unenrolled
+path is unchanged.
+
+- [ ] **Step 4: Mount the seven routes and write the glue**
+
+```
+POST /passkey/register/begin | /finish
+POST /passkey/stepup/begin | /finish
+POST /passkey/signin/begin | /finish
+POST /passkey/signin/recovery
+```
+
+All behind `csrf.Protect`. Set `ConfirmPath` (its default is
+`/passkey/confirm`).
+
+**Correcting this plan's own earlier claim that "nobody has to write it":**
+`webauthn.JS()` embeds `js/webauthn.mjs`, which only *exports*
+`register()`/`authenticate()` and makes no network call. The app writes the
+glue that imports it, calls it, POSTs to `/passkey/*` and follows the `to`
+that `SignInFinish` returns. Both files are served as **static same-origin
+assets** — rastrillo's default CSP forbids inline script, and ruling 21's
+carve-out is for static files, not for inline blocks. Serve `.mjs` with an
+explicit `text/javascript` Content-Type or strict MIME checking blocks the
+module.
+
+- [ ] **Step 5: The confirm page carries the recovery form**
+
+The same screen hosts the ceremony *and* `SignInRecovery`'s plain form.
+**Script-strip it and the `code` form must still work** — that is gate 6's
+requirement under ruling 21, and the reason the allowlist exempts the
+ceremony button rather than the page.
+
+- [ ] **Step 6: Show the codes once, behind `RequireFresh`**
+
+`RegenerateRecoveryCodes` mints ten, shown once. The package requires that
+page to sit behind `sessions.RequireFresh`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/home/passkey.go internal/home/passkey_test.go internal/home/templates static/passkey.js internal/home/app.go internal/home/db.go
+git commit -m "Passkeys as an optional second factor, gated on both sign-in paths"
+```
---
@@ -1010,9 +1134,18 @@ Assert no cookie set there carries a `Domain`.
- [ ] **Step 2: Every screen works with scripts off**
-§11 is enforceable mechanically and should be. If Task 6's ruling grants a
-carve-out, this test gains an explicit allowlist of the one screen — never
-a blanket exemption.
+§11 is enforceable mechanically and should be. Ruling 21's carve-out is an
+allowlist, and **an allowlist of screens is not enough** — it would admit
+any script at all on those screens. Constrain it:
+
+- The only `<script>` on an allowlisted template is a **same-origin `src`**
+ from a named list. No inline bodies.
+- Assert the served CSP is still rastrillo's default (`Options.CSP`
+ untouched), so the carve-out cannot quietly widen into
+ `unsafe-inline`.
+- Script-strip the confirm page and assert the recovery `code` form still
+ posts to `/passkey/signin/recovery` — gate 6's shape, and the reason the
+ exemption is the ceremony button rather than the screen.
```go
func TestNoScriptTagInAnyTemplate(t *testing.T) {
@@ -1039,7 +1172,7 @@ git commit -m "Scriptless sign-in screens, and the guard that the RP ID is a ful
| Thing | Trigger |
|---|---|
-| Passkey enrollment and step-up | Paul rules on §11's zero-JS absolute |
+| ~~Passkey enrollment and step-up~~ | **Ruled 2026-09-05 (ruling 21): carve-out granted, Task 6 builds it.** |
| The pepper and the blind index | §3.1's "expensive KDF" vs ruling 3's `HMAC` is settled — task 2 on `skeleton` |
| Assertion minting, `jti`, the redirect protocol | §13 step 3 |
| The directory, calls 2 and 3 | §13 step 4 |
@@ -1047,8 +1180,8 @@ git commit -m "Scriptless sign-in screens, and the guard that the RP ID is a ful
## Self-review notes
-- **Spec coverage:** §5.1 is Tasks 1–5; §5.2 is Task 6, blocked and said so; §10's `people`, `signin_links` and `sessions` are Tasks 2–4; §12 gates 1 and 2 are Tasks 1 and 4; §11 is Task 7. §5.3–§5.6 are step 3, out of scope and listed above.
+- **Spec coverage:** §5.1 is Tasks 1–5; §5.2 is Task 6, unblocked by ruling 21; §10's `people`, `signin_links` and `sessions` are Tasks 2–4; §12 gates 1 and 2 are Tasks 1 and 5; §11 is Task 7. §5.3–§5.6 are step 3, out of scope and listed above.
- **Deliberately not covered:** `prefs`, `directory`, `teams`, `provisioning_queue`, `rekey_journal`, `consumers`, `assertion_jti` — later steps.
- **Two spec corrections needed, neither made silently:** §5.1's "use `rastrillo/auth` for classification" is not achievable; §3.1 contradicts ruling 3 on HMAC-vs-KDF.
-- **The one unverified identifier:** `signin.ClassKeymail` in Task 5. Every other signature in this plan was read at source.
+- ~~The one unverified identifier: `signin.ClassKeymail` in Task 5.~~ **It did not exist** — `Class` is a struct. Flagging it was the right instinct; writing it anyway was the wrong response. Verify, or do not write. Every other signature in this plan was read at source, and the 2026-09-05 review pass re-read them at the pin.
- ~~The riskiest assumption: that `rastrillo/db.Open` either sets `PRAGMA secure_delete` or lets the app set it.~~ **Resolved 2026-09-05: it does neither.** The DSN carries `busy_timeout`, `journal_mode` and `foreign_keys` and passes nothing else through. Task 2 step 5 now uses a connection hook registered before `db.Open`, because the writer pool's one long-lived connection is opened inside it.