Repository navigation
client SDK oauth.applications.register declares three request members the better-auth body schema silently strips (metadata, name, scopes) #15447
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 4, 2026 分诊 ·
domain:cli/priority:p2/pm:queueAnchor read, not guessed. The declaration to change is
ObjectStackClient.oauth.applications.registerinpackages/client/src/index.ts(:3013opensapplications:,:3043posts to/oauth2/create-client) ⇒domain:cli. The vendor route is@better-auth/oauth-provider's and is not ours to change — ⇒ this is a client-declaration card, not a plugin-auth card, and it does not go todomain:services.Grade — p2
The card names the class exactly: "the repo's first commandment pointed at the request side — the SDK declares a capability the runtime does not honour." The failure is silent by construction: the vendor body schema is
z.core.$strip, so unknown keys are dropped rather than refused, and the caller gets HTTP 201 plus a client that quietly lacks what they set. There is no error to notice and no receipt to check.Not p1: nothing breaks, nothing is destroyed, and a caller who does not set these three members is unaffected. Not p3: the near-miss pairs make this actively trap-shaped rather than merely inaccurate —
namesits one field away from the realclient_name,scopesone field away from the realscope, and the same declared type offers both spellings side by side. An author picking the wrong one of two adjacent members gets a success receipt. That is the accept-anything shape that makes AI-written code hard to get right, which is precisely why it is worth more than a doc note.⚠️ The measurement is uneven, and the card says so — carry that forward, do not launder itmetadatawas driven end to end: registered withmetadata: { tenant: 'acme', tier: 7 }against a realbetterAuth+oauthProviderover a real ObjectQL engine on real better-sqlite3, 201 returned, and the response body carries neither key. That one is measured.nameandscopesrest on reading the vendor body schema, not on a drive.
⇒ First deliverable is driving those two, not choosing a disposition. The dispositions differ per member and the drive is what tells them apart:
- if
nameis stripped, it is a near-miss forclient_nameand the question is rename-vs-delete; - if it turns out to be honoured by some path, the card shrinks to one member.
⛔ Do not treat the three as one homogeneous defect because they were filed together.
Boundary test — this crosses, and not in the direction it looks
Removing members from a published SDK request type is a narrowing of the declared surface, which normally is the safe direction. Here it is not free: deleting them changes what existing callers compile against. A caller passing
name:today compiles and silently loses the value; after deletion they get a type error. That is arguably the correct outcome — a loud failure replacing a silent one — but it is a BREAKING change to a published type and needs the categorisation and changeset that go with it. ⇒ manual floor: the disposition (delete / rename onto the real counterpart / route to a door that can honour it) is a contract decision, ⛔ not a dev's to pick mid-PR.The three plausible shapes, stated so the ruler does not have to re-derive them:
- Delete all three. Honest, loud, breaking for anyone currently passing them (who is currently getting nothing anyway).
- Rename
name→client_name,scopes→scope, deletemetadata.⚠️ Not type-compatible:scopesisstring[],scopeisstring. A rename here is a signature change, not an alias. - Keep and honour — client-side maps
name→client_nameandscopes→scope.join(' ')before posting, deletesmetadata. Preserves compilation for existing callers and makes the declaration true.⚠️ But it puts translation logic in the SDK for a vendor route, which is a maintenance burden the next@better-auth/oauth-providerbump can break silently.
⛔ Triage is not choosing among these.
Sequencing — same hard serial as #15451
⚠️ packages/client/src/index.tsis the #12104 family's measured hard serial, released on MERGE. This card and #15451 (oauth.applications.delete, graded p1 this round) touch the same file and the sameapplicationsblock within ~50 lines of each other. ⇒ They should be considered together by thedomain:cliseat (#6024): landing them as one PR is likely cheaper than two passes through a serial queue, provided the p1 is not delayed by this card's ruling. If the ruling is not quick, ⛔ ship #15451 alone rather than letting a p2 contract question hold a p1 fix.Version pin for the re-check:
@better-auth/oauth-provider@1.7.2. The vendor is on its own release cadence — this repo already chased better-auth 1.7 column drift in #3624 / #3647 — so re-read the body schema at pickup rather than trusting the member list transcribed on this card.⛔ Not a claim, not a dispatch — routing only.
Generated by Claude Code
Measurement round —
nameandscopesare now DRIVENDispatched by the
domain:cliseat (#6024) under the bound in5547794993. No code changed, no branch, no PR. ⛔ No recommendation among triage's three shapes — that is the maintainer's.What was measured at
repo head 7beaaa32ccb1295eeaec73bdca4aea36e713232d(origin/main, 2026-09-06 12:06 UTC)@better-auth/oauth-provider1.7.2 (installed, read off the resolved package — matches triage's pin) better-auth1.7.2 · better-call1.4.0 ·zod4.4.3rig bootStack(@objectstack/example-showcase)→ realbetterAuth+ realoauthProviderover the real ObjectQL engine, real TCP socket (HonoServerPluginport 0 →getPort()), driven through the realObjectStackClientwith the realglobalThis.fetchand a real session⚠️ deviation from the card's earlier rigthe datasource is sqlite-wasm(bootStack's pure-JS WASM SQLite), not better-sqlite3. Named because the card'smetadatadrive said better-sqlite3. It does not touch this verdict — every strip happens in the route's Zod body parse, before any driver is reached — but the DB read-backs below are real rows out of a real SQL database either way.Throwaway worktree, torn down; the shared checkout was never edited.
⭐ Per-member verdict
member driven? result what that implies for its disposition name✅ yes, this round stripped on the route registerposts to — but the spelling is honoured on a different, ObjectStack-owned doorIt is not simply a dead member. POST /api/v1/auth/sys-oauth-application/register(auth-plugin.ts, ledgerdisposition: 'server-only') reads a body member spelled exactlynameand maps it ontoclient_name— i.e. the repo already implements triage's shape 3, server-side, for this exact member. So "delete" removes a spelling the platform honours elsewhere; "rename ontoclient_name" is a purestring→stringrename (driven ✅).scopes✅ yes, this round stripped, on every door in the surface No endpoint anywhere in the vendor plugin declares scopes— not create, not DCRregister, notoauth2/update-client, not the admin update. And a mechanical rename ontoscopeis driven-refused (below). This is the member with no counterpart reachable by a rename.metadata✅ re-driven (positive control) stripped at create — the card's finding reproduces exactly. Also stripped by the mounted POST /oauth2/update-client. Honoured only byPATCH /admin/oauth2/update-client, which isSERVER_ONLYand therefore not mounted over HTTP at all."Route it to a door that can honour it" has a sharper meaning than it looked: the honouring door is not an HTTP route. better-call's router doesif (endpoint.options?.metadata?.SERVER_ONLY) continue(router.mjs:22), and the drive confirms it — 404, zero-byte body. It is reachable only in-process (authApi.adminUpdateOAuthClient), which the drive shows does persist the value. An SDK method cannot reach it without a new ObjectStack mount — the same gap-closing move already made foradmin/oauth2/toggle-disabled.⛔ Not one homogeneous defect. Three different answers.
The drives
All through
client.oauth.applications.register(...), real socket, real session. Status + full body verbatim.namealone — 201, and the body carries neithernamenorclient_name:{"client_id":"GshJvINrsShauIzjLxjKpcCtxsYjoOxP","client_secret":"aLPpOONfpGeJymAExjukigaMaxNLvpjt","client_secret_expires_at":0,"user_id":"omZi2cU2XEbvXSLAjFY5AUMJww1DrRYj","client_id_issued_at":1788699309,"redirect_uris":["``https://probe-15447.example.com/cb"],"token_endpoint_auth_method":"client_secret_basic","grant_types":["authorization_code"],"response_types":["code"],"application_type":"web","disabled":false,"dpop_bound_access_tokens":false}``scopesalone — 201, and the body carries neitherscopesnorscope:{"client_id":"ptjtapeKlDWMSfaIQfEShBJHlXvwrZpO","client_secret":"KMTZRWsGRklnrbIhhHdOfCCPghFdJCni","client_secret_expires_at":0,"user_id":"omZi2cU2XEbvXSLAjFY5AUMJww1DrRYj","client_id_issued_at":1788699309,"redirect_uris":["``https://probe-15447.example.com/cb"],"token_endpoint_auth_method":"client_secret_basic","grant_types":["authorization_code"],"response_types":["code"],"application_type":"web","disabled":false,"dpop_bound_access_tokens":false}``metadata: { tenant: 'acme', tier: 7 }— 201, neither key present. The card's measurement reproduces at this head.⭐ Read back through every other door — the value survived nowhere
The response body is not the only place a value could land, so all four doors were checked per client:
probe response body oauth.applications.getoauth.applications.listsys_oauth_applicationrow (ObjectQL, system read)client_name: 'CTRL-…'(control)client_name✅client_name✅✅ namecolumn ="CTRL-CLIENT-NAME-15447"name: 'PROBE-…'absent absent absent namecolumn =nullscope: 'openid profile email'(control)scope✅scope✅✅ scopescolumn =["openid","profile","email"]scopes: ['openid','profile','email']absent absent absent scopescolumn =nullmetadata: {…}absent absent absent metadatacolumn =nullThe two control rows are the sharp edge of this card.
client_namewrites the DB column literally namedname;scope(one space-delimited string) writes the DB column literally namedscopes, as a JSON array. The SDK's stripped members are spelled in the vendor's record vocabulary while the route speaks its wire vocabulary — and the method's own return type (OAuthApplication, bound in #14312) is purely wire-spelled: it declaresclient_nameandscope?: string, and noname,scopesormetadata. The request and return types of the same method disagree on which vocabulary they speak.The doors that DO honour a spelling — both driven
name→POST /api/v1/auth/sys-oauth-application/register(ObjectStack mount):{ name: 'WRAPPER-NAME-15447', redirectURLs: '…', type: 'web' }→ 200,client_name: "WRAPPER-NAME-15447", DBnamecolumn set. Ledger records itserver-only— no SDK method builds this URL.metadata→PATCH /admin/oauth2/update-client: over HTTP → 404, zero bytes (SERVER_ONLY, unmounted; the repo's ownadmin-route-nonadmin-refusal.dogfood.test.tsalready records itbucket: 'not-mounted'). In-process with a real session → persists: DBmetadata={"tenant":"acme-adminUpdateOAuthClient","tier":7}. Once stored, every read door spreads it as top-level members — a laterapplications.getreturned{"tenant":"acme-…","tier":7,"client_id":…}— which is the card's "the serialiser only ever spreads metadata that some other door stored", confirmed.- No ObjectStack mount and no vendor endpoint reads
scopesat all. The only two ObjectStack oauth mounts areadmin/oauth2/toggle-disabledandsys-oauth-application/register; neither touches it.
⭐
scopes→scopeis NOT type-compatible — refuted by drive, not by readingTriage flagged this; it is now measured. The mechanical rename, posted to the real route:
POST /api/v1/auth/oauth2/create-client { client_name: '…', scope: ['openid','profile'], redirect_uris: [...] } → 400 {"message":"[body.scope] Invalid input: expected string, received array","code":"VALIDATION_ERROR"}Through the SDK it surfaces as
Error: [body.scope] Invalid input: expected string, received array. The translated form is what passes:scope: ['openid','profile'].join(' ')→ 201,"scope":"openid profile". Soscopes→scopeis a signature change requiring a value transform, not an alias — exactly as triage said. By contrastname→client_nameisstring→string: driven, 201, no transform.⚠️ And note the asymmetry the drive exposes:scopeis validated — an unregistered value answers400 {"error":"invalid_scope","error_description":"cannot request scope probe15447:gamma"}— whilescopescarrying the same values sails through at 201 and is discarded. The real member refuses bad input; the near-miss accepts anything.
⭐ Vendor body schema re-read at the installed version
Read off
@better-auth/oauth-provider@1.7.2as resolved in this checkout, and additionally introspected at runtime (instantiateoauthProvider(), walkendpoints, readoptions.body) rather than transcribed. The card's member list is accurate — no drift at 1.7.2.POST /oauth2/create-client, 21 members:member type redirect_urisoptional<array<string>>⚠️ optional here; the SDK declares it requiredscopeoptional<string>client_nameoptional<string>client_uri,logo_uri,tos_uri,policy_urioptional<string>contactsoptional<array<string>>software_id,software_version,software_statementoptional<string>post_logout_redirect_urisoptional<array<string>>backchannel_logout_urioptional<string>backchannel_logout_session_requiredoptional<boolean>token_endpoint_auth_methodoptional<string>application_typeoptional<enum<["web","native"]>>jwksoptional<object>·jwks_urioptional<string>grant_typesoptional<array<string>>response_typesoptional<array<enum<["code"]>>>dpop_bound_access_tokensoptional<boolean>No
catchallis set, so the object is zod's default strip. Parsing a body carrying all three disputed members succeeds (ok: true) and reportsdroppedKeys: ["name","scopes","metadata"]— the$stripclaim, confirmed at the schema level.Two further schema facts that bear on disposition:
- A second, independent barrier stands behind the strip. The handler does
const { client_credentials_scopes, ...metadata } = ctx.bodyand funnels the rest of the parsed body into the opaque metadata envelope — so an unknown key that survived parsing would still be dropped bystripReservedOAuthClientMetadataExtensions, becausename,scopesandmetadataare all inOAUTH_CLIENT_RECORD_FIELD_NAMES⊂OPAQUE_METADATA_RESERVED_FIELDS. Loosening the SDK alone could never make these three arrive. metadataappears in exactly one body schema in the whole plugin:PATCH /admin/oauth2/update-client'supdateobject (record<string, unknown>) — the unmounted SERVER_ONLY door. The DCRPOST /oauth2/registerschema has none of the three either, so the legacy endpoint is not an escape hatch.
Evidence ledger — schema reads vs drives, kept apart
Schema reads (evidence about the schema only): the 21-member list and its types;
catchallabsent ⇒ strip; the reserved-field second barrier;metadatapresent only on the admin update body;better-callrouter skipping SERVER_ONLY;sys-oauth-application/registerreading a member spelledname.Drives (evidence about behaviour): every status and body above; all four read-back doors per client; the DB column contents; the 404 on the unmounted admin door; the in-process metadata persist; the 400 on
scope: string[]; the 201 onscope: joined; the 200 on the ObjectStacknamewrapper.Nothing in the per-member verdict table rests on a reading alone.
Generated by Claude Code
维护者速读 — the measurement triage asked for is done. Three members, three different answers, and one of triage's three shapes is already implemented.
domain:cliexecution PM seat (#6024). State transition, one stroke:pm:dispatchedoff,pm:awaiting-maintaineron. ⛔ Measurement only — no code changed, no branch, no commit, no PR. ⛔ This seat makes no recommendation among triage's three shapes; the disposition is the manual floor triage placed it behind.Triage's bound was "First deliverable is driving those two, not choosing a disposition" and "⛔ Do not treat the three as one homogeneous defect because they were filed together." Driven, that warning was exactly right.
The three answers
member verdict what it means for its disposition namestripped by the vendor route — but honoured by a different path ⭐ POST /api/v1/auth/sys-oauth-application/register, an ObjectStack-owned mount (ledgerserver-only, no SDK method builds it), reads a body member spelled exactlynameand maps it ontoclient_name. Driven: 200, DBnameset. ⇒ the repo already implements triage's shape 3 for this member, server-side.scopesstripped, everywhere No vendor endpoint (create, DCR register, oauth2/update-client, admin update) and no ObjectStack mount declares it. DB columnnull.metadatastripped at create; honoured only by a door that is not an HTTP route PATCH /admin/oauth2/update-clientis the sole body schema carryingmetadata, and it isSERVER_ONLY— better-call's router skips it. Over HTTP: 404, zero bytes. In-process: persists. ⇒ "route it to a door that can honour it" would require a new ObjectStack mount.The type-compatibility question triage raised — refuted by drive, as they suspected
scopes→scopeis not a rename. Driven:scope: ['openid','profile']→400 [body.scope] Invalid input: expected string, received array;scope: [...].join(' ')→ 201.name→client_nameisstring→string, driven 201.Vendor drift — none
@better-auth/oauth-provider@1.7.2(better-auth 1.7.2, better-call 1.4.0, zod 4.4.3). Re-read at that version and introspected at runtime rather than trusting the transcription: the card's 21-member list is accurate. Nocatchall⇒ zod default strip; parsing all three disputed members returnsok: truewithdroppedKeys: ["name","scopes","metadata"].⭐ Two facts the card did not have, and both bear on the ruling
1. A second, independent barrier stands behind the strip. The handler funnels the parsed body's rest into the opaque metadata envelope, but
name,scopesandmetadataare all inOPAQUE_METADATA_RESERVED_FIELDS. ⇒ loosening the SDK alone could never make them arrive. Any shape that tries to "keep and honour" has two gates to pass, not one.2. The request and return types speak different vocabularies, and the collision is inverted. The stripped members are the vendor's record spellings —
client_namewrites the DB column namedname, andscopewrites the DB column namedscopes— whileOAuthApplication, the return type bound in #14312, is purely wire-spelled.⚠️ That inverts the "near miss" reading this card was filed on.nameandscopesare not obvious typos forclient_nameandscope; they are the column names those wire fields write to. Whoever declared them may have been reaching for the record vocabulary deliberately. ⇒ a ruling that treats them as misspellings should say so knowingly.Also measured:
scopeis validated (an unknown value →400 invalid_scope) whilescopessails through at 201 and is silently discarded — the two adjacent spellings differ not only in type but in whether the server checks them at all.Rig, and one honest deviation
bootStack(@objectstack/example-showcase)— realbetterAuth+ realoauthProviderover the real ObjectQL engine, on a real TCP socket, driven through the realObjectStackClientwith realfetchand a real session.⚠️ Datasource issqlite-wasm, not better-sqlite3 as in the card's earlier rig — named rather than smoothed over, and immaterial here because every strip happens in the route's Zod body parse, before any driver is touched.Worktree torn down;
/home/user/objectstackverified clean and unmoved.⛔ Triage's three shapes stand as they wrote them. This comment adds evidence, not a preference — but note that shape 3 is now partly a description of what already exists rather than a proposal, and that shape 2 is refuted for
scopeson type grounds.
Generated by Claude Code
状态转换:
pm:awaiting-maintainer→pm:queue(2026-09-09)维护者回批逐字:「C 桶那 24 张的批量转
pm:queue同意」。判据:⭐ 本卡命中
SKILL.md:720元判据①:「静默丢弃的声明并入既有拒收集;新支复用母单裁决直接入队,真实语义差异才重开。」client SDK 的oauth.applications.register声明了三个 better-auth body schema 静默丢弃的成员(metadata、name、scopes)—— 与 #12034 同族(同族母单已于本日裁定收敛到生产方实际形状)。⇒ ⛔ 本就不该进任何箱。同笔清 assignee(
SKILL.md:104)。bug、domain:cli、finding、priority:p2留下。⚠️ 根因见 #17017。
Generated by Claude Code
8 remaining items
os-project-manager commented
on Sep 9, 2026 CollaboratorMore actionsClaim:
domain:cliexecution PM seat (#6024), branchclaude/issue-15447-oauth-register-declared-members, sessionsession_015QE8qk46e5CHJxyQEUjbf8.Clause-②: no
Re-claim of the one withdrawn at
5601375313— that withdrawal was a path collision, ⛔ not a merit problem: I had claimed this while #16534 was live on the same file. #16534 landed as PR #17182 (squashd61139f1ba) at 16:16:07Z, sopackages/client/src/index.tsis free and this card is dispatchable now. Assignee and this claim are written by the dispatching seat; the dev inherits both, ⛔ posts no second claim, ⛔ does not touch the assignee.Why
no. The maintainer discharged triage's manual floor on 2026-09-09 (5595056650) underSKILL.md:720元判据①, ruling this a reuse of the #12034 family's parent ruling — 收敛到生产方实际形状 — so no ruling is owed. What remains is a pull-back: converging a declaration onto what the route it posts to actually accepts (SKILL.md:517). Face ispackages/client/src/index.tsonly: ⛔ nopackages/spec/src/**, ⛔ no*.zod.ts, ⛔ no error-code ledger.⚠️ Breaking ≠ clause-②. This IS a breaking change to a published type — a caller passingname:compiles today and gets a type error after — and it needs a**BREAKING**changeset. It does not need a contract-review-tier PASS. ⛔ If the delivered diff adds anything (a new export, a new key, a compatibility alias member), stop and report; the declaration is provisional on the delivered diff.⛔ The measurement is DONE — do not redo it
Comment
5559384773drove all three members against a realbetterAuth+oauthProviderover a real ObjectQL engine on a real TCP socket through the realObjectStackClient. ⛔ Do not re-run that rig; build on it. Its verdict is that these are not one defect:member driven verdict namestripped by /oauth2/create-client— but ⭐ honoured byPOST /api/v1/auth/sys-oauth-application/register, an ObjectStack-owned mount that maps it ontoclient_name(driven 200, DBnameset). Ledger saysserver-only; no SDK method builds that URL.scopesstripped everywhere; no vendor endpoint and no ObjectStack mount declares it. DB column null. ⛔scopes→scopeis not a rename — driven400 [body.scope] Invalid input: expected string, received array; only.join(' ')passes.metadatastripped at create; honoured only by PATCH /admin/oauth2/update-client, which isSERVER_ONLYand therefore not an HTTP route at all (driven: 404, zero bytes). In-process it persists.Two facts that constrain any "keep and honour" shape, both measured: a second, independent barrier stands behind the strip (
name,scopesandmetadataare all inOPAQUE_METADATA_RESERVED_FIELDS, so loosening the SDK alone could never make them arrive), and the vendor list has no drift at@better-auth/oauth-provider@1.7.2.The disposition, and its one careful edge
Apply the family ruling to this method's request type:
oauth.applications.registerposts to/oauth2/create-client, and that route honours none of the three ⇒ all three come out of the type it declares.⭐ The edge, so you do not have to decide it:
namebeing honoured by a different, ObjectStack-owned mount does not argue for keeping it here — no SDK method builds that URL, so no caller of this SDK can reach it by spellingnameon this method. That the platform honours the spelling somewhere unreachable is a separate gap (an ObjectStack mount with no SDK method). ⛔ Do not close it in this PR; file it, with the driven evidence already on this card, and hand the number back.⚠️ Say plainly in the PR body that the removed members are the vendor's record vocabulary, not typos:client_namewrites the DB column namedname, andscopewrites the column namedscopes. Triage: 「a ruling that treats them as misspellings should say so knowingly.」Acceptance conditions
① A pin that fails on the defect, not on the fix. Assert the type no longer declares
name/scopes/metadata. ⛔ Do not assert on a runtime 201 — the route answers 201 either way, which is the entire reason this was invisible.② Negative control — the members the route DOES honour must still work, byte for byte.
client_nameandscope(space-joined string) must still reach the server and still land in DB columnsnameandscopes. Full-string equality on the request body, ⛔ nevertoContain. Ablate and show it can go red, predicting direction in writing first.③
⚠️ redirect_uris— measured mismatch, ⛔ do NOT fix it here. The vendor declares itoptional<array<string>>; the SDK declares it required. A fourth divergence on the same type, outside this card's three. Report it; ⛔ do not widen the PR.④ The changeset carries a
**BREAKING**banner and says exactly what a caller passing these members should do instead.⑤ Re-measure the single-writer holder from the OPEN PR LIST, ⛔ never from remote branches.
⚠️ packages/client/src/index.tswas held by #17122 and then #17182, both merged — measure rather than trust this sentence, and name a positive control that fires. Branch from a base carryingd61139f1ba.Scope fence
- ⛔ client SDK
oauth.applications.deleterejects withSyntaxErroron EVERY successful delete — the route answers 200 with a zero-byte body and the method callsres.json()on it #15451 (oauth.applications.delete, p1) is a sibling in the sameapplicationsblock. Triage suggested landing them together; ⛔ do not — it is not dispatched, and a p2 must not pull a p1 into its review cycle. - ⛔ Never edit
content/docs/releases/; your only input there is the.changeset/*.md. - ⛔ Never
git stash. - Derive the gate family with
dispatch-gates.mjs --commands, reconcile with--ran, capture exit codes by redirect-then-$?, never through a pipe. A PREREQUISITE refusal is NOT MEASURED — declare it, ⛔ never count it as a pass. - Discharge the docs-drift bot when it fires; ⭐ read its caveats block too — it declares its own coverage holes, and an unanchored changed file is not a clean bill of health. ⛔ Never edit a release-owned page.
- The PR body's declaration must be a line in the fixed spelling
Clause-②:immediately followed by the value; a## Clause-②heading matches nothing. Runnode scripts/pm/check-clause2-carriers.mjs --pair <PR>and report the exit code. - Open the PR as a draft,
Fixes #15447. ⛔ Do not flip it ready and ⛔ do not enable auto-merge.
Generated by Claude Code
- ⛔ client SDK
- added a commit that references this issue
on Sep 9, 2026 os-dev-report
{
"issue": 15447,
"status": "done",
"branch": "claude/issue-15447-oauth-register-declared-members",
"pr": "#17209",
"premise_still_valid": true,
"summary": "Applied the family ruling to the request type of oauth.applications.register:name,scopesandmetadataare removed from the published declaration in packages/client/src/index.ts, because POST /oauth2/create-client honours none of the three (vendor body schema, no catchall, zod strip; plus a second barrier — all three sit in OPAQUE_METADATA_RESERVED_FIELDS). Nothing was added: no export, no key, no compatibility alias, so the PM seat's STOP CONDITION did not trigger and the provisionalClause-2: nostands. The measurement rig was NOT re-run; comment 5559384773's driven verdict is what the fixtures encode. A new pin file carries both halves — a type-level assertion (the route answers 201 either way, so no runtime assertion could see the change) and a runtime negative control holding the honoured membersclient_nameand space-joinedscopeto full-string equality on the request body. A**BREAKING**changeset carries the FROM/TO prescription and states, in the shipped text, that the removed members are the vendor's RECORD vocabulary (client_name writes the DB column namedname; scope writes the column namedscopes) rather than typos. Draft PR #17209, Fixes #15447, not flipped ready, auto-merge not armed.",
"tests": "ALL exit codes captured by redirect-then-$?, never through a pipe; verdicts quoted from each gate's own line. (1)pnpm --filter @objectstack/client typecheckexit 0 — 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. (2)pnpm --filter @objectstack/client testexit 0 — 'Test Files 39 passed (39) / Tests 478 passed (478)'. (3) Dependency closure built first (pnpm --workspace-concurrency=2 --filter '@objectstack/client^...' build, exit 0) — the first vitest attempt was a resolution failure (Cannot find package '@objectstack/spec/data'), read as NOT MEASURED, not as red. (4) Gate family derived withnode scripts/pm/dispatch-gates.mjs --commands(never hand-written) and reconciled with--ran: '58 derived famil(ies) accounted for - 58 run, 0 NOT-MEASURED'. 56 of 58 green first pass. NOT MEASURED and declared, never counted as passes:check:skill-examplesexit 1 on its own prerequisite refusal (packages/client-react/dist has no .d.ts) -> re-run after building client-react + client, exit 0, 258 prose examples type-check;check:type-check-debtexit 3 PREREQUISITE NOT MET (whole-repo tsc re-measure OOMed under the --max-old-space-size=4096 the resource discipline sets, which the gate itself names as the tighter caller ceiling) -> re-run at 8192, exit 0, '5 ledger entr(ies) re-measured in 102.9s, 55 raw tsc error(s) total, none above its recorded number';check:dual-build-cjs-loadsexit 3 PREREQUISITE NOT MET (reads built output, 35 packages have no dist/, needs a whole-repopnpm build) -> LEFT NOT MEASURED and handed to CI, which checks out fresh; a type-annotation removal plus a test file emits no JS for that gate to read. (5)node scripts/pm/check-clause2-carriers.mjs --pair 17209EXIT 0, before and after the body edit: 'the clause-2 declaration is readable in the fixed spelling and both carriers agree, and its diff carries no widening tell'. (6)node scripts/check-adr-0087-registration.mjs --base origin/mainexit 0, one declared-breaking changeset carrying not-required (no-migration-prescription) on a positive argument, with the residual declared. ABLATION, both legs run from the COMMITTED state, undertrap ... EXIT INT TERMwith absolute paths, direction predicted IN WRITING first (scratchpad PREDICTION.md, written before either leg): LEG 1 (pin) predicted RED by two signatures — driven RED,typecheckexit 1, verbatim: TS2344 on the keyof key-set equality naming name/metadata/scopes as the surplus, plus three 'TS2578: Unused @ts-expect-error directive' at lines 147/152/157. LEG 2 (negative control) predicted RED and specifically 2 of 3 — driven exactly that: 'Tests 2 failed | 1 passed (3)', the surviving case being the one whose double answers 400 without reading the request body. On-disk proof per leg: occurrence counts of the INJECTED text and of the DELETED text (6 counts for leg 1, 2 for leg 2, all as predicted) plusgit hash-objectdiffering from the HEAD blob 06216277bf9f67bfdf6cb2b852ce034d4effad1a; empty hash was coded as FAILURE, never as 'nothing to compare'. Restore proof per leg:git checkout HEAD -- path(never bare checkout), blob equal to HEAD's,git diff HEADempty,git status --porcelainempty. No dist preflight applies and that is stated rather than skipped: the subject resolves through a relative./indexsource import inside its own package, so no built artifact is in the resolution path (pnpm check:test-source-aliasgreen corroborates). SINGLE-WRITER (acceptance 5): measured from the OPEN PR LIST, never from remote branches — all 18 open PRs enumerated, each diffed against ITS OWN merge base via the three-dot PR files endpoint. packages/client/src/index.ts is held by NO open PR; #17122 and #17182 are both merged and absent, and this branch is cut from d61139f which IS #17182's squash. Positive control that FIRES: the same scan reports packages/client/package.json and packages/client/CHANGELOG.md held by open PR #17076 — inside the very package whose src/index.ts came back free; second control for multiplicity, packages/metadata/src/migrations/migrate-sys-notification-to-event.ts held by TWO open PRs (#17194, #17073). DOCS DRIFT: bot fired with 'no opinion' (no anchor derived); discharged in PR comment 5605977467 — all 14 coarse-fallback pages named with the reading that keeps each true, its 'name is too generic to anchor' caveat answered by a hand sweep, and TWO rows the tool structurally cannot reach reported rather than cleared (docs/qa/platform-checklist/areas/identity-auth.json is outside its content/docs corpus; content/docs/protocol/objectui/actions.mdx states the rule by its YAML inputs, caveat 2's exact shape). Three of the 14 are release-owned and were read only, never edited.",
"mcp_calls": "1 - one targeted mcp__github__search_issues for the pre-file duplicate check on #17210; every other GitHub read and write went through repo-scoped REST after a green probe (rate_limit 14803/15000 remaining at the end). The search's control fired: #15447 itself came back in the result set, and the same session's scoped REST list+grep over 547 cards labelled finding|domain:cli returned 4 hits for the control term 'oauth.applications.register' and 0 for 'sys-oauth-application'.",
"open_questions": [],
"out_of_scope_findings": [
"filed as #17210: no client SDK method builds POST /api/v1/auth/sys-oauth-application/register, the ObjectStack-owned mount that honours a plainnameand maps it to client_name (driven 200, DBnameset). Filed unassigned and UNLABELLED, with the driven evidence. IMPORTANT for your grading: the route ledger ALREADY declares this rowdisposition: server-onlywith the note 'no SDK method builds this URL - the sys_oauth_application create action posts it directly', so it is a DECLARED disposition, not an undeclared hole - the card is a capability question (rebook tosdkand add a method, or stay Console-only by design), not a defect report. Precedent named on the card: #10975, the same shape for POST /api/v1/auth/set-initial-password.",
"noted, not filed: acceptance condition 3 -redirect_urisis a FOURTH divergence on the same type. The vendor declares it optional array-of-string; the SDK declares it REQUIRED. Not fixed, not widened, per your instruction. Not filed either, and here is the reasoning so you can overrule it: the direction is the safe one - the SDK is NARROWER than the producer, so nothing is silently lost and no caller gets a wrong outcome; a legal call simply cannot be expressed. That is incompleteness, not a defect (my filing rule splitswrongfromincompleteand files only the first), and your dispatch reserved the wordfilefor the #17210 gap while saying onlyreportfor this one. Receiver: you, seat #6024, who asked to be told. Say the word and I file it.",
"noted, not filed: #15451 (oauth.applications.delete, p1) is ALREADY LANDED on main at this base - the method carries its fullWhy this method does not call res.json()block and is pinned by packages/client/src/oauth-applications-delete.test.ts. The fence held anyway (nothing of it was touched), but the sequencing worry in triage 5547794993 and in your dispatch is moot: there was no p1 left to drag in. Receiver: you, when you next read that fence.",
"noted, not filed: the gate derivation printed a STALE TREE warning - origin/main advanced to 22c94e5 mid-flight and one file the derivation reads, scripts/pm/check-clause2-carriers.mjs, changed across that range (#17201). Read rather than assumed: that change is REPORTER-only (its own docblock: 'This is a REPORTER, never a reader ... CLAUSE2_KEY_LINE is untouched'), adding a PLACEMENT near-miss message without moving the accept set, so no gate family is added or removed and the 58-family list still holds. Recorded in the PR's acceptance notes too. Receiver: the next dev on this file, who will see the same warning.",
"noted, not filed: check-type-check-coverage's provenance line reports walkedTestFiles moving 3503 -> 3526 against the record measured on 854639b. That is the shared population across every branch, not this diff (which adds one test file), and the gate says so itself - 'only the floors decide', floor 2800. No ledger entry raised; raising one is a maintainer's act. Receiver: whoever re-tallies that record next."
]
}
Generated by Claude Code
- added a commit that references this issue
on Sep 9, 2026 os-dev-report
{
"issue": 15447,
"status": "done",
"branch": "claude/issue-15447-oauth-register-declared-members",
"pr": "#17209",
"premise_still_valid": true,
"summary": "PATCH ROUND on top of the accepted delivery; new head 5e38385, still a draft, auto-merge not armed. (1) The third case in the new pin was incoherent and is fixed: it sent the JOINED string the route accepts with 201 against a double answering 400 unconditionally, so its title claimed the route's acceptance of the joined form and its refusal of the array form, neither of which the case could witness. It is retitled to "surfaces the route's refusal of an array-formscoperather than swallowing it", the request now carries the ARRAY form through a@ts-expect-error(option b — the body that actually drew the recorded 400, mirroring direction 2 of the type pin), and the comment states that the 400 is a RECORDED response replayed from the driven run rather than one this test produces, plus the clause that makes the pairing coherent rather than relabelled: after this card's narrowing the array form is no longer expressible through the declared type at all. The assertion is untouched, as instructed. (2) One line outside the named case was also corrected and is declared rather than slipped in: the file header read 'The space-joined case below is that prescription, pinned', which after the retitle pointed at a case that no longer sends the joined form; it now names the FIRST case, which does. Same false-at-citation class as the round itself, which is why it was not deferred. (3)redirect_urisfiled as #17215 on your overrule. ⛔ Untouched, as instructed: packages/client/src/index.ts (blob at the new head is 06216277bf9f67bfdf6cb2b852ce034d4effad1a, byte-identical to the previous head's), the changeset, every other case, the draft flag and auto-merge.",
"tests": "All exit codes captured by redirect-then-$?, never through a pipe. ON THE NEW HEAD 5e38385:pnpm --filter @objectstack/client typecheckEXIT 0 — 'check:test-typecheck: OK ... 0 file(s) / 0 error(s) / 0 pinned signature(s)'.pnpm --filter @objectstack/client testEXIT 0 — 'Test Files 39 passed (39) / Tests 478 passed (478)'.node scripts/pm/check-clause2-carriers.mjs --pair 17209EXIT 0, run AFTER the push and after the body edit — 'the clause-2 declaration is readable in the fixed spelling and both carriers agree, and its diff carries no widening tell'. Gate family re-derived and reconciled on this head: identical 3-path change set, '58 derived famil(ies) accounted for - 58 run, 0 NOT-MEASURED'. The eleven source-text-sensitive families were re-run individually rather than assumed — check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check-comment-mask-adoption, check-comment-mask-corpus, check-keyed-text-bounds, check-undeclared-dep-imports, check-registry-log-declared, check-empty-changeset, check-adr-0087-registration, check:type-check-coverage — ALL EXIT 0; plus a hand grep for control bytes on the touched file, clean. BOTH ABLATION LEGS RE-RUN ON THE NEW HEAD, because a citation that moved with the file has to be re-taken; directions predicted in writing first, appended to the same PREDICTION.md before either leg ran. LEG 1 (the pin): predicted RED with the signature UNCHANGED AT 4 ERRORS, and specifically that the new fourth suppression must NOT show up as a TS2578 because leg 1 restoresscopesrather than wideningscope— driven exactly that, typecheck exit 1, '4 type error(s) in a file the ledger does not cover', four not five. LEG 2 (the negative control, the load-bearing one because the PR body cites '2 of 3'): predicted RED and STILL EXACTLY 2 OF 3, the retitled case staying green because it reads the rejection and never the request body — driven 'Tests 2 failed | 1 passed (3)', so the PR body's citation is still true on this head and needed no correction. Both legs from the committed state, undertrap ... EXIT INT TERMwith absolute paths, on-disk proof by occurrence counts of injected AND deleted text plus a blob differing from HEAD's, restores proved by a blob equal to HEAD's withgit diff HEADempty andgit status --porcelainempty. ⛔ The measurement rig was not re-driven at any point in this round. NOT re-run and unchanged from the first delivery: the other 47 of the 58 families and the one declared NOT MEASURED (check:dual-build-cjs-loads, exit 3 PREREQUISITE NOT MET, needs a whole-repo build, handed to CI) — the patch touches one test file already in the change set, and the re-derivation confirms the family list did not move.",
"mcp_calls": "1 - unchanged for the whole run; the patch round used repo-scoped REST only (issue create with labels, label read-back, PR body PATCH, two comment POSTs, PR read).",
"open_questions": [],
"out_of_scope_findings": [
"filed as #17215:oauth.applications.registerdeclaresredirect_urisREQUIRED where @better-auth/oauth-provider@1.7.2's body schema declares it optional (an array of string), so a request the route would accept has no spelling through the SDK. Filed on your overrule with the labels you named — bug, domain:cli, finding, priority:p3 — unassigned, no pm: label, and the label set verified by a compare-style read-back (union(read, target) equals the read set; nothing stripped, nothing extra, no pm:*). Body carries the driven evidence (the runtime introspection ofendpoints/options.bodyfrom comment 5559384773, not a .d.ts transcription) and names #15447 and #17210 as siblings, with the grade argued from the direction rather than asserted.⚠️ CONFLICT DECLARED, not silently resolved: my standing rule is that out-of-scope findings are filed WITHOUT labels so triage grades them; your instruction named four. I followed your instruction because you are the seat that grades this board and you graded it in the instruction itself, but flagging it so the rule and the exception stay distinguishable.",
"filed as #17210 (first round, unchanged): no client SDK method builds POST /api/v1/auth/sys-oauth-application/register, the ObjectStack-owned mount that honours a plainname. Still unassigned and UNLABELLED. Its grading note stands: the route ledger already declares that rowdisposition: server-onlywith the reason, so it is a capability question rather than an undeclared hole.",
"noted, not filed: #15451 (oauth.applications.delete, p1) is ALREADY LANDED on main at this base and was never at risk of being pulled in. Receiver: you, when you next read that fence.",
"noted, not filed: the gate derivation still prints its STALE TREE warning against origin/main; re-read on this head and the verdict is unchanged — the only file it names, scripts/pm/check-clause2-carriers.mjs (#17201), changed REPORTER-only ('This is a REPORTER, never a reader ... CLAUSE2_KEY_LINE is untouched'), so no family moved and the 58-family list holds. Receiver: the next dev on this file.",
"noted, not filed: check-type-check-coverage's provenance line still reports walkedTestFiles above the record (shared population, floor 2800, 'only the floors decide'). No ledger entry raised; raising one is a maintainer's act. Receiver: whoever re-tallies that record next."
]
}
Generated by Claude Code
os-project-manager commented
on Sep 9, 2026 CollaboratorMore actions✅ Accepted — PR #17209 merged as
bccf311100. Round 28.domain:cliexecution PM seat (#6024), 2026-09-09T18:45Z. Read from the ref (git show origin/main:<path>), ⛔ never from a working tree.Landing verified
check reading origin/mainbccf311100, subject fix(client)!: oauth.applications.register declares only the members /oauth2/create-client accepts (#17209)single-parent squash 1 parent, against firing control 6d19f483905542a0a08e5a9d0591e6b3dd2f8696which reads 2the removal the request type now enumerates exactly eleven members — client_name,redirect_uris,token_endpoint_auth_method,grant_types,response_types,client_uri,logo_uri,scope,contacts,tos_uri,policy_uri.name,scopes,metadataare gone.firing control client_nameandscope— the two members the route does honour — are still declared. ⇒ The absence above is a removal, ⛔ not an empty read.⚠️ Verified by printing the whole type block rather than grepping for the three names. A grep returning zero would have been equally consistent with reading the wrong symbol; an enumeration that shows eleven survivors and three absences is a reading.The ruling this executed
⛔ No ruling was owed and none was made here. The maintainer discharged triage's manual floor on 2026-09-09 (
5595056650) underSKILL.md:720元判据①, reusing the #12034 family's parent ruling — 收敛到生产方实际形状 — 直接入队. This PR carried out that convergence on one method's request type.⭐ Worth keeping
- The pin could not assert on a 201, and says so. The route answers 201 either way — that is precisely why the defect survived — so any runtime assertion on status, response body, or read-back would have been green both before and after. The pin is type-level for that reason, and its key-set is asserted as an equality, which makes it simultaneously the stop-condition guard: re-adding any member under a new spelling, or slipping in a compatibility alias, reddens it.
- The removed members were the vendor's RECORD vocabulary, not typos.
client_namewrites the DB column literally namedname;scopewrites the column literally namedscopes. The type offered the column spelling and the wire spelling side by side and only the wire one worked. ⇒ A fix that "corrected the typos" would have been the wrong reading of what they were. Andscopes→scopeis ⛔ not a rename:scopeis one space-delimited string, and the array form is driven-refused with400 [body.scope] Invalid input: expected string, received array. - A patch round was spent on one test's title, and it was worth it. The pin's third case claimed "the array form is refused on the wire" while sending the joined string against a transport double answering 400 unconditionally — a fixture pairing a valid request with the invalid request's answer. Its assertion was correct; only its title over-claimed. It now sends the array through a
@ts-expect-error— the body that actually drew the recorded 400 — and states that the 400 is replayed, never produced. ⭐ The delivering seat also repaired the file header, which the retitle had left pointing at a case that no longer sent the joined form. ⛔ A brand-new pin whose title is false at birth is not a follow-up card; a pin's title is what the next reader cites. - Two ablation legs, re-run on the patched head with predictions written first, because a citation that moves with the file must be re-taken. Leg 1 predicted the signature stays at four errors, not five — the new fourth suppression must not surface as an unused directive, since that leg restores
scopesrather than wideningscope. Driven exactly four. Leg 2 still reddens 2 of 3, keeping the PR body's own citation true.
⚠️ Residue — now 7 of 8The merge again left
pm:queue,pm:dispatchedand the assignee on a card closedcompleted. Stripped by hand and read back:bug/priority:p2/domain:cli/finding, no assignee.Filed out of this card's round, ⛔ none fixed in this PR
- client SDK oauth.applications.register declares redirect_uris REQUIRED where the better-auth body schema declares it optional, so a legal vendor call cannot be expressed #17215 —
redirect_urisis declared required here where the vendor declares it optional, so a legal vendor call has no spelling through the SDK. The delivering seat judged it unfileable under its ownwrongvsincompleterule and flagged the conflict rather than resolving it silently; I overruled and it filed.⚠️ I then had to correct myself: I directed apriority:p3onto it after saying an hour earlier on client SDK: no method builds POST /api/v1/auth/sys-oauth-application/register, the one door that honours a plainnamewhen registering an OAuth client #17210 that priority is triage's carrier, not mine. Label stripped, argument preserved on the card (5606388056). - client SDK: no method builds POST /api/v1/auth/sys-oauth-application/register, the one door that honours a plain
namewhen registering an OAuth client #17210 — no SDK method buildsPOST /api/v1/auth/sys-oauth-application/register, the ObjectStack-owned mount that honours a plainname. A capability question, ⛔ not an undeclared hole: the route ledger already declares that rowserver-only.
What this unblocks
packages/client/src/index.tsis free. #16760 · #16582 · #12034 become dispatchable — ⛔ serial with each other, one at a time.⚠️ This seat is at the batch ceiling (3 live devs), so none is dispatched yet.
Generated by Claude Code
- added 3 commits that reference this issue
on Sep 17, 2026
Found while binding the
oauth.*return types in #14312 (PR #15445). Request side, not return side — out of that card's scope, filed rather than fixed in passing.The defect
ObjectStackClient.oauth.applications.registerdeclares this request type (packages/client/src/index.ts):The route is
POST /api/v1/auth/oauth2/create-client, mounted verbatim from@better-auth/oauth-provider(auth-route-ledger.tsrecords itsource: 'better-auth'). Its body schema is a Zod object withz.core.$stripsemantics, and its members are:redirect_uris,scope,client_name,client_uri,logo_uri,contacts,tos_uri,policy_uri,software_id,software_version,software_statement,post_logout_redirect_uris,backchannel_logout_uri,backchannel_logout_session_required,token_endpoint_auth_method,application_type,jwks,jwks_uri,grant_types,response_types,dpop_bound_access_tokens.name,scopesandmetadataare not among them, so a caller who sets any of the three gets HTTP 201 and a client that silently does not have it.$stripdrops unknown keys rather than refusing them, so there is no error to notice.This is the repo's first commandment pointed at the request side: the SDK declares a capability the runtime does not honour.
Measured, not inferred
Driven against a real server (real
betterAuth+ realoauthProviderover the realcreateObjectQLAdapterFactoryon a real ObjectQL engine and a real better-sqlite3 database, real session cookie, realObjectStackClient). Registering withmetadata: { tenant: 'acme', tier: 7 }returned 201 and a body carrying neither key:{"client_id":"pWhEhQXRmdLVGVZkgiobMJfxEmsgVCoB","client_secret":"EPsjhqMCoPzoNPiQdMRLDPfcUIWDnFmp","client_secret_expires_at":0,"scope":"openid profile email","user_id":"t3IvR7eGC7IHhZnZUy4ftuLgeesecJ4a","client_id_issued_at":1788534576,"client_name":"Probe App","client_uri":"``https://app.example.com","logo_uri":"https://app.example.com/logo.png","contacts":["ops@example.com"],"tos_uri":"https://app.example.com/tos","policy_uri":"https://app.example.com/privacy","redirect_uris":["https://app.example.com/cb"],"token_endpoint_auth_method":"client_secret_basic","grant_types":["authorization_code","refresh_token"],"response_types":["code"],"application_type":"web","disabled":false,"dpop_bound_access_tokens":false}``metadatawas measured directly.nameandscopesrest on the body-schema reading above, not on a drive — worth confirming before acting, since the correct disposition may differ per member (nameandscopeslook like near-misses for the realclient_nameandscope, which the same type also declares;metadatahas no vendor counterpart at all and the serialiser only ever spreads metadata that some other door stored).Not decided here
Whether each member is deleted, renamed onto its real counterpart, or routed to a door that can honour it is a contract question, and the near-miss pairs (
name/client_name,scopes/scope) mean deleting them changes what existing callers compile against. Left to triage.Version measured:
@better-auth/oauth-provider@1.7.2. Nothing here changes the return-type work in #14312 or its two sibling cards, which are return-side only.