Skip to content

fix(client)!: oauth.applications.register declares only the members /oauth2/create-client accepts - #17209

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-15447-oauth-register-declared-members
Sep 9, 2026
Merged

os-project-manager merged 3 commits into
mainfrom
claude/issue-15447-oauth-register-declared-members

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #15447

Clause-②: no

Declared no by the dispatching domain:cli seat (#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 is packages/client/src/index.ts only — no packages/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.register declared three members that POST /api/v1/auth/oauth2/create-client never 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 no catchall, 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. name and scopes are not misspellings of client_name and scope — they are the names of the DB columns those two wire members write:

posted on the wire lands in sys_oauth_application column
client_name: 'CTRL-CLIENT-NAME-15447' column literally named name
scope: 'openid profile email' column literally named 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: scopes to scope is not a rename. scope is 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.

metadata has no reachable door at all: only PATCH /admin/oauth2/update-client honours it, and that endpoint is SERVER_ONLY, which better-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 betterAuth plus real oauthProvider over the real ObjectQL engine on a real TCP socket, driven through the real ObjectStackClient. Each of the three came back absent from the response, absent from oauth.applications.get, absent from oauth.applications.list, and null in 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 in OPAQUE_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.ts carries 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:

  • a key-set equality on keyof RegisterRequest — red if a member returns, and equally red if a new one is added (which is also the STOP-CONDITION guard);
  • three @ts-expect-error excess-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:

src/oauth-applications-register-request-members.test.ts(122,5): error TS2344: Type '"client_name" | ... | "response_types"' does not satisfy the constraint '"Expected: literal string: client_name, Actual: literal string: name" | ... | "Expected: literal string: client_name, Actual: literal string: scopes"'
src/oauth-applications-register-request-members.test.ts(147,5): error TS2578: Unused '@ts-expect-error' directive.
src/oauth-applications-register-request-members.test.ts(152,5): error TS2578: Unused '@ts-expect-error' directive.
src/oauth-applications-register-request-members.test.ts(157,5): error TS2578: Unused '@ts-expect-error' directive.

pnpm --filter @objectstack/client typecheck under that mutation: exit 1, "4 type error(s) in a file the ledger does not cover".

Acceptance ② — the negative control. client_name and a space-joined scope must still reach the server byte for byte. Asserted as full-string equality on the request body, never toContain — 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:

 × sends `client_name` and a space-joined `scope` byte for byte
 × is a pass-through: every surviving member arrives unchanged and nothing is added
 Tests  2 failed | 1 passed (3)

Both ablation legs ran from the committed state, mutated under a trap ... EXIT INT TERM with absolute paths, proved on disk by occurrence counts of the injected text and of the deleted text plus a blob hash off the HEAD blob (06216277bf9f67bfdf6cb2b852ce034d4effad1a), and both restores were proved by git diff HEAD empty and a blob equal to HEAD's, with git status --porcelain empty. No dist preflight applies: the subject resolves through the relative ./index source import inside its own package, so no built artifact sits in the resolution path (pnpm check:test-source-alias green confirms no unaliased dist import here).

Verification

  • pnpm --filter @objectstack/client typecheckexit 0; test layer compiles under tsconfig.test.json, 0 files / 0 errors held in test-typecheck-debt.json.
  • pnpm --filter @objectstack/client testexit 0; 39 files / 478 tests passed.
  • Gate family derived with 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 after pnpm --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=4096 this 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-loadsexit 3, PREREQUISITE NOT MET: it reads built output and 35 packages have no dist/, which needs a whole-repo pnpm 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/mainexit 0, one declared-breaking changeset carrying not-required (no-migration-prescription), claimed on a positive argument with its residual declared rather than hidden.
  • Docs drift: node scripts/docs-audit/affected-docs.mjs reports 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 naming oauth.applications.register is docs/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.md lists 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.

Reported, deliberately NOT fixed here

验收备注


Generated by Claude Code

…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>
@github-actions github-actions Bot added the size/m label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4261fbc80e67b1715d62e02417f959430ad2666dpackageMentionDocs.

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Docs Drift Check — discharged, page by page

The 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 22c94e53b6.

The coarse fallback — all 14 pages it names, each with the reading that keeps it true

node scripts/docs-audit/affected-docs.mjs --json 22c94e53b652d95289e93aaa78c71e9c8436ec92packageMentionDocs. Two of the 14 mention OAuth at all; the other twelve mention @objectstack/client and nothing this diff touches.

page reading
content/docs/api/client-sdk.mdx Names the oauth namespace in one sentence and then defers to index.ts "for the full surface". It enumerates no request member of any method, so a member leaving one cannot falsify it. Structurally immune here, by that deferral.
content/docs/permissions/authentication.mdx Its OAuth content is about sign-in providers (Google/GitHub social login, POST /api/v1/auth/sign-in/social, the /api/v1/auth/callback/... return) — the opposite side of OAuth from registering a client of our own provider. It never names oauth2/create-client, oauth.applications.register, or any of the three removed members. True before and after.
content/docs/api/data-flow.mdx · api/environment-routing.mdx · api/error-catalog.mdx · getting-started/your-first-project.mdx · kernel/runtime-services/data-service.mdx · kernel/runtime-services/index.mdx · kernel/runtime-services/storage-service.mdx · plugins/packages.mdx · protocol/kernel/realtime-protocol.mdx Zero occurrences of oauth.applications, applications.register or create-client. They mention the package, which is the pre-#9192 wide predicate doing its job; nothing in them is about this method.
content/docs/releases/implementation-status.mdx · releases/v16.mdx · releases/v17/17-3.mdx Release-owned — ⛔ never edited in a code PR, and none of the three names this surface anyway. This PR's input to the release pages is its changeset, as the guardrail requires.

Caveat 1 — "1 name(s) were too generic to anchor anything (single lowercase words)"

That name is name, one of the three members removed. It is precisely the token the anchor derivation cannot use, so this run's silence about it carries no information at all. Swept by hand instead, over content/, docs/, skills/, examples/ and every published README, for applications.register, oauth2/create-client, client_name, scopes and the Record-typed member: the only hand-written page that names the SDK method is docs/qa/platform-checklist/areas/identity-auth.json, and it names the three registration doors, never a request member. True unchanged.

Caveat 2 — the inputs-vs-emitter blind spot, and a row this run could not report

⭐ Reported rather than cleared: docs/qa/platform-checklist/areas/identity-auth.json is outside this tool's corpus entirely. The tool walks content/docs; the platform checklist lives under docs/. So its absence from every list above is structural, not evidence. It is the one page in the repo that maps oauth2/create-client to oauth.applications.register by name (lines 1008, 1017, 1078) — exactly the kind of page the check exists to catch — and it can never appear on a run of this tool. Read by hand: it describes doors and route mappings, not request members, so this diff leaves it true.

⭐ Second row the anchor pass could not reach: content/docs/protocol/objectui/actions.mdx (line 424) documents the Console's create_oauth_application action posting to /api/v1/auth/sys-oauth-application/register. It states the rule by its YAML inputs, sharing no identifier with packages/client/src/index.ts, which is caveat 2's shape exactly — an emitter-only diff cannot list it. Read by hand: it documents the Console door's action shape and never the SDK method, so this diff leaves it true. It is, however, the page a reviewer of #17210 wants, since that card is about exactly this door having no SDK method.

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>
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Patch round — the third pin case now sends the body that produced the recorded 400

Head 5e3838531c. One case changed, nothing else.

The defect, restated so the fix is checkable

The third it() sent scope: 'openid profile email' — the joined string the route accepts with 201 — against a transport double that answers 400 unconditionally, and asserted the array-form refusal. So it paired a valid request with the invalid request's answer, and its title claimed two things the case could not witness: the route's acceptance of the joined form (the double never 201s) and its refusal of the array form (no array was ever sent). ⛔ A brand-new pin whose title is false at birth is not a follow-up card.

What it is now

  • Retitled: surfaces the route's refusal of an array-form scope rather than swallowing it. It names what the case can see — the SDK's handling — and no longer claims the route's verdict.
  • Fixture made self-consistent: the request now carries the array form, spelled through a @ts-expect-error, mirroring direction 2 of the type pin. That is the body which actually drew the recorded 400, so the recorded answer is now the answer to the request this case makes.
  • Comment says the 400 is RECORDED, not produced: replayed verbatim from the driven run (issue client SDK oauth.applications.register declares three request members the better-auth body schema silently strips (metadata, name, scopes) #15447, comment 5559384773), with the clause that makes the pairing coherent rather than merely relabelled — after this card's narrowing the array form is no longer expressible through the declared type at all, the removed scopes having been the member that invited it.
  • The suppression is load-bearing in a second direction: widen scope to accept an array and the directive goes unused, so the case goes red.
  • One line outside the case, declared rather than slipped in: the file header said "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 defect class as the one this round is about, which is why it was not left for later.

The assertion itself is untouched — it was correct, and it is what the case keeps.

Re-measured on the new head, not carried over

packages/client/src/index.ts is untouched: its blob at this head is 06216277bf9f67bfdf6cb2b852ce034d4effad1a, byte-identical to the previous head's. So is the changeset.

  • pnpm --filter @objectstack/client typecheckexit 0; check:test-typecheck: OK ... 0 file(s) / 0 error(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 after the push and the body edit.
  • Gate family re-derived and reconciled on this head: same 3 paths, 58 derived famil(ies) accounted for - 58 run, 0 NOT-MEASURED. The eleven source-text-sensitive families were re-run individually (check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, comment-mask adoption and corpus, keyed-text-bounds, undeclared-dep-imports, registry-log-declared, empty-changeset, adr-0087-registration, type-check-coverage) — all exit 0, every code captured by redirect-then-$?.

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:

  • Leg 1 (the pin) — predicted RED with the signature unchanged at 4 errors, and specifically that the new fourth suppression must not appear as a TS2578, since leg 1 restores scopes rather than widening scope. Driven: 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 this 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). The citation in the body is still true on this head.

Both legs ran from the committed state under a trap ... EXIT INT TERM with absolute paths, proved on disk by occurrence counts of injected and deleted text plus a blob differing from HEAD's, and both restores proved by a blob equal to HEAD's with git diff HEAD empty and git status --porcelain empty.

Also in this push

#17215 — the redirect_uris divergence, filed as instructed: the SDK declares it required where the vendor declares it optional, so a legal vendor call is inexpressible. bug / domain:cli / finding / priority:p3, unassigned, no pm: label, labels verified by a compare-style read-back. The acceptance-notes bullet in this PR's body no longer says "not filed" — it names the card.

Still a draft; auto-merge not armed. Both stay the PM seat's.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 9, 2026 18:15
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit bccf311 Sep 9, 2026
39 of 41 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-15447-oauth-register-declared-members branch September 9, 2026 18:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client SDK oauth.applications.register declares three request members the better-auth body schema silently strips (metadata, name, scopes)

2 participants