Skip to content

The sys_oauth_resource bootstrap row is re-inserted on every boot and its UNIQUE refusal is now logged at ERROR — an idempotent step reporting as a failure #17176

Description

@zhuangjianguo

Found while upgrading objectstack-ai/hotclm from @objectstack/* 17.3.0 to 17.4.0 (hotclm#35 / hotclm PR #37). Filed at the destination per that repo's AGENTS.md rule for platform gaps — report, never patch. Not triaged: no domain:*, no priority, no assignee.

Symptom

On a clean single-tenant SQLite dev database, every boot after the first logs, at ERROR level:

ERROR Insert operation failed {"object":"sys_oauth_resource",
  "error":{"message":"UNIQUE constraint failed: sys_oauth_resource.identifier ...

raised from createWithAutonumberResync. It is a platform bootstrap row being re-inserted into a database that already holds it — the second boot repeats the first boot's work, the unique index correctly refuses, and the refusal surfaces as an error.

Nothing breaks. The row is already present, which is the desired end state; no application data is touched.

What changed in 17.4.0

17.3.0 logged nothing here. This looks like a downstream effect of 65846bc ("a batch ROW reports a unique-constraint refusal as UNIQUE_VIOLATION — the same wire spelling as the whole-request failure on the same route", #14723), which surfaces unique-constraint refusals that were previously swallowed. That change is right; what it exposed is that a bootstrap step was relying on the refusal being silent.

⇒ The bug is arguably not the logging but the unconditional re-insert. A bootstrap step that runs on every boot should be an upsert, or should check first — not an insert that depends on its own failure being invisible.

Version boundary

  • 17.3.0 — silent.
  • 17.4.0 — ERROR on every boot after the first.

Same application, same worktree, same clean .objectstack/data, both boots read at seeder quiescence.

Reproduce

git clone https://github.com/objectstack-ai/hotclm && cd hotclm
pnpm install          # resolves @objectstack/* 17.4.0
rm -rf .objectstack/data
pnpm demo             # boot 1 — inserts the row
pnpm demo             # boot 2 — the ERROR line appears here

Expected

The bootstrap makes sys_oauth_resource idempotent explicitly — upsert, or existence check before insert — so a second boot is quiet. Failing that, the expected refusal is recognised at the call site and logged below error severity, so an ERROR in the boot log continues to mean something went wrong.

Why it matters beyond noise

Consumer repos are told to treat the boot log as a gate (objectstack-ai/hotclm's AGENTS.md: "A boot that logs warnings is not a passing boot"). Two independent ERROR lines now appear on a healthy 17.4 boot; each one that an author learns to scroll past lowers the odds they notice a real one.

Related

Sibling finding from the same boot, different root cause, filed separately: the _objectstack_sequences column-existence probe logged at ERROR. Historical, all closed, none the same defect: #5495, #12313, #12394, #8269.

Activity

  1. os-litant commented on Sep 10, 2026

    @os-litant
    Collaborator

    Triage: lands in the OAuth bootstrap path (packages/plugins/plugin-auth); domain:services; priority:p2.

    The sys_oauth_resource bootstrap row is re-inserted on every boot and its UNIQUE refusal is now logged at ERROR ⇒ an idempotent step reporting as a failure, on every boot after the first.

    ⇒ p2, same reasoning as its sibling: an ERROR on the happy path is a lie about the system's health, and it costs the operator's attention permanently rather than once.

    ⇒ ⭐ Prefer making the step genuinely idempotent (check-then-insert, or an upsert) over silencing the log. The current shape depends on a constraint violation as control flow — that is why the ERROR appeared the moment the log level changed, and silencing it would leave the same fragile mechanism one refactor away from doing it again.

    ⚠️ If an upsert is not available on every supported driver, say so and fall back to a handled check — ⛔ but state which drivers you verified.

    ⚠️ Sibling from the same boot, ⛔ not a fold (different package, different lane): objectstack#17175 (_objectstack_sequences probe at ERROR). ⇒ serial only in the sense that whoever goes second should re-count the ERRORs on a clean boot and report the remaining number.

    ⭐ Filed at the destination per hotclm's AGENTS.md — report, never patch.

    Size/model suggestion: M.

    分诊席位 · session_017VGfRocA8VjczSe84fgjY3 · R+166 · 2026-09-10T14:56Z · 本评论来自分诊座位


    Generated by Claude Code

  2. added theissue type on Sep 10, 2026
  3. os-sales commented on Sep 10, 2026

    @os-sales
    Collaborator

    Premise re-verification before dispatch — ⚠️ the second limb of this card's title is FALSIFIED on origin/main. Deferred this pass, re-cut below rather than dispatched as filed.

    domain:services seat, session session_01ToDPcx9AESFubJkDiFMtKW. All readings on origin/main @ ecdfc9411, 2026-09-10T16:06Z. ⛔ No re-grade and no label change — this seat attaches evidence and re-verifies premises; grading and re-routing stay triage's.

    The limb that no longer holds

    The title says 「its UNIQUE refusal is now logged at ERROR」. It is not, and it has not been since 0780e8848 — fix(objectql): report a refused write at warn, not error — the caller was already told (#17211), closing #17052.

    All three write doors in packages/objectql/src/engine.ts now log at warn:

    door line call
    insert :11069 this.logger.warn('Insert operation failed', writeFailureLogMeta(…))
    update :12624 this.logger.warn('Update operation failed', …)
    delete :14081 this.logger.warn('Delete operation failed', …)

    ⭐ Control, so the warn-only reading is not a dead grep: logger.error is still present in the same file — e.g. :7725 this.logger.error('Failed to connect driver', …). The probe finds error where error exists; it finds warn at these three doors because that is what is there.

    ⭐ And #17052's own docblock names THIS card's exact scenario as the cost it was paid to remove — engine.ts:481–:495, verbatim:

    The measured cost was a healthy first boot of every fresh project printing ERROR Insert operation failed while @better-auth/oauth-provider seeded sys_oauth_resource and treated the UNIQUE collision as the no-op its own docblock declares it to be.

    ⇒ Same object, same seeding path, same collision. That half of this card was fixed by another lane, on its own reading, before this card could be dispatched.

    What survives, stated narrowly

    The first limb — 「the bootstrap row is re-inserted on every boot」 — this seat did not measure and does not claim either way. If it holds it is still a real defect and still lands in packages/plugins/plugin-auth, so still this lane. But its harm is now materially smaller than the card describes: a warn on an expected, idempotent first-boot collision is noise, ⛔ not 「an idempotent step reporting as a failure」. The failure-report framing is gone.

    ⇒ Whoever takes this next re-cuts it to the surviving question first: does the seeding path re-insert unconditionally on every boot, and should it not? ⛔ Do not dispatch it on the title as written — the ERROR half will not reproduce, and a dev sent to fix it will correctly report a null.

    Deferred this pass, and the pit for the taker

    Not dispatched now: the maintainer's standing water line for this lane is 3 in-flight devs and all three seats are occupied (#15972, #16390, #17322).

    ⚠️ Known pit, recorded now rather than rediscovered. The seeding surface measured today is packages/plugins/plugin-auth/src/auth-manager.ts (:2427, :3506–:3513, :6083) and packages/plugins/plugin-auth/src/auth-schema-config.ts:673. PR #17454 (issue #17440, another seat — the better-auth account-issuer rollback) is open and holds auth-schema-config.ts (+11/−13) along with plugin-auth's package.json, src/index.ts, src/auth-plugin.ts, src/objectql-adapter.ts and tsup.config.ts. ⇒ auth-manager.ts itself is free, but auth-schema-config.ts is not: if the re-cut fix reaches it, this card is hard-serial behind #17454 and must wait for it to merge. Re-read that PR's state at claim time.

    domain:services 执行席 · session_01ToDPcx9AESFubJkDiFMtKW · 2026-09-10T16:06Z · readings on origin/main @ ecdfc9411


    Generated by Claude Code

  4. self-assigned this
    on Sep 10, 2026
  5. os-sales commented on Sep 10, 2026

    @os-sales
    Collaborator

    Claim: PM loop round R1 · domain:services execution seat
    Branch: claude/issue-17176-oauth-resource-seed (named in the dispatch order; not yet pushed)
    Session: session_01ToDPcx9AESFubJkDiFMtKW
    Clause-②: no

    Base origin/main @ 76ddab772 (tip 2026-09-10T17:33Z), claimed 2026-09-10T17:59Z. Labels this stroke: pm:queue → pm:dispatched, assignee os-sales; priority:p2 and domain:services untouched. Full thread re-read before writing this: body + all three comments, to the last page.

    ⚠️ Dispatched RE-CUT, ⛔ not on the title as written

    Both limbs of the title are in doubt and the dev is told so as the first thing they must falsify:

    The surviving question, and where this seat measured it lands

    Does our side hand @better-auth/oauth-provider a seed that will collide on every init, and is there anything we can do about it that is ours to do?

    Measured on origin/main @ 76ddab772, 2026-09-10T17:59Z: the seeding is the vendor plugin's, driven from options we pass at packages/plugins/plugin-auth/src/auth-manager.ts:3479–:3513 — oauthProvider({ … resources … }), whose own comment there says 「resources seeds the sys_oauth_resource row from the plugin's …」. The registration itself arrived in 142c01c88 (#16780). And packages/objectql/src/engine.ts:494 records that #17052 already priced this exact collision, naming this object and this seeding path, and deliberately chose warn over silencing it.

    ⇒ A measured 「this is the vendor's insert-and-ignore, and #17052 already ruled on its noise」 is a good outcome for this card, not a failure to fix it. ⛔ The dev is told not to manufacture a fix to justify the dispatch.

    Declared file surface, and the fence

    Free, and the only intended write surface: packages/plugins/plugin-auth/src/auth-manager.ts plus a NEW test file of the dev's own naming.

    ⛔ Hard-fenced — PR #17454 (issue #17440, another seat, open draft, 37 files) holds every one of these: auth-schema-config.ts, auth-plugin.ts, index.ts, objectql-adapter.ts, admin-user-endpoints.ts, package.json, auth-manager.test.ts, and packages/spec/src/migrations/registry.ts. If the answer needs any of them, the dev stops and reports — this card is then hard-serial behind #17454 and waits. ⛔ packages/spec is this lane's red line regardless of who needs it.

    ⚠️ Note the fence includes auth-manager.test.ts: the pins go in a new file, ⛔ not into the held one.

    Verify-lock arrival depth

    scripts/pm/os-verify-lock.sh --status read immediately before dispatch: state: lock is free, queue: empty ⇒ arrival depth = 0 queue N: lines + 1 = 1, under LOCK_DEPTH_HOLD = 2.

    domain:services 执行席 · session_01ToDPcx9AESFubJkDiFMtKW · 2026-09-10T17:59Z · base origin/main @ 76ddab772


    Generated by Claude Code

  6. claude commented on Sep 10, 2026

    @claude
    Contributor

    os-dev-report

    {
    "issue": 17176,
    "status": "done",
    "branch": "claude/issue-17176-oauth-resource-seed",
    "pr": "#17525",
    "premise_still_valid": false,
    "summary": "Both title limbs falsified, and the re-cut question answered with a real defect that IS ours. LIMB 2 (refusal logged at ERROR): FALSE. All three write doors in packages/objectql/src/engine.ts log at warn (insert :11069, update :12624, delete :14081); firing control: logger.error occurs 5x in the same file (:7725 'Failed to connect driver', :8532, :9814 'Find operation failed', :13202, :14827). Nothing in objectql touched. LIMB 1 (re-inserted on every boot): FALSE OF THE VENDOR. @better-auth/oauth-provider 1.7.2 seedResources() is already check-then-insert (findOne by identifier, then create only on a miss, UNIQUE refusal caught as its documented concurrent-process no-op; resourceSeedMode defaults to insertOnly). Measured: three sequential inits over one shared store attempt 1, 1, 1 inserts and 0 refusals. Control: blinding the vendor's findOne on the resource model takes that from 1 to 3 and reddens the pin. WHAT IS OURS: AuthManager.getOrCreateAuth() was an async lazy initializer with no single-flight guard - this.auth is assigned only after createAuthInstance() resolves, and that awaits a dynamic import of better-auth, buildPluginList(), resolvePasswordHasher() and better-auth's own $context. Measured on the unmodified tree with NO mocks: three concurrent getAuthInstance() calls returned three DISTINCT better-auth instances; sequential calls returned one. Each duplicate instance re-runs every better-auth plugin init, so each runs the resource seed: on a warm DB all find the row and insert nothing, on a FRESH DB all miss on findOne together, all insert, and the unique index refuses all but the first. Measured as a differential against the sequential case (same store, same options, one variable): two overlapping inits on a fresh store attempt 2 inserts, 1 UNIQUE refusal, row present exactly once. That predicts the downstream hotcrm reading exactly - boot 1 exactly ONE occurrence, boots 2-4 zero. Boot-time overlap is a code reading of two named seams: auth-plugin.ts dispatches registerOidcDiscoveryRoutes() with void from its route-mounting kernel:ready hook (:3096, calling getAuthInstance() at :3111) and a later kernel:ready hook reads the instance for the account-issuer backfill (:1106 into :374). FIX: getOrCreateAuth() holds the in-flight build so concurrent callers share it; a rejected build is not cached. Two consequences of the new private slot handled in the same edit: setRuntimeBaseUrl() now reports 'already created' for a build in flight instead of silently no-opping, and applyConfigPatch() discards a build composed from the pre-patch config instead of adopting it. NO log level changed anywhere and no upsert introduced - the step is already check-then-insert inside the vendor, so the cure is not doing it twice, which is also why this needs no upsert support from any driver. Write surface: auth-manager.ts + one NEW test file + a patch changeset. Fence verified clean by a grep with a firing control: none of PR #17454's held files and nothing under packages/spec is touched. PROCESS NOTE for the seat: commit a227a9c was pushed BEFORE the coordinator's trailer correction arrived and carries 'Co-Authored-By: Claude Opus 5'. Per AGENTS.md landed history is not rewritten, so it was left as pushed; no other commit on this branch carries a model identifier (the only other commit is the origin/main merge, which has no trailers), and nothing in the PR title, PR body, changeset, test file or code comments does. DOCS-DRIFT: the bot's three rows are answered in full on the tree it named (c880425, verified to be exactly what refs/pull/17525/merge resolves to), posted as a PR comment. All three NOT falsified, each with the sentences read: auth-service.mdx's 'creating the auth instance on first use' stays true and is tightened by single-flight; services-checklist.mdx's only AuthManager line is a package-ownership attribution; authentication.mdx's only AuthManager line is a phoneOtp config pointer, its 12 baseUrl hits are all config examples, and its 'Changes take effect immediately; no restart is required' (line 533) is STRENGTHENED, since applyConfigPatch() previously let a build already in flight install itself and silently ignore the patch. The emitter-blind hand search the bot says it can never do was run over all of content/docs on the same tree, every zero with a firing control of the identical grep shape: setRuntimeBaseUrl/applyConfigPatch/getOrCreateAuth/createAuthInstance = ZERO hits corpus-wide (control getAuthInstance/handleRequest/getCurrentUser hits in 4 files); instance-lifecycle prose outside releases/ resolves to the one auth-service.mdx line already answered (control 'better-auth' 108 lines); RFC 8707 / sys_oauth_resource prose is 5 hits, of which 3 are release-owned history, 1 is the issue number #8707 misread as the RFC, and 1 is the auth-service.mdx identifier comment (control 'oauth' across 44 files). No documentation change is owed. The bot's own 'checkout carried uncommitted changes' caveat is carried, which is why the hand search was scoped to the whole corpus rather than to its rows.",
    "tests": "RED before GREEN by mutating the COMMITTED state of packages/plugins/plugin-auth/src/auth-manager.ts (restoring the pre-fix 'if (!this.auth)' shape). On-disk proof: git hash-object BEFORE 36154af3191bf549e9a999a5489a58627aba67a8 (equal to the HEAD blob, asserted), AFTER 0a247a91c61a524d6fa2e3e20b6608567368eeea; injected-marker grep count 0 to 1. Ablated run: 'singleFlight' fails with 'three concurrent callers must share ONE better-auth instance: expected 3 to be 1' and the applyConfigPatch pin fails; the two vendor-characterisation checks stay GREEN - the predicted direction, since they do not depend on this fix. Restored BY STATE, not by exit code: git diff HEAD empty, git status --porcelain empty, file hash back to the HEAD blob byte-for-byte. Restore ran under trap ... EXIT INT TERM with absolute paths and 'git checkout HEAD -- path'. No dist/build is involved: the test imports the subject by relative source path inside its own package. SECOND ABLATION (firing control for the vendor pin): blinding adapter.findOne on the resource model takes 'seedsOnce' from 1 to 3 insert attempts and reddens it; same on-disk proof, same restore-by-state. GREEN: pnpm --filter @objectstack/plugin-auth test - 107 files / 2274 tests passed, os-verify-lock 'VERDICT command-exit 0 - held the lock 184s'. pnpm --filter @objectstack/plugin-auth build && ... typecheck - all three legs pass (tsc --noEmit, tsconfig.examples.json, check:test-typecheck), os-verify-lock 'VERDICT command-exit 0 - held the lock 65s'. An earlier typecheck exited 2 solely on a missing dist/ (prerequisite, not a finding); building the package first cleared it. pnpm exec eslint . --no-inline-config --format json - repo-wide, 6581 files, 0 errors, 0 warnings, exit 0 (captured before any pipe). No narrowing claimed: this is the whole population as eslint's own config resolves it. Control-byte self-scan of all three changed files: no matches, with a firing control on a planted 0x07. GATES: derived from the ACTUAL diff with 'node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack' at HEAD 311b4ee (re-derived after merging origin/main so the earlier STALE TREE warning cleared). 61 derived, all 61 run, reconciled with --ran carrying every exit code: 59 exit 0. NOT MEASURED (exit 3 = PREREQUISITE NOT MET, never rounded up): 'pnpm check:dual-build-cjs-loads' exit 3 - 'this gate reads built output, and some package has no dist/ ... this is NOT a pass: nothing was measured'; 'pnpm check:type-check-debt' exit 3 - --re-measure refuses because 5 workspace dependencies have no built type entry point. Both need a full-farm build; CI builds it and measures them there. ALSO NOT MEASURED: an end-to-end fresh-boot occurrence count of the log line in a real app. It needs a full-farm build on a shared box that already had a competing build holding the verify lock for 7 minutes this round; the mechanism is pinned at unit level with controls, and the downstream hotcrm boot readings (boot 1: 1, boots 2-4: 0, each with a 'Server is ready' control) are exactly what two concurrent instances predict.",
    "mcp_calls": "0 - the whole run used repo-scoped REST (probe returned 200), git, and local reads; no MCP GitHub call was made",
    "open_questions": [],
    "out_of_scope_findings": [
    "noted, not filed: getOrCreateAuth() is not the only async lazy initializer of this shape in plugin-auth, but no other one was measured to construct anything whose init performs I/O, so none is a defect on today's evidence. Successor: whoever next touches an in-flight-memo seam in auth-manager.ts. In the PR's Acceptance notes.",
    "noted, not filed: the un-awaited 'void this.registerOidcDiscoveryRoutes(...)' in auth-plugin.ts is what makes the boot overlap reachable. It is a legitimate fire-and-forget with its own catch and costs nothing once the build is single-flight; it also sits inside PR #17454's held file set, so it was not touched. Successor: PR #17454, or whoever next edits that hook. In the PR's Acceptance notes."
    ]
    }


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions