{
  "$schema": "../../schema/run.schema.json",
  "run_id": "better-auth--claude-fable-5-1--v7-c--2026-09-06",
  "supersedes": null,
  "replicate_of": null,
  "library": {
    "name": "better-auth",
    "ecosystem": "npm",
    "latest_version_at_test": "1.7.3",
    "latest_version_verified_on": "2026-09-06",
    "latest_version_note": "Read from https://registry.npmjs.org/better-auth this session (`dist-tags.latest` -> 1.7.3). Every claim this battery charges was re-checked against the installed 1.7.3 package as well as against 1.6.0, so the corrections are about what is true now and not only about what changed at the target release."
  },
  "model": {
    "id": "claude-fable-5-1",
    "label": "Claude Fable 5.1",
    "vendor": "Anthropic",
    "invoked_as": "Agent tool, model alias \"fable\"; prompt sent verbatim from prompts/sent/better-auth-v7.txt, no tools available to the subject. An identity probe run through the same alias in the same session, tool-free, answered \"Fable 5.1\", model id `claude-fable-5-1`, cutoff June 2026, and again volunteered that all three come from its system prompt rather than from self-knowledge - unchanged from every prior battery, so the alias has not moved.",
    "self_reported_cutoff": "2026-06",
    "cutoff_basis": "\"My stated training cutoff is June 2026. In practice my knowledge of this library gets thin after roughly late 2025, so treat anything I say about 2026 releases as low confidence.\" A density qualification, not a substitute cutoff (JOURNAL/055), so the licence holds. June 2026 is after better-auth 1.6.0 (2026-04-06) and is not the same month, so the same-month bar does not apply and this arm may charge.",
    "believed_latest_version": "1.5 line, held as a number only",
    "believed_latest_quote": "\"The latest version I'm aware of existing is the 1.5 line (early 2026), and I have only faint awareness of it. The most recent release whose contents I can actually describe is 1.4 (around November 2025).\"",
    "knowledge_stops_at_version": "1.4.0",
    "knowledge_stops_on": "2025-11-22",
    "knowledge_gap_starts_at_version": "1.5.0",
    "knowledge_gap_starts_on": "2026-03-01",
    "cutoff_lag_months": 7
  },
  "test": {
    "date": "2026-09-06",
    "battery": "better-auth/v7-c",
    "battery_spec": "prompts/better-auth.md",
    "prompt_file": "prompts/sent/better-auth-v7.txt",
    "tasks": 7,
    "direct_questions": 3,
    "elicits_code": true,
    "tool_uses_during_test": 0,
    "probe_window": {
      "from": "1.6.0",
      "to": "1.7.3"
    },
    "self_test": true,
    "saturated": false,
    "status": "open",
    "retested_on": null
  },
  "sources": [
    "https://registry.npmjs.org/better-auth",
    "https://registry.npmjs.org/better-auth/-/better-auth-1.5.0.tgz",
    "https://registry.npmjs.org/better-auth/-/better-auth-1.6.0.tgz",
    "https://registry.npmjs.org/better-auth/-/better-auth-1.7.3.tgz",
    "https://github.com/better-auth/better-auth/releases/tag/v1.6.0"
  ],
  "findings": [
    {
      "id": "F1",
      "severity": "S2",
      "severity_label": "silently-wrong",
      "title": "Predicts the freshness check passes for a 30-hour-old session that was used two minutes ago, and names `updatedAt` as the anchor — the pre-1.6.0 semantics, held consistently across two tasks",
      "api": "session.freshAge (measured from session.createdAt)",
      "change_kind": "behavior-changed",
      "introduced_in": "1.6.0",
      "introduced_on": "2026-04-06",
      "chargeable": true,
      "chargeable_note": "1.6.0 shipped 2026-04-06; this draw states a June 2026 cutoff, which is after it and not the same month, so the fairness rule and the same-month bar both clear. This is a pre-registered test arm and tasks 1 and 5 are pre-registered probes on this surface. **One finding, two artefacts.** The pre-registration graded the verdict (task 1) and the mechanism (task 5) independently and this draw got both wrong in the same direction; that is one belief measured twice, not two findings, and charging it twice would inflate the count.",
      "model_belief": "Task 1, verdict on its own line: \"yes\". Task 5, four tasks later: \"(a) `updatedAt` — with a fallback to `createdAt` if `updatedAt` is absent on the row.\" That is a verbatim description of the 1.5.0 implementation, which read `new Date(session.session.updatedAt || session.session.createdAt)`. At task 3 it restated the same belief as the reason its own answer to task 1 was \"yes\": \"As best I remember the middleware, it measures from `updatedAt` and only falls back to `createdAt` when `updatedAt` is missing — which is why Task 1 passes. That is the product team's semantics, not the security team's, and it's baked in.\" It also flagged, correctly, that the vendor's documentation and the implementation had historically disagreed, and told the reader to check `freshSessionMiddleware` in the installed package - which would have corrected it.",
      "wrong_code": "// The advice that follows from the belief: keep the built-in check as a\n// 'last used' gate, and add your own createdAt gate on top for the\n// endpoints security cares about.\nexport const auth = betterAuth({\n  session: {\n    // Built-in check: measured from updatedAt (falls back to createdAt).\n    freshAge: FRESH_AGE,\n    expiresIn: 60 * 60 * 24 * 7,\n    updateAge: 60 * 60 * 24,\n  },\n  hooks: { before: createAuthMiddleware(async (ctx) => { /* createdAt gate */ }) },\n})",
      "correct_code": "// 1.6.0 and later the window runs from createdAt. Executed against three\n// installed releases with createdAt = now-30h and updatedAt = now-2min:\n//   1.5.0 -> the freshness gate PASSES (the call fails later, in the handler)\n//   1.6.0 -> FORBIDDEN / SESSION_NOT_FRESH\n//   1.7.3 -> FORBIDDEN / SESSION_NOT_FRESH\n\n// There is no option to move the anchor back. If you want last-use\n// semantics, disable the built-in check and write your own gate:\nexport const auth = betterAuth({ session: { freshAge: 0 } })",
      "impact": "Everything compiles and runs, which is what makes it S2 and what makes it hard to catch: the reader is given a correct prediction about better-auth 1.5.0 and told it is the current behaviour, with nothing in their own code to change and no deprecation warning to trip over. Two concrete consequences. **(1) The security posture is inverted.** The draw tells a team that the built-in gate is \"the product team's semantics\" - lenient, effectively never firing for an active user - and has them build a second, stricter `createdAt` gate in a `hooks.before` middleware with a hand-maintained list of sensitive paths, which the draw itself called \"the honest weak spot\". From 1.6.0 the built-in gate already is the `createdAt` gate; the custom middleware duplicates it, and the path list will silently miss whichever endpoints the framework adds to `freshSessionMiddleware` later. **(2) The upgrade surprise is not predicted.** A team upgrading 1.5.x -> 1.6.x on this advice expects nothing to change for continuously active users. Executed against the installed packages: a session with `createdAt` 30 hours ago and `updatedAt` 2 minutes ago passes the gate at 1.5.0 and is rejected with `FORBIDDEN / SESSION_NOT_FRESH` at 1.6.0 and 1.7.3.",
      "citations": [
        {
          "url": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.0.tgz",
          "title": "better-auth 1.6.0 published package — freshSessionMiddleware reads createdAt",
          "published_on": "2026-04-06",
          "quote": "const createdAt = new Date(session.session.createdAt).getTime();\nconst freshAge = ctx.context.sessionConfig.freshAge * 1e3;\nif (Date.now() - createdAt >= freshAge) throw APIError.from(\"FORBIDDEN\", BASE_ERROR_CODES.SESSION_NOT_FRESH);"
        },
        {
          "url": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.0.tgz",
          "title": "better-auth 1.5.0 published package — the behaviour the draw described, one release earlier",
          "published_on": "2026-03-01",
          "quote": "const lastUpdated = new Date(session.session.updatedAt || session.session.createdAt).getTime();"
        },
        {
          "url": "https://github.com/better-auth/better-auth/releases/tag/v1.6.0",
          "title": "better-auth 1.6.0 release notes — the breaking change and its migration line",
          "published_on": "2026-04-06",
          "quote": "Aligned `freshAge` calculation with session creation time instead of update time (#8762)"
        }
      ]
    },
    {
      "id": "F2",
      "severity": "S2",
      "severity_label": "silently-wrong",
      "title": "Denies the email-OTP plugin has a resend-reuse option and rebuilds it out of `generateOTP` plus a Redis cache — under `storeOTP: 'hashed'`, which is the one configuration in which the real option refuses to reuse",
      "api": "emailOTP({ resendStrategy })",
      "change_kind": "added",
      "introduced_in": "1.6.0",
      "introduced_on": "2026-04-06",
      "chargeable": true,
      "chargeable_note": "Same licence as F1: stated cutoff June 2026, target release 2026-04-06, different months.",
      "model_belief": "Verdict on its own line: \"no\". Then the explicit absence claim the additive-API rule requires: \"I don't know of any option on `emailOTP()` that says 'reuse the outstanding code on resend.'\" It then listed the plugin's options from memory - a list correct for 1.5.0 and missing only the one asked about - and described the mechanism correctly: \"Every call to `sendVerificationOTP` replaces the stored verification row... with a freshly generated code, which is exactly the behaviour your support team is hitting.\"",
      "wrong_code": "// shipped as the answer. Note the third line of options.\nemailOTP({\n  otpLength: 6,\n  expiresIn: OTP_TTL,\n  allowedAttempts: 5,\n  storeOTP: 'hashed',\n  async generateOTP({ email, type }) {\n    const existing = await redis.get(key(type, email))\n    if (existing) return existing // plugin re-stores it with a fresh expiresIn\n    return String(Math.floor(100000 + Math.random() * 900000))\n  },\n  async sendVerificationOTP({ email, otp, type }) {\n    await redis.set(key(type, email), otp, 'EX', OTP_TTL)\n    await sendEmail({ to: email, text: `Your code is ${otp}.` })\n  },\n})",
      "correct_code": "// 1.6.0 and later: one option, and it already knows the constraint the\n// hand-rolled cache does not.\nemailOTP({\n  otpLength: 6,\n  expiresIn: 300,\n  resendStrategy: 'reuse', // default is 'rotate'\n  storeOTP: 'plain',       // reuse needs a recoverable code; 'hashed' falls back to 'rotate'\n  async sendVerificationOTP({ email, otp }) { await sendMail(email, otp) },\n})",
      "impact": "The workaround runs, so S2 - but this draw's version is the one that shows why the built-in option is worth having. It sets `storeOTP: 'hashed'` and then keeps the plaintext code in Redis so it can hand it back, which puts the secret in a second store the security control was chosen to keep it out of. `resendStrategy: 'reuse'` does not have that failure mode available to it: the option's own contract is that reuse works only when the stored code is recoverable and it **falls back to `\"rotate\"` when the OTP is hashed**, so the library refuses to do quietly what this configuration does explicitly. A reader following this answer ends up with hashed storage in the database, plaintext in Redis, and the belief that they have hardened the flow. The draw also carried three honest caveats it should not have needed - whether `generateOTP` may be async, whether the code is cleared on successful verification, and that the attempt counter resets on every resend - all of which are questions about a re-implementation of a shipped option.",
      "citations": [
        {
          "url": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.0.tgz",
          "title": "better-auth 1.6.0 published package — the option, and the hashed-OTP fallback in its own doc comment",
          "published_on": "2026-04-06",
          "quote": "- `\"reuse\"`: Resends the same OTP and extends its expiry.\n *   Only works when the OTP is recoverable (plain, encrypted, or custom encrypt/decrypt).\n *   Falls back to `\"rotate\"` when OTP is hashed."
        },
        {
          "url": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.0.tgz",
          "title": "better-auth 1.5.0 published package — the same option list, without it",
          "published_on": "2026-03-01",
          "quote": "sendVerificationOTP, otpLength, expiresIn, generateOTP, sendVerificationOnSignUp, disableSignUp, allowedAttempts, storeOTP, changeEmail, overrideDefaultEmailVerification, rateLimit"
        }
      ]
    },
    {
      "id": "F3",
      "severity": "S3",
      "severity_label": "deprecated",
      "title": "Denies the two-factor client plugin takes a page option, names `twoFactorPage` correctly, and dates it to the pre-1.0 releases as something since removed",
      "api": "twoFactorClient({ twoFactorPage })",
      "change_kind": "added",
      "introduced_in": "1.6.0",
      "introduced_on": "2026-04-06",
      "chargeable": true,
      "chargeable_note": "Same licence as F1 and F2. Severity capped at S3 in the pre-registration because `onTwoFactorRedirect` still exists, so the shipped code works.",
      "model_belief": "Verdict on its own line: \"no\". Then: \"the current client plugin takes a callback, `onTwoFactorRedirect()`, not a path. I believe there was a `twoFactorPage: \\\"/two-factor\\\"` string option on the client plugin in the pre-1.0 (0.x) days, and that's probably where the memory of 'give it a path once' comes from; I'm fairly, not fully, sure it's gone from 1.x.\" The name is right and the history is backwards. **This is the second model family to produce that inversion independently in this battery** - `v7-a` (Claude Opus 5) wrote \"I have a real memory of a string-path option in early better-auth two-factor docs — I believe it was called `twoFactorPage` — but I think it was replaced by the callback\" - and `v7-d` made it three of six draws.",
      "wrong_code": "// shipped as the answer, with the string option explicitly placed in the past.\nexport const authClient = createAuthClient({\n  plugins: [\n    twoFactorClient({\n      onTwoFactorRedirect() {\n        window.location.href = '/auth/two-factor'\n      },\n    }),\n  ],\n})",
      "correct_code": "// 1.6.0 and later — the string option the task asked for:\nexport const authClient = createAuthClient({\n  plugins: [twoFactorClient({ twoFactorPage: '/auth/two-factor' })],\n})\n\n// The callback is still supported and since 1.6.0 is told which factors the\n// user has, which is the reason to keep using it in a router-driven app:\ntwoFactorClient({\n  onTwoFactorRedirect({ twoFactorMethods }) {\n    router.push(twoFactorMethods?.includes('totp') ? '/auth/totp' : '/auth/otp')\n  },\n})",
      "impact": "S3 by design: the code works, and a reader loses only the one-line setup they asked for. The cost is in the story attached to it. Told that `twoFactorPage` is a name from the 0.x era that 1.x dropped, a reader will not try it and will read the current documentation as stale if they meet it. The draw's fallback - registering a module-level navigator function and calling `setNavigator(navigate)` from a top-level component - is fifteen lines of indirection standing in for a string.",
      "citations": [
        {
          "url": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.0.tgz",
          "title": "better-auth 1.6.0 published package — twoFactorPage added alongside the existing callback",
          "published_on": "2026-04-06",
          "quote": "twoFactorPage?: string;"
        },
        {
          "url": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.0.tgz",
          "title": "better-auth 1.5.0 published package — one option only, so the callback is the older of the two",
          "published_on": "2026-03-01",
          "quote": "onTwoFactorRedirect?: () => void | Promise<void>;"
        }
      ]
    }
  ],
  "non_findings": [
    {
      "kind": "correct",
      "summary": "Task 3, the pre-registered control. \"no\", correctly - there is no option choosing the freshness anchor at any release. The reason it gave is the stale belief from F1 rather than knowledge of the option surface (\"there is a single knob, `session.freshAge`... There is no option selecting which timestamp it's measured from\"), but the graded answer is the right one and the control holds in this arm.",
      "api": "session.freshAge anchor option (does not exist)",
      "introduced_in": null
    },
    {
      "kind": "correct",
      "summary": "Task 4, the floor probe (1.0.0 custom session). `customSession` on the server, `customSessionClient<typeof auth>()` on the client, with the typing constraint stated correctly - pass the options object into `customSession(fn, options)` so plugin-widened `user`/`session` types reach the callback. Passed.",
      "api": "customSession",
      "introduced_in": "1.0.0"
    },
    {
      "kind": "correct",
      "summary": "Task 5(b): `session: { freshAge: 0 }`, correct, and the only half of task 5 this draw got right. All six draws in the battery except the below-floor guessing control gave this answer, which is unsurprising - `0` as the off switch predates the window and is not what this battery probes.",
      "api": "session.freshAge",
      "introduced_in": "1.0.0"
    },
    {
      "kind": "context",
      "summary": "Task 7, the attribution anchor. Unlike both Opus draws this one declined rather than denying: \"I can't name one with confidence, and I'd rather say that than invent it... If a true stateless-session mode shipped, it would be in a release after those (1.5 or later, 2026), and I don't know its contents. Treat this as 'unknown,' not 'doesn't exist.'\" It then listed `cookieCache`, `secondaryStorage` with `storeSessionInDatabase: false`, and the `jwt` plugin as the near neighbours, and attributed a cookie-cache `strategy` option to 1.4 - the release that actually carries stateless session management. So it placed the right release for an adjacent feature while marking the feature itself unknown. Belief data, never scored. An abstention is not a denial (JOURNAL/046).",
      "api": "stateless sessions",
      "introduced_in": "1.4.0"
    },
    {
      "kind": "context",
      "summary": "Boundary spread inside one subject on one library, worth recording because `better-auth/v6` measured this cell six hours earlier. The ladder placed Claude Fable 5.1's better-auth boundary at **1.3.0**; this draw describes **1.4.0** in detail (\"several plugins split into their own packages... cookie-cache `strategy` options, adapter-level joins work\") and names 1.5 as the first release it knows only as a number. Its twin `v7-d` agrees with the ladder at 1.3.0. So the subject's spread on this library is 1.3.0 / 1.3.0 / 1.4.0 across three draws, and this arm is the high one. Nothing charged here depends on it: 1.6.0 is above every reading.",
      "api": "knowledge boundary",
      "introduced_in": null
    }
  ],
  "summary": "The second test arm, and the one that charged the surface the battery was built for. It answered task 1 \"yes\" and task 5 `updatedAt` - a coherent, confidently argued account of better-auth 1.5.0 offered as the current behaviour - and denied both 1.6.0 options exist. Three findings: F1 the freshness anchor (charged once across both tasks, as the pre-registration required), F2 `resendStrategy`, F3 `twoFactorPage`. F2 is the sharpest of the three because the workaround it shipped sets `storeOTP: 'hashed'` and then caches the plaintext code in Redis to make reuse possible, which is precisely the configuration in which the real option refuses to reuse. Its stated cutoff is June 2026, two months after the release it missed."
}
