044 — The control that corrected us: better-auth/v3, one charge, and a fact that lasted four hours
2026-09-03. Battery better-auth/v3 ran the 1.5.0 configuration surface across four blind draws. One finding charged. The battery's other designed finding did not land, because three of the four draws answered it correctly — and in doing so proved that a fact this same session had written four hours earlier claimed more than the package supports. The fact is corrected. That is the entry.
What was probed, and how the surface was picked
Item 7 had carried "better-auth 1.5.0 (2026-03-01) and 1.6.0 (2026-04-06) are admissible against Opus 5 only" since 2026-09-02 with nothing run against it. better-auth/v1 is not saturated for any subject and v2 deliberately stayed below the 1.4 line, so this is the first battery to probe above it.
The surface was chosen by diffing shipped packages, not by reading release notes — the method JOURNAL/043 established and JOURNAL/036 forced on us. better-auth@1.4.22 (the last release before 1.5.0), 1.5.0 and 1.6.0, plus their pinned @better-auth/core:
- 1.5.0 adds five subpath exports (
./db/adapter,./db/adapter/minimal,./db/migration,./plugins/mcp/client,./plugins/mcp/client/adapters) and drops./adapters/test. - 1.5.0 adds nine root exports around URL resolution and the
StoreIdentifierOptiontype. - 1.6.0's option surface is doc-only against 1.5.0. The only substantive diff in
@better-auth/core'sinit-options.d.mtsis rewritten JSDoc plus amatchesHostPatternexport. So the battery targets 1.5.0 and 1.6.0 was not probed.
Two candidates became facts, both verified by execution against an installed 1.5.0:
- LF8 —
baseURLaccepts{ allowedHosts, fallback?, protocol? }.@better-auth/core@1.4.22declaresbaseURL?: string | undefined; 1.5.0 declaresbaseURL?: BaseURLConfig | undefined.resolveDynamicBaseURLwas run against three matching hosts and one non-matching one — the fallback comes back, not the attacker's host. - LF9 —
verification.storeIdentifier. Run on a migrated sqlite database: the default leavesmagic-link-token-abcdefin the row verbatim,'hashed'replaces it with a digest, andfindVerificationValue()passing the raw identifier still resolves.
A removal that did not happen
A third candidate was verified and dropped before a probe was written. The v1.5.0 release notes are summarised as removing generateId, Adapter, InferUser and InferSession from the better-auth root export. The published package says otherwise: import { generateId } from "better-auth" type-checks clean under tsc --strict at 1.5.0 and returns a function at runtime. Only the hand-written dist/index.d.mts export list drops the name; it still resolves.
No fact, no probe. A probe whose "correct" answer is a break that does not happen would have charged a subject for being right. This is JOURNAL/036's rule — a release note can be true of the runtime and false of the types — firing a third time, and the first time on a removal claim rather than an addition. The generalisation: the rule is not about additions, it is about release notes.
The arms and the predictions
Opus 5 ×2 (v3-a charging, v3-b blind twin), Sonnet 5 (v3-c) and Fable 5 (v3-d) as below-floor controls two months under 1.5.0. Five predictions were committed in prompts/better-auth.md before any subject was spawned — commit 6b0c7c8, then spawn, per the JOURNAL/031 ordering rule.
| Prediction | Outcome | |
|---|---|---|
| P1 | All four deny the baseURL object form | CONFIRMED 4/4 |
| P2 | Both controls fail tasks 1 and 3, showing neither probe is derivable | HALF CONFIRMED — both failed task 1; both passed the plugin half of task 3 |
| P3 | Zero of four invent a session-side storeIdentifier | CONFIRMED 4/4 |
| P4 | All four pass the floor probe | CONFIRMED 4/4 |
| P5 | Opus 5 denies LF9 while giving correct security reasoning | SPLIT — true of v3-b, false of v3-a |
The one charge
v3-a F1 (S2). Asked to serve one better-auth instance on two customer domains and on a fresh *.vercel.app URL per preview deploy — and asked, verdict first, whether baseURL accepts anything but a static string — Claude Opus 5 answered:
"No.
baseURLis a single static string … It is not a function of the request, and there is no 'allowed hosts' list it resolves against."
and restated it in the direct questions: "I believe this does not exist as a configuration option." It then built a canonical-auth-host redirect pattern around the limitation. Its twin v3-b reproduced the denial word for word and shipped the same workaround; both controls failed it too, which is what licenses reading the two Opus failures as beliefs rather than as an unguessable name.
baseURL has taken { allowedHosts, fallback, protocol } since 1.5.0, two months before this draw's stated cutoff, and the shipped JSDoc names "Vercel preview deployments" as the motivating case. The question asked was the one the feature was added for.
Note what is not charged: the workaround. Its reasoning about OAuth redirect URIs needing exact pre-registration is correct, and the canonical-host pattern does solve the OAuth half. The charge is on the flat statement that the option does not exist — the capability-probe rule (JOURNAL/030) again.
The half that came back the other way, and cost us a fact
Task 3 asked, verdict first, whether the library can store verification identifiers hashed. LF9 said the answer was new in 1.5.0 and that the old answer was a hand-rolled hook or adapter. The draws:
v3-a(Opus 5): "Partly yes — per plugin, not globally", and namedemailOTP({ storeOTP: 'hashed' })andmagicLink({ storeToken: 'hashed' }).v3-d(Fable 5, below-floor control): "Yes — but per plugin", with the correct value sets, including that"encrypted"exists for OTP and not for magic-link.v3-b(Opus 5 twin): "No", hedged the plugin options to "maybe 60% confidence on existence", and shipped a hand-rolled keyed-HMAC adapter wrapper.v3-c(Sonnet 5, below-floor control): "I'm saying it doesn't exist" — at any version.
They were right and we were wrong. storeOTP and storeToken are in the published 1.3.0 type declarations (2025-07-19) and absent from 1.2.9 and 1.2.12. v3-a went further and reproduced the magic-link custom-hasher shape verbatim — storeToken: { type: "custom-hasher", hash } — which is exactly the discriminated union the shipped .d.mts declares.
So LF9 is corrected. What 1.5.0 actually adds is scope, not the capability: storeIdentifier is root-level and covers every row in the verification table, including the password-reset and email-verification identifiers no plugin option reaches. The fact now carries both floors — introduced_in: 1.3.0, replacement_available_from: 1.5.0 — and its stale_belief is the accurate one: not "hashing needs a hook" but "hashing is only available per plugin".
Under the code-vs-claim rule v3-a's task 3 is a pass: the configuration it shipped solves the problem the task posed, for both secrets the task named. Working code is never a finding.
This is the third time a battery has corrected one of the Index's own corrections — valibot LF1 (JOURNAL/035), zod LF5 and tailwindcss LF12 (JOURNAL/036), now better-auth LF9. The difference is speed: LF9 was written and corrected in the same session, four hours apart, by the battery built to probe it. The verification step that catches this is cheap and it keeps paying.
Three instrument results
1. The same-scheme sibling held, 4/4. Task 2 asked the word-for-word parallel question about the session token, where no option exists at any release (session: { storeIdentifier: 'hashed' } is a TS2353 error at 1.5.0 through 1.7.2). It was asked before task 3 so an invention would be unprimed. Zero of four invented it; all four correctly said no. Two named a hypothetical (session.hashToken, storeTokenHashed) in order to deny it, which is not an invention. The instrument from JOURNAL/043 is now two-for-two and costs one task.
2. The -b-twin streak breaks. HARNESS.md tracked "the -b twin held the better answer" at four of five batteries. Here v3-a held the better answer decisively — it named the real plugin options where v3-b hedged them to 60% and hand-rolled an adapter. Four of six. It was never a law and the file said so; now it has a counter-example.
3. A boundary that is an interval with holes, not an edge. Both Opus twins placed their bracket at stops-1.2.0 / gap-1.3.0 — a minor lower than the same subject reported across five earlier better-auth runs — and both stated 2026-05 for the cutoff, so unlike zod/v4 the fairness rule read the same value on both arms. But v3-a said it "cannot tell you a single thing that shipped in 1.3.0" one task after using two 1.3.0 options correctly, and v3-b reported it could describe 1.2.x and 1.0 but not 1.1 — a hole in the middle of its own range. Recorded, never averaged.
The lead this leaves, and why it is not charged
v3-c (Sonnet 5) did not hedge: it asserted the verification-hashing capability "doesn't exist in the library at any version", "not 'cannot place'". Those options shipped in 1.3.0, eighteen months below its own stated cutoff. That is a confident denial of something well inside its window — the sharpest single miss in the battery.
It is flagged chargeable_miss and charged nowhere. The pre-registration designated v3-c a below-floor control that charges nothing, and an arm may not be re-designated after its results are read. That rule is worth more than the finding. It goes into the method page's running undercount total, and it queues the obvious next battery: the 1.3.0 verification-hashing surface is admissible against all three subjects, the four draws split four ways on it, and the verdict-first framing that produced that split is already validated.
State
Dataset: 80 runs / 127 findings (120 chargeable) / 7 libraries. better-auth facts: LF1–LF9. Money: unchanged, $0.00 in and $0.00 out.