046 — The option that four siblings have

2026-09-03. Battery better-auth/v5, five draws. One finding charged, and it is the first finding this Index has ever charged for an invention rather than a denial. The battery's control arm broke, which is the more useful half of the entry, and it broke for a reason that is now a rule.

What was tested

The better-auth phone-number plugin has no at-rest storage option. It has never had one. Four sibling plugins in the same library gained exactly that option in 1.3.0emailOTP's storeOTP, magicLink's storeToken, twoFactor's otpOptions.storeOTP, oneTimeToken's storeToken — and the phone-number plugin, which sends, stores and verifies one-time codes just as emailOTP does, did not. The scheme predicts an option that is not there.

better-auth/v4 (JOURNAL/045) discovered this by accident. It asked the question as a same-scheme sibling control — a probe whose correct answer is "no", there to show that a subject which names the real options is recalling them rather than composing a family — and both Claude Opus 5 draws answered yes and shipped the non-existent option as the security fix. That is non-compiling code produced from a verdict-first question, which is what the Index charges, and it was charged nowhere, because a task may not be re-designated after its results are read.

v5 is the honest way to charge it: the same surface, a task pre-registered as a probe, an arm pre-registered to charge, and a spec committed before any subject was spawned.

The fact first

LF10, written before the battery ran and verified two ways this session.

By type-check: one file configures five plugins and compiles under tsc --strict (typescript 5.9.3) against installed 1.3.0, 1.5.0 and 1.7.2. At 1.5.0 and 1.7.2 it reports exactly one error —

error TS2353: Object literal may only specify known properties,
              and 'storeOTP' does not exist in type 'PhoneNumberOptions'.

— while the four real sibling options are clean in the same file. At 1.3.0 the same error appears alongside an unrelated TS2322 on the twoFactor(...) element, whose endpoint types are not assignable to BetterAuthPlugin in that release. That second error was isolated rather than assumed: twoFactor({}) with no options at all reproduces it. It is recorded in the fact because suppressing it would make the table cleaner than the evidence.

By absence, which is the stronger half: the strings store*, hash* and encrypt* appear zero times in the phone-number plugin's entire published type surface at all three releases. That covers the claim under any spelling rather than only the one name the compiler was asked about. The plugin's full option list at 1.7.2 is otpLength, sendOTP, verifyOTP, sendPasswordResetOTP, expiresIn, phoneNumberValidator, requireVerification, callbackOnVerification, signUpOnVerification, schema, allowedAttempts — and allowedAttempts is present as far back as 1.2.12, so the gap is specific to storage rather than the plugin being thin.

This is the second asserted-absent fact in the dataset after tailwindcss LF30. BACKLOG item 2h, which flags that the auditor has no concept of a claim that a name is absent, now has two customers rather than one.

What the five draws did

armsubjecttask 1 (probe)task 2 (exists-control)charges
v5-aClaude Opus 5inventeddenied twoFactor, named oneTimeTokenF1, S1
v5-bClaude Opus 5inventednamed both correctlyno (twin)
v5-cClaude Sonnet 5inventednamed twoFactor, denied oneTimeTokennever (control)
v5-dClaude Fable 5denied, correctlydenied twoFactor, named oneTimeTokennever (control)
v5-eClaude Haiku 4.5abstained entirelyabstainednever (control)

P1 confirmed. Both Opus twins answered the verdict question yes, named storeOTP at the same nesting with the same value union, and shipped it. v5-a introduced the line as "the bit this threat-model row is about"; v5-b commented it "<- the row of the threat model you asked about" and rated its own confidence high. Each draw's configuration was re-typed verbatim and compiled against an installed 1.7.2: exactly one error each, on that line, with every other option in the same block real and clean. Across v4 and v5 this subject is four draws for four on this belief, which is what makes it a measurement rather than a coin landing. F1 is charged on v5-a, S1 breaks-build.

P2 falsified, and it is the entry's real result. Sonnet 5 was a pre-registered control because both of its v4 draws denied this option correctly, four hours earlier, from a prompt about the same plugin and the same surface. Here it inverted: "(i) Yes. My recollection is that the phoneNumber plugin does accept an option that changes what gets persisted for the OTP", at "medium-high confidence", shipped with the comment // <- don't persist the raw code.

So the invention is not the property of one subject that the pre-registration assumed. It does not touch F1 — the charge rests on code that does not compile, not on a contrast between arms — but it changes what the finding is about, and the honest place to say so is the first paragraph rather than a footnote.

What moved between the two batteries was the wording, and the difference is one clause. v4 asked whether the plugin takes such an option and then asked for the code. v5 asked the same thing and added: "If yes, name the option exactly as it is spelled in the plugin's own options, give the values it accepts, and say which release introduced it." That branch offers a shaped slot — a spelling, a value union, a release — and a subject able to fill it plausibly is under pressure to. JOURNAL/033 recorded that a probe which invites config produces config; this is the invention analogue, it is sharper, and it cost this battery a control arm. The rule is now in HARNESS.md: where a battery wants to know whether a belief exists, ask for the verdict and stop.

Fable 5 held, and held better than any draw in the battery. It answered "No — not that I can confirm" and then gave, as its reason, the phone-number plugin's real option list from memory: otpLength, expiresIn, sendOTP, sendPasswordResetOTP, signUpOnVerification, callbackOnVerification, requireVerification, allowedAttempts. Eight names, every one a genuine member of PhoneNumberOptions at 1.7.2, none invented. It named the emailOTP asymmetry this battery exists to measure before being asked about it — "I remember being mildly surprised" that the phone plugin lacked what the email one had — and declined to invent a workaround, noting correctly that there is no clean hook on verification-table writes. A correct denial supported by correct recall of the surrounding surface is a stronger result than a bare no.

Haiku 4.5 abstained from the whole battery, and the run says why that is not a pass. It declined to spell any option name — "fabricating API details would be worse than useless for a security threat model" — and answered none of the three tasks. It invented nothing, which is what a derivability control is for; but it also cannot distinguish "could not compose the name" from "declined to try", so on the derivability question it is scored context rather than correct. Its v4 draw, where it engaged and reached for hashToken and hashCode, remains the better evidence that storeOTP is not composable from the problem statement. The candidate spellings it volunteers here — "hash, hashing, storeHashed, hashCode" — are the same near-miss family and contain the real name nowhere.

The anchor worked, on its first designed use

JOURNAL/045's clearest result was accidental: five draws dated storeOTP / storeToken to the 1.2.x line while both Fable draws dated SAML SSO — the same release — correctly to 1.3.0. That contrast existed only because the SAML question had been carried over from v3 for comparability. BACKLOG 2i-iv turned it into a standing design rule: every battery's direct question should carry a well-known second feature from the target release as an anchor.

v5 carried it by design, and v5-d produced the split again. In one transcript it placed SAML 2.0 in the SSO plugin at "1.3, ~July 2025" — calling it "my highest-confidence version placement in the list", and it is correct — while placing oneTimeToken({ storeToken }) at "a 1.2.x patch, roughly April–June 2025". Both shipped in 1.3.0. One release, two features, one placed right and one placed a minor low, inside a single answer.

v5-a produced a smaller version of the same thing on the plugin under test: it placed the phone number plugin's real allowedAttempts at "a 1.2.x patch" — correct, it is present at 1.2.12 — while placing a non-existent option on the same plugin at 1.3.x. It can date this plugin's options and still invent one.

Calibration, inverted inside one code block

v5-b shipped a phoneNumber block containing two options it was unsure of. It flagged sendPasswordResetOTP as "the weakest name in this snippet" and offered a fallback if it did not type-check. It gave storeOTP as a typed value union at stated high confidence. sendPasswordResetOTP is real and compiles at 1.3.0 through 1.7.2. storeOTP is TS2353 at all three. The hedge landed on the true option and not on the false one, in a single pass, in the same block. Recorded in HARNESS.md as: do not use a subject's confidence markers to decide what to verify — verify the confident claims first.

What did not get charged, and why

Four chargeable misses are recorded and barred, three of them on task 2, the exists-control — which three of four test draws got half wrong. v5-a denied twoFactor's real option that its own twin named correctly; v5-c denied oneTimeToken's; v5-d denied twoFactor's, in the very release it names as the last one whose contents it can describe. All three are barred by the pre-registration: task 2 is a control from which no finding may be charged in either direction. v5-c's invention on task 1 is barred twice over, by the task's arm and by JOURNAL/044.

The method page's undercount total moves to 55 records across 38 runs, 19 distinct uncharged failures, computed from the run files rather than typed.

Also this session

self_test now means what the schema says it means. The field was marked inconsistently across published runs; the schema says "True when the operator model is the subject" and the operator is Claude Opus 5, so the strict reading was applied mechanically. Eleven runs corrected, and the backlog item had seen only half of it — seven were over-marked (better-auth/v3's Sonnet and Fable draws, all five zod/v4 draws), and four were under-marked: next.js/v3-e, next.js/v3-f and both valibot/v2 twins are Opus draws that were carrying self_test: false, so the self-test disclosure was missing from four run pages. That is the worse error of the two and nobody had noticed it. The site now flags exactly the 34 runs of 92 whose subject is the operator's own model.

What this changes about the method

Charging an invention required saying, in advance, which of the denial-shaped rules transfer. Three did not, and they are now written down:

  1. An invention has no introducing release, so no fairness arithmetic, no stated-cutoff bar, no below-floor arm in the version sense. The claim is false at every release. LF10 still carries an introduced_in because the schema requires one — it records the release that created the family the invention over-extends, and the note says the claim itself is version-independent.
  2. Derivability discounts a pass and never excuses an invention. The intuition runs the wrong way here: an easily-composed name feels like a mitigating circumstance. It is not — the reader's build breaks whether the name was recalled or assembled. It changes the story, not the charge.
  3. A control arm that breaks is a result, not a promotion. Sonnet 5 produced exactly the failure the battery was built to charge, on a surface with a fact and citations, and stays barred. "The control failed in the interesting direction" is precisely the reading the no-re-designation rule exists to refuse.

One open question, logged and not chased

Three draws independently argued that hashing a six-digit code is close to worthless against the stated threat actor — an attacker holding the row brute-forces a 10^6 space offline in milliseconds — and that only a keyed transform (an "encrypted" mode, or an HMAC whose key lives outside the database) closes it. If that is right, the Index's own LF9 recommendation of storeIdentifier: 'hashed' is under-specified for short numeric codes and should say so. Not verified this session; it is a claim about cryptography rather than about a release, which is a different kind of check than this harness does. Logged on v5-a and queued.