Skip to content

D7 collision guard derives a narrower plugin surface than the auth manager loads — sys_user.phone_number is a real overlap it cannot see #7820

Description

@os-help

Observation

managed-extension-fields.test.ts (ADR-0105 D7) derives better-auth's owned column surface from a single plugin:

getAuthTables({ plugins: [organization({ teams: { enabled: true } })] })

auth-manager.ts assembles many more (bearer always; organization, twoFactor, admin, phoneNumber, jwt, deviceAuthorization, magicLink, genericOAuth, sso, scim, … behind AuthPluginConfig flags), and the sibling gate better-auth-schema-parity.test.ts already derives its surface from that wider set with the reasoning written down: "Plugins that are feature-flagged off in some deployments are still included: the column has to exist before the flag can be turned on."

D7 makes the opposite choice and never says why. The consequence is measurable.

The overlap it cannot see

MANAGED_EXTENSION_FIELDS.sys_user declares phone_number as an ObjectStack extension field. better-auth's phoneNumber plugin extends the user model and this repo maps it explicitly to that exact column — AUTH_PHONE_NUMBER_USER_FIELDS in auth-schema-config.ts:

phoneNumber          -> phone_number
phoneNumberVerified  -> phone_number_verified

Measured against the pinned better-auth 1.7.0-rc.2, resolved user columns:

  • with D7's plugin set: name, email, emailVerified, image, createdAt, updatedAt
  • with the auth manager's set: the same plus twoFactorEnabled, role, banned, banReason, banExpires, phone_number, phone_number_verified

So sys_user.phone_number is simultaneously a declared ObjectStack extension field and a column better-auth writes whenever plugins.phoneNumber is on. That is the ownership-transfer condition D7 exists to fail on, and D7 reports green because the plugin that owns the column is not in its derivation.

phone_number is the only overlap today. I checked the rest against the wider surface: manager_id, primary_business_unit_id, ai_access, source on sys_user, all of sys_organization and all of sys_invitation are clean, and better-auth's role/banned/ban_*/two_factor_enabled are not claimed by the registry.

What the decision actually is

Not "widen the plugin list and go green" — widening it turns D7 red on sys_user.phone_number, which is the honest outcome and a real question:

  1. phone_number is better-auth's, and its MANAGED_EXTENSION_FIELDS entry is wrong. Drop it. Consequence: it stops being registered as an ObjectStack column, which is what the D2 write guard reads. It is not in MANAGED_EXTENSION_EDITABLE_FIELDS, so no generic write surface loses an affordance.
  2. phone_number is ObjectStack's and the plugin must be mapped elsewhere. Costs a rename plus a migration, and contradicts the mapping auth-schema-config.ts already ships.
  3. Both, deliberately shared — which D7's own header calls the thing that must never happen ("one side clobbers the other with no error").

Reading 1 looks right on the evidence, but it is an ownership call on an identity column, so filing rather than guessing.

Severity note

Bounded today: phone_number is declared but not generically editable, so no generic write path targets it, and the plugin is opt-in. What is not bounded is the gate — D7 will keep answering green about a column it is not looking at, and the same blindness covers every other plugin outside its list.

Related

Found while implementing #7770 (the unmapped-object half of the same guard). #7770's fix accounts for objects the map skips; this is the other axis — columns the derivation skips because the owning plugin is not loaded. sys_two_factor / sys_device_code / sys_jwks are registered in that PR's new exemption registry with exactly this reason, and each carries the note that the exemption holds only while no extension field is declared on them.

Nothing in #7770's PR changes the plugin set, deliberately: doing so is this issue.

Filing unassigned.


Generated by Claude Code

Activity

  1. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    Maintainer ruling — 2026-08-12

    裁定:读法 1 —— phone_number 归 better-auth;同时把 D7 的推导面拉宽。

    要点:

    • sys_user.phone_number 从 MANAGED_EXTENSION_FIELDS 删除:auth-schema-config.ts 已发货的显式映射(phoneNumber → phone_number)是所有权证据;该字段不在 MANAGED_EXTENSION_EDITABLE_FIELDS,无通用写面损失。
    • D7 的插件推导拉宽到与 auth manager 实际装配一致,采用 better-auth-schema-parity 已写下的同一条理由(「feature-flag 关闭的插件也要算 —— 列必须先存在,flag 才能打开」),并把这条理由写在 D7 处;拉宽后 D7 对本次删除应转绿,对未来任何新 overlap 应真实转红。
    • ⛔ 不选读法 2(改映射 + 迁移)—— 与已发货映射矛盾;⛔ 不选读法 3(有意共享)—— D7 的存在意义就是拒绝这个。
    • managed-extension-fields D7 collision guard silently skips sys_api_key (no apikey entry in MODEL_TO_OBJECT) #7770 的豁免登记(sys_two_factor / sys_device_code / sys_jwks)携带的「仅在无 extension field 声明时成立」注记保持不动。

    裁定人:维护者 huangyiirene(2026-08-12,接受 PM 综合分析后批准);由 PM 会话 session_01GZKbx4xyF7U5WXj6ch49BM 代笔落卡。转 pm:queue。


    Generated by Claude Code

  2. self-assigned this
    on Aug 12, 2026
  3. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    Claim: PM loop round 3 (domain:identity seat #6022)
    Session: session_01D6Qi9sYxhaRwj7TYiD5MWg
    Branch: claude/issue-7820-d7-plugin-surface
    Worktree: objectstack-issue-7820
    Domain: domain:identity
    File surface: packages/plugins/plugin-auth/src/managed-extension-fields.ts (the sys_user.phone_number removal) + managed-extension-fields.test.ts (the D7 derivation widening) + a changeset.
    Container & model: M (a security-adjacent whitelist edit plus a guard-surface widening), mode:cloud, model: opus
    Serial constraints cleared: the one real risk is named and fenced. PR #7879 (#7724) is in the merge queue right now and touches packages/plugins/plugin-auth/src/objectql-adapter.ts + auth-manager.ts. This card's surface is managed-extension-fields.{ts,test.ts} — disjoint files in the same package. auth-manager.ts is therefore READ-ONLY for this card: the D7 widening must read the auth manager's plugin assembly to mirror it, ⛔ not edit it. Breach ⇒ stop and report.

    Executing the maintainer's ruling of 2026-08-12 (读法 1)

    Two halves, both from the ruling, ⛔ neither re-litigated:

    1. Delete sys_user.phone_number from MANAGED_EXTENSION_FIELDS — the shipped explicit mapping in auth-schema-config.ts (phoneNumber → phone_number) is the ownership evidence, and the field is not in MANAGED_EXTENSION_EDITABLE_FIELDS, so there is no generic-write-surface loss.
    2. Widen D7's plugin derivation to match the auth manager's actual assembly, adopting the reason better-auth-schema-parity already records — 「feature-flag 关闭的插件也要算 —— 列必须先存在,flag 才能打开」 — and write that reason at the D7 site.

    ⛔ Reading 2 (change the mapping + migrate) and reading 3 (deliberate sharing) are both explicitly refused by the ruling. ⛔ #7770's exemption registrations (sys_two_factor / sys_device_code / sys_jwks) and their "only holds while no extension field is declared" note stay untouched.

    The acceptance shape the ruling itself dictates

    The ruling states the post-condition directly, and it is a two-directional pin: after widening, D7 must go green on this removal, and must go genuinely red on any future overlap. So the second half is not optional decoration — a widening that only makes today's case pass, without demonstrating it would catch the next one, has implemented half the ruling.

    ⚠️ This is the guard whose sibling was fixed only last night: #7770/PR #7822 closed the unmapped-object blind spot and its dev reported this second axis as out-of-scope, which is how this card exists. That PR's own failure text explicitly refuses "complete MANAGED_EXTENSION_FIELDS to make the comparison look meaningful" — ⛔ do not undo that reasoning while widening the derivation; the two changes must compose, not fight.


    Generated by Claude Code

  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator
    {
      "issue": 7820,
      "status": "done",
      "branch": "claude/issue-7820-d7-plugin-surface",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7980",
      "premise_still_valid": true,
      "summary": "Executed both halves of the 2026-08-12 ruling. (1) `sys_user.phone_number` removed from MANAGED_EXTENSION_FIELDS — the shipped `phoneNumber -> phone_number` mapping in auth-schema-config.ts is the ownership evidence, and no write surface moves (the only consumer of these maps in packages/ is auth-plugin.ts, which reads the EDITABLE map only; the admin bulk-import that does upsert the column runs system-context off SYS_USER_IMPORT_UPDATE_FIELDS). (2) D7's derivation now loads the auth manager's whole 14-factory plugin set instead of `organization` alone, with the parity gate's reason written at the D7 site. auth-manager.ts was read, never edited — the #7879 fence held. The ruling's second post-condition (genuinely red on a FUTURE overlap) is pinned in-repo, not just asserted: findCollisions() is extracted as a pure function and exercised against a synthetic registry declaring `two_factor_enabled`, a column only a widened plugin contributes; plus a drift tripwire that scans auth-manager.ts's plugin imports, a literal list of the widened columns, and a plugin set derived from the accounting map itself so `covered` cannot be declared without loading.",
      "tests": "pnpm --filter @objectstack/plugin-auth test -> 43 files / 1046 tests passed (D7 file 19 tests, was 12). pnpm --filter @objectstack/plugin-auth typecheck -> clean, BUT that script excludes *.test.ts (TEST_DEBT package), so it does not read this diff's main file; reproduced the ratchet's own measurement instead (tsconfig with the test glob dropped from `exclude`): 107 raw tsc errors on origin/main vs 107 on this branch — ZERO added, and well under the ledger's recorded 131. The one diagnostic in the changed test file is the pre-existing import.meta TS1470, which only moved line. eslint --no-inline-config on both changed files -> clean, no inline disables used. node scripts/check-nul-bytes.mjs -> OK (7279 files) plus a direct control-byte grep of the changed files. pnpm check:adr-anchors -> clean. Consumer sweep, DOWNSTREAM direction (--filter '...@objectstack/plugin-auth', prefix form = the 16 packages that depend on it: cli, client, client-react, cloud-connection, dogfood, driver-turso, example-crm, example-showcase, example-todo, hono, http-conformance, plugin-auth, plugin-dev, runtime, service-sms, verify) -> exit 0, all green (a full `turbo build` was required first; the first attempt's reds were an unbuilt dist, not this diff). PREDICT-THEN-MUTATE, all directions written down before mutating: (a) restore phone_number -> RED with exactly ['sys_user.phone_number'] — matched; (b) delete the phoneNumber plugin entry -> predicted RED on the widened-columns pin + the tripwire, OBSERVED 3 reds not 2 (the synthetic re-declaration pin also fires, correctly, since narrowing removes phone_number from the surface) — reported as observed, not as predicted; (c) mark phoneNumber as a documented `skip` -> RED on the widened-columns pin, tripwire green — matched, proving a 'documented' narrowing still cannot pass; (d) feed the schema overrides in -> RED on the additionalFields pin — matched. CI gate status at report time: in_progress (not polled, per the dispatch contract).",
      "open_questions": [
        {
          "question": "Now that the widened derivation emits the `twoFactor` / `jwks` / `deviceCode` models, mapping sys_two_factor / sys_device_code / sys_jwks into MODEL_TO_OBJECT is free and would give them real D7 collision coverage. The ruling said those #7770 exemptions stay unmoved, so I did NOT map them — but their reason text claimed the plugins were 'not loaded by this call', which my change makes false, so I restated the reasons in place while keeping the entries and the 'only holds while no extension field is declared' note verbatim in intent.",
          "options": [
            "A — leave as shipped: entries kept, reasons restated to say the models are derived but deliberately unmapped (what this PR does)",
            "B — follow-up card: map the three models, drop the three exemptions, extend COVERED_OBJECTS to twelve"
          ],
          "recommendation": "A for this PR, because reversing 'exemptions stay unmoved' is the maintainer's call, not mine — and B as a cheap follow-up: the derivation change already paid for it, so the remaining cost is three MODEL_TO_OBJECT lines plus three COVERED_OBJECTS entries. Flagging rather than filing, since it is an amendment to this same ruling rather than an independent finding."
        },
        {
          "question": "D7 now constructs @better-auth/oauth-provider, @better-auth/sso and @better-auth/scim, which are separately-pinned packages the sibling parity gate deliberately excludes. They contribute no column to any MAPPED object today, so including them buys only future coverage of user/session/account.",
          "options": [
            "A — keep them (fidelity to 'the auth manager's actual assembly', catches a future user column from any of the three)",
            "B — drop them and register a `skip` reason, matching better-auth-schema-parity.test.ts's separation"
          ],
          "recommendation": "A, as shipped: the ruling's words are 'to match what the auth manager actually assembles', and the mechanism costs nothing — the accounting map's `skip` disposition exists precisely so B can be chosen later with the reason written down."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    Collaborator

    PM review: ACCEPT — PR #7980. Seat #6022, session session_01D6Qi9sYxhaRwj7TYiD5MWg. Flipping ready + queueing.

    Verified against GitHub, not against the report

    check result
    Path face — docs/adr/** / .claude/skills/** / skills/** ✅ 0 hits
    The #7879 fence — auth-manager.ts untouched ✅ 0 occurrences in the diff
    Diff scope exactly the 3 declared files, nothing else
    Per-job CI ✅ ESLint 09:23Z, TypeScript Type Check 09:29Z, all 25 completed
    First line Fixes #7820 ✅ correct — both ruled halves implemented

    Ruling half 1 checked by declaration form, not by a bare grep. phone_number still returns 3 hits in the map file — all three are comments recording the removal and its evidence. A declaration-form scan (^\s*phone_number\s*:) returns nothing, while the control (sys_user:) still resolves, so the scanner works and the entry is genuinely gone. Worth stating because "grep found the string" and "the entry survives" are the same reading if you stop at the first command.

    Ruling half 2 is the part I most wanted, and it was pinned rather than asserted. The ruling's post-condition was "拉宽后 D7 对本次删除应转绿,对未来任何新 overlap 应真实转红" — the second clause is the one that is easy to claim and hard to demonstrate. You extracted findCollisions() as a pure function and exercised it against a synthetic registry declaring two_factor_enabled — a column only a widened plugin contributes — plus a drift tripwire that derives its plugin set from the accounting map itself, so covered cannot be declared without loading. That is the future direction made mechanical.

    And the TEST_DEBT trap was handled instead of stepped in. pnpm --filter @objectstack/plugin-auth typecheck excludes *.test.ts for this package, so it does not read this diff's main file — "typecheck clean" would have been a true statement that proved nothing about the thing you changed. Reproducing the ratchet's own measurement (107 raw errors on origin/main vs 107 on the branch, zero added) is the right substitute. That is precisely the failure mode the dispatch warned about, and you did not take the free green.

    Also noted: ablation (b) produced 3 reds where you predicted 2, and you reported it as observed rather than back-filling the prediction table — with the correct explanation (narrowing removes phone_number from the surface, so the synthetic re-declaration pin fires too).

    Your two open questions — both answered, one filed

    Q2 — keep oauth-provider / sso / scim in the derivation? → A, as shipped. The ruling's words are "match what the auth manager actually assembles"; fidelity is the instruction, and the accounting map's skip disposition means B stays available later with a written reason. No action.

    Q1 — map sys_two_factor / sys_device_code / sys_jwks now that the widened derivation emits their models? → A for this PR, and I am filing B. You were right not to take it: the ruling says those #7770 exemptions stay unmoved, and reversing an explicit ruling line is the maintainer's call, not a dev's and not mine. Restating their reason text was correct and necessary — the old wording claimed those plugins were "not loaded by this call", which your change makes false, and leaving a now-false justification in place would have been its own small declared≠actual defect.

    Filing B as a follow-up card so the cheap coverage is not lost: three MODEL_TO_OBJECT lines and three COVERED_OBJECTS entries buy real D7 collision coverage on three identity tables, and the derivation change has already paid for it.


    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

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions