fix(client)!: oauth.applications.register declares only the members /oauth2/create-client accepts - #17209
Conversation
…h2/create-client accepts `name`, `scopes` and `metadata` were never honoured by the route this method posts to. The vendor body schema (`@better-auth/oauth-provider@1.7.2`) has no `catchall`, so zod strips them: the caller got HTTP 201 and a client that quietly did not have the value. Driven on the card — absent from the response, from `applications.get`, from `applications.list`, and `null` in the `sys_oauth_application` row. The two near-misses were the vendor's RECORD vocabulary, not typos: `client_name` writes the DB column named `name`, and `scope` writes the column named `scopes`. `scopes` -> `scope` is therefore not a rename — the array form is refused (`400 [body.scope] Invalid input: expected string, received array`) and only a space-joined string passes. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
…15447 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Docs Drift Check — discharged, page by pageThe bot has no opinion (no anchor derived), which is ⛔ not a clean bill of health, so its caveats block is answered below rather than treated as a clearance. Read at The coarse fallback — all 14 pages it names, each with the reading that keeps it true
Caveat 1 — "1 name(s) were too generic to anchor anything (single lowercase words)"That name is Caveat 2 — the inputs-vs-emitter blind spot, and a row this run could not report⭐ Reported rather than cleared: ⭐ Second row the anchor pass could not reach: Neither of those is falsified by this PR. Both are recorded because a row that had to be reached by hand is worth more here than a row the tool cleared. Generated by Claude Code |
…the recorded 400 The third case in the #15447 pin paired a VALID request (the joined string the route accepts with 201) with the error the INVALID request produces, against a double that answers 400 unconditionally — so its title asserted two things the case could not see. It now sends the array form through a suppression, which is the body that actually drew the recorded refusal, and the title and comment say what it pins: the SDK's handling of that refusal, never the route's verdict. The suppression is load-bearing: after this card's narrowing the array form is no longer expressible through the declared type, and widening `scope` to accept one leaves the directive unused and this case red. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
Patch round — the third pin case now sends the body that produced the recorded 400Head The defect, restated so the fix is checkableThe third What it is now
The assertion itself is untouched — it was correct, and it is what the case keeps. Re-measured on the new head, not carried over
Both ablation legs re-run on this head, because a citation that moved with the file is a citation that has to be re-taken. Directions predicted in writing again, before either leg ran:
Both legs ran from the committed state under a Also in this push#17215 — the Still a draft; auto-merge not armed. Both stay the PM seat's. Generated by Claude Code |
…n OAuth client (objectstack-ai#17210) (objectstack-ai#17755) Fixes objectstack-ai#17210 Implements the director seat's ruling on this card (`#issuecomment-5634036848`), option **B**: **the SDK gains no method; the ledger row is untouched.** The one measured trap is made visible in prose where its reader meets it. No SDK method is added, no request or response type moves, no accept set moves, and `packages/plugins/plugin-auth/src/auth-route-ledger.ts` is not in this diff. ## Premises — falsified first, on `origin/main` at `396eae33` All four rows of the dispatch's premise table hold, plus the falsification trigger it named. | # | premise | reading | |:--|:--|:--| | 1 | `oauth.applications.register` still exists | `applications: {` at `:3997`; `register: async (req: {` at `:4043`; pin named at `:4041` | | 2 | the request type is the vendor shape | `client_name?: string` at `:4044` and `redirect_uris: string[]` at `:4045` in the request type; the same two members at `:947` / `:960` in `OAuthApplication` | | 3 | the ledger row is unchanged and still `server-only` | `auth-route-ledger.ts:241`, `disposition: 'server-only'`, note verbatim as quoted on the card | | 4 | control | `disposition` returns **70** in that ledger — the probe is live | | 5 | PR objectstack-ai#17209's removal held: no bare `name` in the typed request | **zero** hits for `^\s*name\??:` in lines `4043-4055`. Positive control, **same file, same regex**: 14 bare `name` members elsewhere (`:1062`, `:1218`, `:1267`, `:1305`, `:1741`, …) | Every `client_name` hit in `packages/client/src/index.ts` was read in context, not counted: `:947` (an `OAuthApplication` member), `:1008` (inside the `OAuthApplicationPublic` `Pick`), `:4025` / `:4027` (the existing objectstack-ai#15447 docblock), `:4044` (the request type). Premise not falsified — `premise_still_valid: true`. ## 1. The JSDoc — landed bytes Added to the `oauth.applications.register` docblock, after the `metadata` paragraph and before the `Pinned by` line: ``` * ##⚠️ A plain `name` IS honoured somewhere — not here (objectstack-ai#17210) * * There is exactly one door that reads a body member spelled `name`, and * this method does not build it: * `POST /api/v1/auth/sys-oauth-application/register`, the ObjectStack * mount behind the Console's *Setup → OAuth Applications* create form. * The same objectstack-ai#15447 round drove both doors on one real socket: that mount * answered **200** to a body spelled `name`, mapped it onto * `client_name`, and the `sys_oauth_application` row's `name` column was * set; this method's route, `/oauth2/create-client`, answered **201** * with the value **stripped** — absent from the response, absent from * `applications.get`, absent from `applications.list`, and `null` in * that same column. * * ⛔ That mount is **not** an SDK door, and it is not withheld by * oversight: it is `disposition: 'server-only'` in the auth route ledger * (`packages/plugins/plugin-auth/src/auth-route-ledger.ts`), a * session-required self-service wrapper written to serve the Console's * form — and objectstack-ai#17210 ruled that it **stays** `server-only`, so no SDK * method builds its URL. To name a client from here, post `client_name`. * * The wrapper's other Console-shaped convenience is the same asymmetry's * second half: it splits that form's newline-separated redirect-URL * **textarea** into the array the vendor schema requires. ⛔ This route * performs no such split — `redirect_uris` must arrive **pre-split**, * one entry per URL, which is what an SDK caller holds anyway. ``` Both halves the ruling asked for are stated: `name` is honoured only by the Console's server-only mount, and this route requires `redirect_uris` pre-split. ## 2. `content/docs/protocol/objectui/actions.mdx` — the same fact, that page's own idiom Not a copy-paste of the JSDoc. The page is an action-authoring reference, so the note is written to an action author, about the `target` of the `create_oauth_application` example, and it is addressed at the `:424` neighbourhood — a new `####⚠️ That target is not interchangeable with the SDK's registration door` immediately after that example's `resultDialog` explanation, inside the same **Result Dialog** section. It says why the YAML above is that short (the mount does the plain-`name` mapping and the textarea split on the action's behalf), that the ledger carries it `server-only` for exactly that reason, and what an author would have to change — rename the name param to `client_name`, pre-split the redirect URLs — if they re-pointed `target` at the provider route. The docs-drift tool cannot surface this page for this diff (it states the rule by its YAML inputs, so it shares no identifier with the SDK file), which is why it was edited by hand. ## 3. The measured evidence, restated and not overstated From the objectstack-ai#15447 round, on a real socket, driven through the real client: - `POST /api/v1/auth/sys-oauth-application/register` with a body spelled `name` answered **200**, the value came back as `client_name`, and the `sys_oauth_application` row's `name` column was set. - The same spelling posted to `/oauth2/create-client` answered **201** with the value **stripped**: absent from the response, absent from `oauth.applications.get`, absent from `oauth.applications.list`, `null` in the DB column. Nothing in this PR claims more than those two readings. In particular it does not claim the mount is reachable from the SDK, nor that any behaviour changed. ## 4. Changeset — `patch`, measured, not assumed `skip-changeset` was tested and refused. `@objectstack/client`'s `files[]` is `["dist","README.md","CHANGELOG.md"]`; after `pnpm --filter @objectstack/client build`, the new docblock prose is present in **both** `dist/index.d.ts` and `dist/index.d.mts` — it ships as editor hover to every consumer. - measurement: `grep -c "A plain \`name\` IS honoured somewhere"` → `1` in each of the two files - positive control, same files, same grep shape: `grep -c "the vendor's RECORD vocabulary"` (pre-existing docblock prose) → `1` in each - negative control: an absent marker string → `0` Published bytes move ⇒ `patch`. `.changeset/17210-oauth-register-name-trap-prose.md`. ## Gates Derived mechanically with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no hand-written path list), then reconciled with `--ran` carrying each command's own exit code, captured before any pipe. - **82 derived families — 81 run green, 0 UNRUN, 1 NOT MEASURED.** - `pnpm lint` (whole repo, `eslint . --no-inline-config`) — **exit 0**. - `pnpm --filter @objectstack/client test` — **42 files / 506 tests passed**, including the `oauth-applications-register-request-members.test.ts` pin. - `pnpm --filter @objectstack/client typecheck` — **exit 0**, test layer included (`check:test-typecheck`: 0 files / 0 errors). - Dependency closure built first: `pnpm --filter '@objectstack/client^...' build` — exit 0. Heavy runs went through `scripts/pm/os-verify-lock.sh`; verdicts read from its `VERDICT command-exit` lines. - Control characters: `grep -naP` over both edited files, zero hits, on top of `check:nul-bytes` green. **NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason:** it exited **3** with `PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured`. It wants a whole-repo `pnpm build` (34 packages listed, none of them `@objectstack/client`, which is built). That is CI's `Build Core` run, and the gate reads emitted runtime JS where comments are stripped, so a docblock-only diff has no way to move it. Declared rather than run. `Clause-②: no`, re-derived from the delivered diff: three files, 33 inserted lines plus a changeset, zero deletions; no declaration, export, accept set or published symbol moves. ## Acceptance notes **`docs/qa/platform-checklist/areas/identity-auth.json` (`:1008`, `:1017`, `:1078`) — read, judged out of scope, not edited.** The dispatch left this call to me, so here is the reasoning rather than the verdict alone. All three lines were read in context. They belong to `identity-auth.oauth-app-consent-loop`, and **none of them states anything false.** `:1008` enumerates the three registration doors and already records this mount's distinguishing behaviour (*"the session-required wrapper that splits the redirect-URL textarea into redirect_uris"*); `:1017` offers two of those doors as alternatives for a step whose object is the **`client_secret`**, not the client's name; `:1078` is a `source` row naming the ledger. Nothing there claims the provider route honours a plain `name`. The item never names an application, at any step, in any acceptance clause or negative — so the trap is not reachable from it, and there is no assertion to correct. Against that, editing it has a real cost in the checklist's own machinery: a semantic edit owes a `revision` bump (`scripts/check-platform-checklist.mjs` holds `revision` equal to the last `history` entry precisely so that a changed item cannot silently re-validate old run results), which would invalidate the item's recorded runs for a clarification its steps never exercise. So: read, found accurate, left alone. It is not orphaned — the carriers that will next open it are the `checklist-author` coverage sweep and any `checklist-test` run selecting `identity-auth.oauth-app-consent-loop`. **Red lines held.** No `packages/spec` path in the diff. `auth-route-ledger.ts` untouched. No `content/docs/releases/` path. No file held by objectstack-ai#17725, objectstack-ai#17718 or objectstack-ai#17093 is reached — the diff is `packages/client/src/index.ts`, `content/docs/protocol/objectui/actions.mdx` and one new changeset. --- _Generated by [Claude Code](https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…nal, matching the body schema of the route it posts to (objectstack-ai#17215) (objectstack-ai#17834) Fixes objectstack-ai#17215 Clause-②: yes Declared by the dispatching `domain:cli` seat (objectstack-ai#6024) and unchanged by the delivered diff: relaxing `redirect_uris` **enlarges a published SDK method's accepted set**, which is the widening direction and is graded conservatively regardless of the fact that it restores parity with the vendor. `needs:contract-review` is on the card; this PR does not enqueue until an at-tier contract review of record exists, which is the PM seat's half and is ⛔ not satisfied by green CI. ## ⭐ The falsifier, resolved FIRST Triage (`5621351376`) fenced this card: before writing the `?`, establish whether `redirect_uris`' required-ness is a **deliberate guard** rather than an oversight — RFC 7591 makes `redirect_uris` required for authorization-code clients, so a wrapper stricter than a permissive vendor may be correct on purpose. An assertion found ⇒ stop, write no code. **Verdict: no such assertion exists anywhere.** Every search below is reported with its count and a lit control, because a bare "found nothing" is not a result. | surface searched | how | hits | lit control (proves the matcher fired) | |---|---|---|---| | the declaration's own file, in context | `grep -n redirect_uris packages/client/src/index.ts`, **every hit read in context** | **8**, none an assertion | the same 8 hits are the control — the matcher fires and each was read, not counted | | repo-wide `redirect_uris` | `git grep -c` | **20 files**, none an assertion | `create-client` -> 8 files | | ADRs | `git grep` in `docs/adr/` | **0** files for `redirect_uris`, **0** hits for `7591` | `Status` -> **136** ADR files | | RFC 7591 anywhere in-tree | `git grep 7591` | **20 files** — none ties 7591 to this member's required-ness | `RFC ` -> **150** files | | guard-shaped prose (`deliberately/intentionally required`, `stricter than`, `guard rather than`, `on purpose`) | `git grep -niE` over client, plugin-auth, docs, content, changesets | **12 hits, zero about this member** | `deliberately` in `packages/client` -> 51 in CHANGELOG alone | | the pin test named by the docblock | read in full (253 lines) | pins the **key set**, never this member's optionality | it pins `client_name` and `scope` value types one screen up — so the idiom exists and was deliberately not applied here | | `oauth-provider-schema-parity.test.ts` | read in full | **0** mentions of `redirect_uris`, `create-client`, `body` | it is a DB-column parity gate; its own subject matter is the control | | PR review threads on the sibling PR objectstack-ai#17209 | REST `/pulls/17209/comments` and `/reviews` | **0** and **0** | repo-wide `/pulls/comments` -> **100** returned, so the channel is lit | | review threads on the commits that CREATED the required-ness | REST `/commits/{sha}/pulls` | **none — both are direct commits, no PR, so no review thread ever existed** | the same endpoint resolves PRs for other shas | | the sibling issue threads | `objectstack-ai/issues/15447/comments`, `objectstack-ai/issues/17210/comments` | **13** and **9** `redirect_uris` lines — every one calls it a *measured mismatch / divergence to report* | comment counts 10 and 7 returned | **What the history shows instead of a guard.** The member was required from the method's first commit, `170ecfa4c` ("feat: add OAuth application management features"), which declared `client_name: string` required too. Nine hours later `bc5bd719b` ("feat(auth): update OAuth application handling and consent flow with improved parameter management") re-pointed the method at `/oauth2/create-client`, **relaxed `client_name` to optional**, added the three bogus members objectstack-ai#15447 later removed — and left `redirect_uris` untouched as unchanged context, with no comment and a one-line commit message. Both are direct commits with no PR. Nothing, anywhere, ever stated a reason. ⇒ Per triage's own branch: **no assertion found ⇒ proceed; the `?` restores parity.** ###⚠️ Near-misses, so the next reader does not re-file this The dispatch order handed over two; reading every hit in context turned up **three more**. None speaks to whether the REQUEST member may be omitted: 1. `index.ts` "Only `client_id` and `redirect_uris` are always present … `redirect_uris` is unconditional" — the **response** projection's serialiser. 2. `index.ts` "⚠️ `redirect_uris` is always `[]` here" — the **public response** projection. 3. *(new)* `index.ts`, inside this very method's docblock: "the array the vendor schema requires … `redirect_uris` must arrive **pre-split**". About the **element shape** (array vs the Console textarea), not about presence. 4. *(new)* `auth-plugin.ts`: "better-auth's Zod body schema requires `redirect_uris: string[]`" — about the **type**, and it names its own failure mode, `expected array, received string`. It is the Console wrapper's split, in another package. 5. *(new)* objectstack-ai#17210 comment `5642689415` calls the request type "the vendor shape" while pointing at `redirect_uris: string[]` — an acceptance row asserting that a prose-only PR moved no type. ## Acceptance 2 — the vendor schema re-introspected at RUNTIME ⛔ Not read from a `.d.ts`. Method as the card prescribes: `createRequire` the package, `await import` the resolved entry, instantiate, walk `endpoints`, select the endpoint whose `path` contains `create-client`, read `options.body`.⚠️ **The package version has MOVED: the card measured `1.7.2`; the installed version is `1.7.3`** (`packages/plugins/plugin-auth/package.json` pins `1.7.3`, lockfile resolves `1.7.3`, and the package's own `package.json` read off the resolved entry's directory reports `1.7.3` at runtime). Re-measuring was therefore load-bearing, not ceremony.⚠️ A second difference at 1.7.3: a zero-argument `oauthProvider()` **throws** (`Cannot read properties of undefined (reading 'clientRegistrationAllowedScopes')`), so it was instantiated the way this repo instantiates it, `oauthProvider({ loginPage, consentPage })`. ``` endpoints walked : 33 create-client endpoint: key=createOAuthClient path=/oauth2/create-client options.body present : yes body constructor: ZodObject member count : 21 (card recorded 21 at 1.7.2 — no drift) ``` The row under test, read today, with optionality **driven** rather than inferred: | member | optionality (driven: `safeParse(undefined)`) | type | |---|---|---| | `redirect_uris` | **optional** | optional of array of string | ``` safeParse(undefined).success : true <- the member may be omitted safeParse([]).success : false <- an EMPTY array is refused safeParse(["https://a/cb"]) : true body.safeParse({ client_name: 'probe' }).success : true parsed keys: client_name unknown key => success: true | STRIPPED (no catchall — zod default strip, as the card recorded) ``` **All 21 members are optional**, and a body omitting `redirect_uris` entirely parses `ok`. The divergence the card describes is confirmed at 1.7.3.⚠️ Carried into the docblock and the changeset, because it is a trap in the other direction: optional does **not** mean `[]` will do — the vendor refuses an empty array, so omitting the member and passing `[]` are different requests and only the first is legal. ## Acceptance 3 — a DRIVEN reading that the call is now expressible ⛔ Not a type-level argument; compiled. `pnpm --filter @objectstack/client typecheck` -> **exit 0**, and that script is two halves — `tsc --noEmit && pnpm check:test-typecheck` — the second of which compiles the test layer under `tsconfig.test.json`. New pin `registerRedirectUrisOptionalPin17215`, compiled and never invoked, in the file the docblock already names: - an `expectTypeOf` on `RegisterRequest['redirect_uris']`, asserted equal to the union of `string[]` and `undefined` — the parity assertion, in the file's existing house idiom (spelled in words here because the body sanitiser eats angle-bracket fragments; the file carries the real generic syntax); - `register({ client_name: 'PROBE-17215-OMITTED' })` — **previously inexpressible**; - `register({})` — the emptiest legal call; - `register({ client_name, redirect_uris })` — still compiles, unchanged; - plus one runtime `it()` proving the SDK synthesises no default: the body sent is exactly `{"client_name":"PROBE-17215-OMITTED"}` with no `redirect_uris` key.⚠️ **The existing `keyof` key-set equality gives this change ZERO coverage** — `keyof` is insensitive to optionality, so it reads the same eleven names in both states. That is why a new assertion was owed rather than relying on the one already there, and it is stated in the new pin's docblock. ⛔ The equality itself is **untouched**: `git diff` on that test file has **0 removed lines** — the file is purely additive. ### Ablation, and a correction I am reporting rather than hiding⚠️ **My first ablation run was wrong and its green is void.** I predicted RED, mutated `?` back to required, ran `pnpm --filter @objectstack/client exec tsc --noEmit`, and got **exit 0**. The cause is not the pin: `packages/client/tsconfig.json` carries `"exclude": [… "**/*.test.ts"]`, so the bare `tsc` program never contains the pin file. Proved with `--listFiles`: **0** occurrences of the pin file and **0** `.test.ts` files in that program, against a lit control of **1** for `src/index.ts`. That run is **NOT MEASURED**, not a pass. Re-run against the program that actually compiles the test layer: ``` MUTATION PROVEN ON DISK (optional spelling 1 -> 0, required spelling 0 -> 1, blob differs from HEAD's) pnpm --filter @objectstack/client typecheck -> exit 1 check:test-typecheck: src/oauth-applications-register-request-members.test.ts: 4 type error(s) packages/client/src/oauth-applications-register-request-members.test.ts(189,66): error TS2344: Type 'string[] | undefined' does not satisfy the constraint 'string[]'. packages/client/src/oauth-applications-register-request-members.test.ts(194,50): error TS2345: Argument of type '{ client_name: string; }' is not assignable ... packages/client/src/oauth-applications-register-request-members.test.ts(197,50): error TS2345: Argument of type '{}' is not assignable ... packages/client/src/oauth-applications-register-request-members.test.ts(269,41): error TS2345: Argument of type '{ client_name: string; }' is not assignable ... ``` Direction RED as predicted; **count 4 where I predicted 3**, and the extra is nameable: the runtime `it()` I added also omits the member, so it stops compiling too. Both legs ran from the committed state under `trap … EXIT INT TERM` with absolute paths; restore proved by blob equality with `HEAD:packages/client/src/index.ts` **and** empty `git diff HEAD` **and** empty `git status --porcelain`. No dist preflight applies — the pin imports `./index`, a relative source specifier inside its own package, so no built artifact sits in the resolution path (`check:test-source-alias` exit 0). ## Acceptance 4 — nothing else in the signature moves ``` 1 file changed, 1 insertion(+), 1 deletion(-) (the type change itself) - redirect_uris: string[]; + redirect_uris?: string[]; ``` Member-by-member against the merge base, extracted from both revisions and compared: ``` members at BASE: 11 members at HEAD: 11 lines IDENTICAL between the two: 10 client_name? client_uri? contacts? grant_types? logo_uri? policy_uri? response_types? scope? token_endpoint_auth_method? tos_uri? the single differing line: redirect_uris: string[]; -> redirect_uris?: string[]; ``` ## Acceptance 5 — changeset `.changeset/17215-oauth-register-redirect-uris-optional.md`, **minor**: a published package's public surface moves, per the maintainer's ruling of 2026-09-04 (decision batch objectstack-ai#35) that such a change takes at least `minor` — the same grade the sibling objectstack-ai#15447 carried. ⛔ Not breaking in this direction: relaxing a required member to optional keeps every existing call valid, so the changeset carries no BREAKING banner and `check:adr-0087-registration` correctly reports no declared-breaking changeset (exit 0). ## Verification Exit codes captured by redirect-then-`$?`, ⛔ never through a pipe. Lock runs read their own `VERDICT command-exit` line. - `pnpm --filter @objectstack/client typecheck` — **exit 0** (test layer compiles under `tsconfig.test.json`; 0 files / 0 errors in `test-typecheck-debt.json`). - `pnpm --filter @objectstack/client test` — **exit 0**, **43 files / 519 tests passed**. - `pnpm --filter '@objectstack/client^...' build` — **exit 0**. Required first: on an unbuilt `dist` the typecheck reported 17 phantom `TS2307 Cannot find module '@objectstack/spec/*'` errors that have nothing to do with this diff. - Gate families derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (⛔ never a hand list) and reconciled: **`✓ 60 derived famil(ies) accounted for — 59 run, 1 NOT-MEASURED, 0 UNRUN`**. - The first derivation carried a **STALE TREE** warning naming `scripts/pm/check-half-states.mjs`; rather than run a list about a tree nobody is on, `origin/main` was fetched and merged (clean, no `os-regen` deferral, and neither incoming file overlaps this diff) and the families re-derived at `834e79059`. - `pnpm check:dual-build-cjs-loads` — **exit 3, `PREREQUISITE NOT MET`**: it reads built output and 32 packages have no `dist/`. ⛔ Declared NOT MEASURED, never counted as a pass; its own text says "This is NOT a pass: nothing was measured". CI checks out fresh and builds. This diff changes one type annotation and adds test code, so it emits no JavaScript for that gate to read. - Two others refused their prerequisites on the first pass and were remediated, ⛔ not counted as passes until re-run: `check:skill-examples` exit 1 (`packages/client-react/dist` held no declarations) -> after building `client-react` + `client`, **exit 0**, 258 prose examples type-check across 3 surfaces; `check:type-check-debt` exit 3 (the whole-repo re-measure OOMed under `--max-old-space-size=4096`) -> at 8192, **exit 0**, "5 ledger entr(ies) re-measured, 55 raw tsc error(s), none above its recorded number". - `pnpm lint` population, run in full rather than narrowed: `eslint . --no-inline-config` — **exit 0**, **6645 files checked, 0 errors, 0 warnings**, at `834e79059`. Type-aware linting is not enabled (`eslint.config.mjs` states "no `parserOptions.project`, no typed `@typescript-eslint` rules"), so per-file verdicts are independent. ## Acceptance notes - **The `[]` asymmetry, noted not filed.** The TYPE cannot express "non-empty array", so `register({ redirect_uris: [] })` compiles and the vendor refuses it at runtime. This is unchanged by this PR — it was equally true while the member was required, which is also why the old required-ness guarded nothing the vendor did not already guard better. Recorded in the docblock and in the new pin so the compiling call is not mistaken for a legal one. - **`return-type-precision.test.ts` passes `redirect_uris` at both its call sites (including `[]`) and both still compile** either way; no change was needed there. - The card's item 3 — checking the answer against the two other registration doors (`POST /oauth2/register` DCR, and the ObjectStack `sys-oauth-application/register` mount) — is ⛔ deliberately not taken here: neither door's shape moves, and objectstack-ai#17210 already ruled the mount stays `server-only`. --- _Generated by [Claude Code](https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #15447
Clause-②: no
Declared
noby the dispatchingdomain:cliseat (#6024) and unchanged by the delivered diff: this is a pull-back of a published request type onto what the route it posts to already accepts, and the face ispackages/client/src/index.tsonly — nopackages/spec/src/**, no*.zod.ts, no error-code ledger. Breaking is not clause-②; the**BREAKING**banner is carried by the changeset instead.What changed
ObjectStackClient.oauth.applications.registerdeclared three members thatPOST /api/v1/auth/oauth2/create-clientnever honoured —name,scopes,metadata. They are removed. Nothing is added: no export, no key, no compatibility alias.The route is mounted verbatim from
@better-auth/oauth-provider@1.7.2. Its body schema declares 21 members and sets nocatchall, so it is zod's default strip: an unknown key is dropped rather than refused and the caller gets HTTP 201 plus a client that quietly does not have the value. There was no error to notice and no receipt to check.The removed members are the vendor's RECORD vocabulary, not typos
This is the part a reader is most likely to get wrong, so it is stated first and plainly.
nameandscopesare not misspellings ofclient_nameandscope— they are the names of the DB columns those two wire members write:sys_oauth_applicationcolumnclient_name: 'CTRL-CLIENT-NAME-15447'namescope: 'openid profile email'scopes, as the JSON array["openid","profile","email"]So the type used to offer the record spelling and the wire spelling side by side, and only the wire one worked. Triage's words on the disposition this PR carries out: 「a ruling that treats them as misspellings should say so knowingly.」 It does, here.
A consequence:
scopestoscopeis not a rename.scopeis one space-delimited string, and the array form is refused on the wire —400 [body.scope] Invalid input: expected string, received array. Only.join(' ')passes. The prescription in the changeset says so.metadatahas no reachable door at all: onlyPATCH /admin/oauth2/update-clienthonours it, and that endpoint isSERVER_ONLY, whichbetter-call's router skips — driven over HTTP it answers 404 with a zero-byte body.Measurement provenance — nothing here was re-driven
The rig verdict comes from the measurement round already on the card (issue #15447, comment 5559384773): real
betterAuthplus realoauthProviderover the real ObjectQL engine on a real TCP socket, driven through the realObjectStackClient. Each of the three came back absent from the response, absent fromoauth.applications.get, absent fromoauth.applications.list, andnullin the DB row. A second, independent barrier stands behind the strip — the handler funnels the parsed remainder into the opaque-metadata envelope and all three names sit inOPAQUE_METADATA_RESERVED_FIELDS— so loosening the SDK alone could never have made them arrive. The vendor member list has no drift at 1.7.2.The pin, and why it cannot assert on a 201
packages/client/src/oauth-applications-register-request-members.test.tscarries both halves.Acceptance ① — the pin fails on the DEFECT, not on the fix. It is type-level, because the route answers 201 either way; that is the entire reason this was invisible, and any runtime assertion on the status or the response body would have been green in both states. Two independent directions:
keyof RegisterRequest— red if a member returns, and equally red if a new one is added (which is also the STOP-CONDITION guard);@ts-expect-errorexcess-property probes, one per removed member.Ablation, direction predicted in writing before the run: RED, by both signatures. Driven, with the three members put back:
pnpm --filter @objectstack/client typecheckunder that mutation: exit 1, "4 type error(s) in a file the ledger does not cover".Acceptance ② — the negative control.
client_nameand a space-joinedscopemust still reach the server byte for byte. Asserted as full-string equality on the request body, nevertoContain— a body carrying a re-introduced member, or one the SDK had begun translating, would satisfy a containment check. Ablation, direction predicted in writing first: RED, and specifically 2 of 3, because the third case's transport double answers 400 without ever reading the request body. Driven, with the SDK made to filter the body:Both ablation legs ran from the committed state, mutated under a
trap ... EXIT INT TERMwith absolute paths, proved on disk by occurrence counts of the injected text and of the deleted text plus a blob hash off theHEADblob (06216277bf9f67bfdf6cb2b852ce034d4effad1a), and both restores were proved bygit diff HEADempty and a blob equal to HEAD's, withgit status --porcelainempty. No dist preflight applies: the subject resolves through the relative./indexsource import inside its own package, so no built artifact sits in the resolution path (pnpm check:test-source-aliasgreen confirms no unaliased dist import here).Verification
pnpm --filter @objectstack/client typecheck— exit 0; test layer compiles undertsconfig.test.json, 0 files / 0 errors held intest-typecheck-debt.json.pnpm --filter @objectstack/client test— exit 0; 39 files / 478 tests passed.node scripts/pm/dispatch-gates.mjs --commands(never a hand list) and reconciled:✓ dispatch-gates --ran: 58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED. Every exit code captured by redirect-then-$?, never through a pipe. 56 of 58 green on the first pass; the two that were not are declared below, and neither is counted as a pass.pnpm --filter @objectstack/spec run check:skill-examples— first run exit 1 on its own prerequisite refusal (packages/client-react/dist holds no .d.ts declarations). NOT MEASURED, not a finding. Re-run afterpnpm --filter @objectstack/client-react --filter @objectstack/client build: exit 0, 258 prose examples type-check across 3 surfaces.pnpm check:type-check-debt— first run exit 3,PREREQUISITE NOT MET(the whole-repo tsc re-measure OOMed under the--max-old-space-size=4096this container's resource discipline sets, which the gate itself names as "the caller's NODE_OPTIONS, which is tighter"). NOT MEASURED. 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".pnpm check:dual-build-cjs-loads— exit 3,PREREQUISITE NOT MET: it reads built output and 35 packages have nodist/, which needs a whole-repopnpm build. ⛔ Declared NOT MEASURED, never counted as a pass; CI checks out fresh and builds, so it runs there. This diff removes type annotations and adds a test file, so it emits no JavaScript change for that gate to read.node scripts/check-adr-0087-registration.mjs --base origin/main— exit 0, one declared-breaking changeset carryingnot-required (no-migration-prescription), claimed on a positive argument with its residual declared rather than hidden.node scripts/docs-audit/affected-docs.mjsreports 0 docs across 1 changed package (1 test file excluded) — but with no anchors derived, which is that tool's own coverage caveat and ⛔ not a clean bill of health. Swept by hand as well: the only hand-written page namingoauth.applications.registerisdocs/qa/platform-checklist/areas/identity-auth.json, which names the three registration doors and never the SDK request members, so it stays true unchanged.packages/client/README.mdlists namespaces only. No release-owned page touched.Single-writer measurement (acceptance ⑤)
Measured from the open PR list, never from remote branches, on 2026-09-09: all 18 open PRs enumerated, each one's file list read as its own three-dot diff against its own merge base.
packages/client/src/index.ts— held by no open PR. The two prior holders named on the card, fix(client): the scoped SDK reads metadata.prefix off the advertised routes instead of restating /meta #17122 and fix(client): a bearer-mode ObjectStackClient adopts the session token the three rotating auth routes hand it #17182, are both merged and absent from the open list; this branch is cut fromd61139f1ba, which is fix(client): a bearer-mode ObjectStackClient adopts the session token the three rotating auth routes hand it #17182's squash.packages/client/package.jsonandpackages/client/CHANGELOG.mdheld by open PR chore: version packages #17076 — inside the very package whosesrc/index.tscame back free, so the scan demonstrably reaches there. Second control, for multiplicity:packages/metadata/src/migrations/migrate-sys-notification-to-event.tsis reported held by two open PRs (refactor(metadata,spec)!: retire the adr-0030-notification-event migration — no operator door, no platform invoker #17194 and fix(types,metadata,cli): read the dialect text out ofcausefor operator-facing records #17073).Reported, deliberately NOT fixed here
redirect_urisis a fourth divergence on the same type and is left alone on the dispatching seat's instruction. The vendor declares it an optional array of string; this SDK declares it required. Outside this card's three, and widening the PR to take it would change a signature nobody asked to change. Reported to the PM, who overruled the not-filed call: it is filed as 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 (bug/domain:cli/finding/priority:p3, unassigned) with the driven evidence. The direction is the safe one — the SDK is narrower than the producer, so nothing is silently lost and no receipt lies; what a caller loses is expressiveness, since a request the route would accept has no spelling here. That is why it grades p3, and it is ⛔ not why it would go unfiled: the declaration still does not match the route it posts to, which is client SDKoauth.applications.registerdeclares three request members the better-auth body schema silently strips (metadata,name,scopes) #15447's own family.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 and is ⛔ NOT pulled in: it is not dispatched, and a p2 must not drag a p1 into its review cycle. Observed while reading the file: its fix is already onmainat this base, carried by the long comment ondeleteand pinned byoauth-applications-delete.test.ts.namebeing honoured byPOST /api/v1/auth/sys-oauth-application/register— a different, ObjectStack-owned mount — is not an argument for keeping it on this method: no SDK method builds that URL, so no caller of this SDK can reach it by spellingnamehere. Filed separately as 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, with the driven evidence, rather than closed here.验收备注
origin/mainadvanced to22c94e53b6while this branch was in flight, and one file the derivation reads —scripts/pm/check-clause2-carriers.mjs— changed across that range (fix(pm): name PLACEMENT, not spelling, when a Clause-② key sits mid-line #17201). Read: the change is reporter-only. It adds a PLACEMENT near-miss message and its own docblock says 「⛔ This is a REPORTER, never a reader ...CLAUSE2_KEY_LINEis untouched」, so the accept set is unmoved and no gate family is added or removed by it. The 58-family list therefore still holds; noted rather than papered over.check-type-check-coverage's provenance line reportswalkedTestFilesmoving 3503 to 3526 against the record. That is the shared population, not this diff (which adds one test file), and the line says so itself: "only the floors decide", and the floor is 2800.os-devseat in sessionsession_015QE8qk46e5CHJxyQEUjbf8, dispatched by thedomain:cliexecution PM seat ([PM seat] domain:cli — 🟢 os-project-manager · session_01QCdUBjM47SxioST9z5Zwdf · R78 接任(维护者接管令,前任断粮)· 现值在标记 5747152616,⛔ 正文仍是 R76 的 · 在飞 PR 2(#18962 全绿待达档复核 · #18867 停在维护者)· #18893 死认领回收中 #6024).Generated by Claude Code