Skip to content

docs(f3): a row lock the shipped database does not take - #117

Merged
lessevv merged 1 commit into
mainfrom
spec/a-lock-the-database-does-not-take
Sep 13, 2026
Merged

lessevv merged 1 commit into
mainfrom
spec/a-lock-the-database-does-not-take

Conversation

@lessevv

@lessevv lessevv commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

F3-R11 required a recovery code to be "matched and consumed atomically under a row lock".

SQLite has no row lock. Laravel's SQLiteGrammar::compileLock() returns '', so the call that appeared to satisfy the requirement emits no SQL:

SQL emitted: select * from "users" where "id" = ?
contains FOR UPDATE: NO

The requirement named a mechanism the shipped database does not implement, and the implementation's lockForUpdate() was decorative.

Why that was worth changing rather than leaving

F3-R13 requires a constant mismatch cost, which the implementation delivers as a fixed ten bcrypt comparisons per attempt — 3.5 seconds, measured at rounds 12 on this hardware.

The connection runs transaction_mode = IMMEDIATE, which takes the database-wide write lock at BEGIN, and both shells serve one request at a time. A transaction held across that hashing therefore freezes the entire application for three and a half seconds — from an endpoint that needs no credential to reach.

So the decorative lock was hiding a real constraint: the expensive comparison must not happen inside the consuming transaction.

What it says now

A recovery code MUST be matched and consumed atomically: a redemption MUST spend exactly one code, and a code already spent MUST NOT be spendable again however many attempts are in flight. The hashing that decides the match MUST NOT be held inside the consuming transaction.

The property, not a mechanism. Atomicity is carried by a compare-and-set — a single UPDATE … WHERE id = ? AND used_at IS NULL whose affected-row count is the answer — which needs no row lock and cannot be interleaved.

A new section, A row lock the shipped database does not take, records the measurement and the reasoning so the next reader does not reinstate the call.

Ordering

Filed before the implementing change, per GOV-R27. The beatrax PR that moves the hashing out of the transaction will cite F3-R11 and will not merge before this does.

Gates run locally: integrity.py clean, manifest_check.py clean (1 manifest, 373 goals), markdownlint-cli2 0 issues.

Spec: F3-R11, F3-R13, GOV-R27

F3-R11 required a recovery code to be "matched and consumed atomically
under a row lock". SQLite has no row lock. Laravel's SQLite grammar
compiles lockForUpdate() to an empty string, so the call that appeared
to satisfy the requirement emitted no SQL at all, and the requirement
named a mechanism the shipped database does not implement.

What made that worth changing rather than leaving decorative is the
cost. F3-R13 requires a constant mismatch cost, which is ten bcrypt
comparisons per attempt -- 3.5 seconds measured at rounds 12. The
connection runs transaction_mode IMMEDIATE, taking the database-wide
write lock at BEGIN, and both shells serve one request at a time. So a
transaction held across the hashing freezes the whole application for
three and a half seconds, from an endpoint reachable without any
credential.

The requirement now states the property instead of a mechanism: spend
exactly one code, never spend a spent one, and keep the hashing out of
the transaction that spends it. Atomicity is carried by a compare-and-
set whose affected-row count is the answer, which needs no row lock and
cannot be interleaved.

Spec: F3-R11, F3-R13, GOV-R27
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Wessel Verheij <info@nightworks.io>
@sonarqubecloud

Copy link
Copy Markdown

@github-actions github-actions Bot added documentation Docs only functional awaiting-maintainer Green PR with no review — a maintainer needs to act labels Sep 13, 2026
@lessevv
lessevv merged commit fcdfc58 into main Sep 13, 2026
13 checks passed
@lessevv
lessevv deleted the spec/a-lock-the-database-does-not-take branch September 13, 2026 22:45
@github-actions github-actions Bot added awaiting-maintainer Green PR with no review — a maintainer needs to act and removed awaiting-maintainer Green PR with no review — a maintainer needs to act labels Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-maintainer Green PR with no review — a maintainer needs to act documentation Docs only functional

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant