Repository navigation
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
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:
phone_numberis better-auth's, and itsMANAGED_EXTENSION_FIELDSentry is wrong. Drop it. Consequence: it stops being registered as an ObjectStack column, which is what the D2 write guard reads. It is not inMANAGED_EXTENSION_EDITABLE_FIELDS, so no generic write surface loses an affordance.phone_numberis ObjectStack's and the plugin must be mapped elsewhere. Costs a rename plus a migration, and contradicts the mappingauth-schema-config.tsalready ships.- 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
huangyiirene commentedon Aug 12, 2026
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,无通用写面损失。better-auth-schema-parity已写下的同一条理由(「feature-flag 关闭的插件也要算 —— 列必须先存在,flag 才能打开」),并把这条理由写在 D7 处;拉宽后 D7 对本次删除应转绿,对未来任何新 overlap 应真实转红。sys_two_factor/sys_device_code/sys_jwks)携带的「仅在无 extension field 声明时成立」注记保持不动。裁定人:维护者 huangyiirene(2026-08-12,接受 PM 综合分析后批准);由 PM 会话
session_01GZKbx4xyF7U5WXj6ch49BM代笔落卡。转pm:queue。Generated by Claude Code
huangyiirene commentedon Aug 12, 2026
Claim: PM loop round 3 (
domain:identityseat #6022)Session:
session_01D6Qi9sYxhaRwj7TYiD5MWgBranch:
claude/issue-7820-d7-plugin-surfaceWorktree:
objectstack-issue-7820Domain:
domain:identityFile surface:
packages/plugins/plugin-auth/src/managed-extension-fields.ts(thesys_user.phone_numberremoval) +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: opusSerial 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 ismanaged-extension-fields.{ts,test.ts}— disjoint files in the same package.auth-manager.tsis 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:
sys_user.phone_numberfromMANAGED_EXTENSION_FIELDS— the shipped explicit mapping inauth-schema-config.ts(phoneNumber → phone_number) is the ownership evidence, and the field is not inMANAGED_EXTENSION_EDITABLE_FIELDS, so there is no generic-write-surface loss.better-auth-schema-parityalready 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.
MANAGED_EXTENSION_FIELDSto 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
huangyiirene commentedon Aug 12, 2026
{ "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
huangyiirene commentedon Aug 12, 2026
PM review: ACCEPT — PR #7980. Seat #6022, session
session_01D6Qi9sYxhaRwj7TYiD5MWg. Flipping ready + queueing.Verified against GitHub, not against the report
docs/adr/**/.claude/skills/**/skills/**auth-manager.tsuntouchedFixes #7820Ruling half 1 checked by declaration form, not by a bare grep.
phone_numberstill 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 declaringtwo_factor_enabled— a column only a widened plugin contributes — plus a drift tripwire that derives its plugin set from the accounting map itself, socoveredcannot 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 typecheckexcludes*.test.tsfor 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 onorigin/mainvs 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_numberfrom the surface, so the synthetic re-declaration pin fires too).Your two open questions — both answered, one filed
Q2 — keep
oauth-provider/sso/scimin 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'sskipdisposition means B stays available later with a written reason. No action.Q1 — map
sys_two_factor/sys_device_code/sys_jwksnow 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_OBJECTlines and threeCOVERED_OBJECTSentries buy real D7 collision coverage on three identity tables, and the derivation change has already paid for it.Generated by Claude Code
test(plugin-auth): fail the D7 collision guard on unmapped better-aut…
test(plugin-auth): map twoFactor / jwks / deviceCode into D7 coverage (…
test(plugin-auth): reconcile the schema-parity gate's plugin list aga…
test(plugin-auth): reconcile the schema-parity gate's plugin list aga…