Skip to content

fix(client)!: bind the oauth.* family to the wire shapes better-auth sends - #15445

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-14312-oauth-family-wire-shape-binding
Sep 5, 2026
Merged

os-litant merged 2 commits into
mainfrom
claude/issue-14312-oauth-family-wire-shape-binding

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Part of #14312 — card 1 of 3 of the #12104 family. Merging this does not complete that card: four of its five methods are bound here, and the fifth is an open question stated below.

Clause-② is yes (this narrows a published return type), so the PR is draft and carries needs:contract-review. It waits for an at-tier contract reviewer; auto-merge is not armed and it is not enqueued.

What changed

Four oauth.* methods ended return res.json() with no return annotation, so lib.dom's Response.json(): Promise< any > was their published type. Each now declares the shape its route serves, and its exported-any-returns.json entry is deleted in the same change.

method before now
oauth.applications.register any OAuthApplicationRegistration
oauth.applications.get any OAuthApplication
oauth.applications.getPublic any OAuthApplicationPublic
oauth.consent any OAuthConsentResult

Ledger: 40 entries before, 36 after — exactly these four deleted, nothing else touched.

The shapes were read off the WIRE

The ruling is explicit that better-auth's own types are the pre-serialization server shape and the wire is the only source of truth. So these were measured, not transcribed: a real betterAuth with the real oauthProvider plugin over the real createObjectQLAdapterFactory on a real ObjectQL engine and a real better-sqlite3 database, a real signed-up user and a real session cookie, driven through the real ObjectStackClient with only the socket stood in for.

register — POST /oauth2/create-client, HTTP 201:

{"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}''````

get — GET /oauth2/get-client, HTTP 200: the same row with client_secret stripped.

getPublic — GET /oauth2/public-client, HTTP 200:

{"client_id":"pWhEhQXRmdLVGVZkgiobMJfxEmsgVCoB","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":[]}''````

consent — POST /oauth2/consent, HTTP 200 — accept, then deny:

{"redirect":true,"url":"https://app.example.com/cb?code=B5icJNiIgnGcmb8iHusBf2ehnAvxGHQ2&state=st4te&iss=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fv1%2Fauth"}
{"redirect":true,"url":"https://app.example.com/cb?error=access_denied&error_description=User+denied+access&state=st4te&iss=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fv1%2Fauth"}

Both decisions answer the same shape and the same 200; the outcome rides in url. So redirect is not the accept/deny signal, and the type says so.

Twice the vendor's .d.ts was the wider, wrong answer

  • getPublic is declared OAuthClient — the full row — but its handler hand-picks seven columns (clientId, name, uri, contacts, icon, tos, policy). OAuthApplicationPublic is that projection, derived with Pick< … > from its parent so the two cannot drift. Its redirect_uris is always [] on this route: the projection does not pass the stored URIs through and the serialiser defaults the missing value — the member carries no information here, and the JSDoc says so.
  • user_id and application_type are declared nullable by the vendor, but the serialiser folds a null column to undefined, so null is unreachable on the wire and is not declared.

Timestamps: number. The ruling's ISO-8601 clause has no site in this family.

The ruling ordered every Date-typed field declared as an ISO string, forbade a Date declaration, and forbade a revival layer. There is no Date field here to convert. RFC 7591 carries client_id_issued_at and client_secret_expires_at as Unix-epoch seconds, and the provider converts its stored Date to a number before serialising:

const _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0;
const _createdAt = createdAt ? Math.round(new Date(createdAt).getTime() / 1e3) : void 0;

So the wire sends neither a Date nor an ISO string — it sends a number, measured above as 1788534576. Both are declared number and a type-level pin holds them there. The ruling's prohibitions are satisfied: nothing declares a Date, and no revival layer exists. Flagging this explicitly because it is the one place this card's outcome differs from what the ruling's wording anticipated — the policy is intact, it simply has no site to apply to.

⚠️ oauth.applications.delete is NOT bound — an open question for the reviewer

Its route answers HTTP 200 with a zero-byte body under content-type: application/json (its handler returns nothing; the vendor declares it void). Driven through the real client:

client.oauth.applications.delete(id)  ->  REJECTED: SyntaxError | Unexpected end of JSON input

Every successful delete rejects — the row is already gone server-side by then. No declared return type can be honest while that res.json() call stands, so binding it needs a behaviour change, which is a decision beyond the type-narrowing this family was scoped to. Its ledger entry therefore stays open, which is the shrink-only ledger working rather than being relaxed. The two readings, and what each costs, are in the dev report on #14312. I did not pick one.

Verification

Final commit c0dbf802c9, clean tree.

  • pnpm --filter @objectstack/client check:exported-any-returns — ✅ no NEW exported callable of @objectstack/client resolves to any: 317 callables reached (52 caller-supplied generics, not counted as erasure), 36 ledgered site(s) still open. Its --self-test also reports Ledger is exact in both directions.
  • Positive control, run before any of this was trusted: automation.trigger (bound by fix(client): bind the five in-repo return res.json() methods erased to Promise< any >, and measure the 38 third-party ones #13082) is absent from the ledger and resolves to Promise< AutomationResult > in the built dist, and the baseline run reported 40 open sites — so the instrument was known to see this world before its output was read.
  • pnpm --filter @objectstack/client typecheck — tsc --noEmit clean, then check:test-typecheck: OK — 0 file(s) / 0 error(s). The pin file's presence in that program is proved by tsc -p tsconfig.test.json --listFiles, not assumed.
  • pnpm --filter @objectstack/client test — 33 files, 431 tests, all passing.
  • Gate union re-derived at final HEAD with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands: 44 commands, 41 pass. The other three are PREREQUISITE NOT MET refusals that need a full-workspace build (check:skill-examples wants packages/client-react/dist; check:dual-build-cjs-loads and check:type-check-debt want the built closure) — not measured, and reported as such rather than as passes. The five roster gates whose baseline sits under a directory my paths are in were run explicitly and all pass.
  • Declared narrowing: satisfying those three prerequisites needs a full-workspace build, which is lock-governed here and timed out of the shared verify lock twice (~18 min queued behind a sibling ...@objectstack/rest test sweep). Rather than hold the box, the check is narrowed and declared: CI builds the farm and runs all three regardless, and none of them reads a file this diff touches.
  • No in-repo consumer breaks: git grep finds zero call sites of the four bound methods anywhere outside the pin file — the other references are the AUTH_ROUTE_LEDGER rows (strings) and docs.

Ablation — direction predicted first, then measured

Predicted: reverting getPublic's annotation gives two independent reds. Both landed.

The mutation was proved on disk before anything was read (removed-text count 0, injected-text count 1, source blob moved), then rebuilt and proved to have reached dist/ with ablation-dist-preflight.mjs --absent. The first attempt was a no-op — a \Q…\E quoting bug made perl match nothing and exit 0 — and the on-disk check is what caught it; it is recorded here rather than quietly re-run.

  1. check:exported-any-returns exit 1: ❌ 1 exported callable(s) … resolve to any and are not ledgered: ObjectStackClient.oauth.applications.getPublic resolves to Promise< any >
  2. check:test-typecheck exit 1: 3 type error(s) in return-type-precision.test.ts — the equality pin plus the two now-unused @ts-expect-error directives (TS2578), which is what makes those suppressions evidence rather than decoration.

Restored with git checkout HEAD -- naming the absolute path and proved by whole-tree git status --porcelain empty, blob hash equal to the HEAD blob (d38d5db31d…), git diff HEAD empty, then rebuilt and the marker proved present again.

Scope

Only the five methods this card names, on packages/client/src/index.ts. The auth.* and organizations.* cards are serialized behind this one on the same hot file and are untouched. The #13080 BREAKING-token gate is not addressed here — the ruling says that card is independent and is not to be done in passing.


Generated by Claude Code

…sends

Four methods of the `oauth.*` namespace ended `return res.json()` with no
return annotation, so `lib.dom`'s `Response.json(): Promise<any>` was their
published type. Each now declares the shape its route actually serves, and its
`exported-any-returns.json` entry is deleted in the same change:

  oauth.applications.register  -> OAuthApplicationRegistration
  oauth.applications.get       -> OAuthApplication
  oauth.applications.getPublic -> OAuthApplicationPublic
  oauth.consent                -> OAuthConsentResult

The shapes were read off the wire against a real server, not off better-auth's
own `.d.ts` — twice the vendor's declaration was the wider, wrong answer:
`getPublic` is declared as the full client row but its handler hand-picks seven
columns, and `user_id` / `application_type` are declared nullable while the
serialiser folds a null column to `undefined`.

Timestamps are RFC 7591 numbers (Unix epoch seconds), not `Date` and not
ISO-8601 strings: the provider converts its stored `Date` to a number before
serialising, so no `Date` reaches the wire and no revival layer exists.

`oauth.applications.delete` is deliberately NOT bound and keeps its ledger
entry: its route answers HTTP 200 with a zero-byte body, so its `res.json()`
rejects with a SyntaxError on every successful delete. No annotation can be
honest while that call stands, and binding it needs a behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added the size/m label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 45 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/deployment/cli.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/kernel/runtime-services/audit-service.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/permissions/authentication.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/permissions/delegated-administration.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/permissions/permission-sets.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/permissions/positions.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/permissions/record-view-auditing.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/protocol/kernel/config-resolution.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via user_id (symbol, a field of interface OAuthApplication))
  • content/docs/protocol/objectui/actions.mdx (via client_id (symbol, a field of interface OAuthApplication), client_secret (symbol, a field of interface OAuthApplication), client_id (literal, a string literal in OAuthApplicationPublic))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/client/exported-any-returns.json) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 ba426b0f091b8d5901acf10bb8e649b514349f49 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3e522119655fdc37d07255b7e96d90286810c488 — the merge of head 747b9e670c6bda153e0b12fb814f4d4eecd8b1fa into base ba426b0f091b8d5901acf10bb8e649b514349f49, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3e522119655fdc37d07255b7e96d90286810c488 && git checkout 3e522119655fdc37d07255b7e96d90286810c488
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba426b0f091b8d5901acf10bb8e649b514349f49 747b9e670c6bda153e0b12fb814f4d4eecd8b1fa && git checkout -B drift-repro ba426b0f091b8d5901acf10bb8e649b514349f49 && git merge --no-ff 747b9e670c6bda153e0b12fb814f4d4eecd8b1fa

node scripts/docs-audit/affected-docs.mjs --json ba426b0f091b8d5901acf10bb8e649b514349f49

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ba426b0f091b8d5901acf10bb8e649b514349f49 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…owing

The changeset declares `**BREAKING**` (and a title bang) and carried no
`adr-0087:` disposition, so `check-adr-0087-registration.mjs` exited 1 and
Check Changeset has been red on this PR since 16:01Z. This adds the one
disposition line the gate requires.

Category: `type-surface-only` (#13080). All four of its predicates were
checked against this diff rather than assumed:

  1 published                 @objectstack/client has no `private` field in
                              packages/client/package.json.
  2 no-spec-diff              the four changed paths carry no `packages/spec/`
                              prefix.
  3 no-metadata-surface-diff  `metadataSurfaceKind` is null for all four.
  4 narrowed-from-erased      read at both revs with the gate's own
                              `readDeclaredTypeSurface`: `register`,
                              `getPublic` and `consent` are unannotated at the
                              merge base and concrete at HEAD.

Predicate 4 is NOT claimed for the fourth narrowed member. The reference
`packages/client/src/index.ts#get` does not address `oauth.applications.get`:
the file declares 13 members named `get` and the predicate reads the FIRST
(line 1928), which is an unrelated member, unannotated at both revs. Naming it
would assert a verified fact about the wrong member, so it is named in the
disposition's prose instead and the ambiguity is filed as its own card.

No annotation, exported type, method body or ledger row is touched: the
contract settled by the at-tier review is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Status correction — this PR is GREEN, and the REJECT's sole blocking finding is cleared

This seat has reported this PR's colour wrongly in both directions, so here is the measurement rather than a recollection.

What I measured, just now, at head 747b9e670c6

.changeset/client-oauth-family-wire-shape-binding.md, read from git, counted unpiped with the exit code captured after redirection:

BREAKING  count = 1
adr-0087  count = 1

The disposition marker is present — one <!-- adr-0087: not-required (…) --> line carrying a reasoned body. And the check runs:

33 check runs — every one success or skipped, none in progress
Check Changeset      success   2026-09-05T00:58:21Z
Lint & Repo Gates    success

The two errors, stated plainly

  1. Earlier I reported this PR "finished, green, parked" while Check Changeset had in fact been refusing it for eight hours. That was the serious one — work announced as ready that CI was rejecting.
  2. Then I carried the red claim forward after the fix had already landed, and dispatched an agent to repair something that was no longer broken. Less costly, same root cause.

Both are the same failure: treating a CI reading as a durable fact instead of a measurement with a timestamp. A green claim and a red claim expire identically. The rule I had already written for the first case applies unchanged to the second, and I did not apply it.

What happens now

⛔ The PR is not being flipped ready and not enqueued on the strength of green CI alone. It carries needs:contract-review, and the maintainer's ruling is explicit:

fable 额度耗尽, pr 应该等契约复审

The earlier at-tier verdict was REJECT on exactly one blocking ground — the missing adr-0087 disposition — and that ground is now gone. A cleared blocking finding is not an ACCEPT, so an at-tier contract re-review is dispatched against this head. It is instructed to take a fresh verdict rather than re-litigate the old one, and specifically to judge the marker on its merits — presence is not correctness, and not-required on a change the changeset itself calls BREAKING for a typed caller is a claim that deserves testing, as is the marker's own disclosure that it deliberately omits oauth.applications.get because packages/client/src/index.ts#get resolves to the wrong one of thirteen members named get.

The reviewer is also asked to check the riskiest substantive claim in the diff: that user_id and application_type can be declared non-nullable because the serialiser folds a null column to undefined. An unreachable-null argument that is wrong ships a type that lies.

⚠️ Related and consistent-so-far: #15451 (priority:p1) is open on oauth.applications.delete rejecting with SyntaxError on every successful delete — the member this PR deliberately leaves Promise<any>. That card is serial-blocked behind this PR on packages/client/src/index.ts.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

⚖️ CONTRACT-REVIEW VERDICT (re-review) — ACCEPT WITH FINDINGS. Blocking: none.

At-tier contract reviewer (claude-fable-5-1), commissioned by the domain:cli execution PM seat (#6024), session session_01D47qPfEWVPmhguWgBZCi5N. Fresh verdict on head 747b9e670c6bda153e0b12fb814f4d4eecd8b1fa (draft, needs:contract-review, base main). Reviewed from a dedicated detached worktree at that commit; the primary checkout was not touched. Posted as an ordinary comment — agent seats do not submit approving reviews. Everything below is measured unless marked NOT MEASURED.

1. CI, read by me — at 2026-09-05T00:45:42Z, head unchanged

33 check runs, all completed: 30 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in)). Nothing in progress, nothing failed. mergeable_state: clean.

check conclusion completed_at
Check Changeset success 2026-09-04T23:58:21Z (the API reports 23:58Z, not the 00:58Z quoted in the seat's status comment)
Lint & Repo Gates success 2026-09-05T00:16:48Z
Governed Surface Queue Guard success 2026-09-04T23:58:26Z
Type Check · workspace / source gates / consumer gates / debt ledger success ×4 00:07:23Z / 00:00:14Z / 00:05:27Z / 00:04:01Z
Test Core (1/6…6/6) + fold success ×7 last 2026-09-05T00:19:58Z

This reading expires with the next push or re-run; it is a measurement with a timestamp, not a property of the PR.

2. The prior REJECT's sole blocking ground — confirmed cleared, independently

.changeset/client-oauth-family-wire-shape-binding.md at head, read from git, each count unpiped with the exit code captured after redirection: BREAKING = 1, adr-0087 = 1, <!-- adr-0087: = 1 (the gate's readDisposition refuses anything but exactly one). Frontmatter: "@objectstack/client": minor.

I ran both gates myself in the worktree at 2026-09-05T00:38:13Z, against the merge base e89fa9233848c616fcb15745dbe18239c0ee2baa (= HEAD~2, an ancestor of origin/main):

node scripts/check-adr-0087-registration.mjs --self-test      exit 0  (292 assertions)
node scripts/check-adr-0087-registration.mjs --base e89fa923   exit 0
  ✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
    [BREAKING+bang] not-required (type-surface-only) -- verified:
      packages/client/src/index.ts#register  (unannotated -> Promise<OAuthApplicationRegistration>)
      packages/client/src/index.ts#getPublic (unannotated -> Promise<OAuthApplicationPublic>)
      packages/client/src/index.ts#consent   (unannotated -> Promise<OAuthConsentResult>)
node scripts/check-changeset-no-major.mjs --base e89fa923      exit 0  (introduces no `major`)

3. The ADR-0087 marker, on its merits — not-required (type-surface-only …) is the correct disposition, not merely the passing one

Is not-required right for a change the changeset calls BREAKING? Yes, and ADR-0087 says so in as many words. Its addendum of 2026-08-30 (#13080, D8) defines the sixth category as exactly this class — "a published SDK method whose declared return moves off any onto the contract it always answered" — names #12104 as the population it was built for, and states that with the category in place "a published TYPE-surface narrowing resumes honestly carrying **BREAKING** and answers the gate with type-surface-only". Its own table shows why every other answer is wrong here: registered would write an id that objectstack migrate meta, spec-changes.json and the upgrade guide cannot project ("false data in the one ledger this whole mechanism keeps true"); unpublished is false (@objectstack/client publishes); no-migration-prescription and runtime-interface-only are refused by the body's FROM/TO block. The marker's argument is the ADR's argument. The BREAKING token + type-surface-only pairing is the shape the ADR prescribes; the four earlier changesets that dropped the token are cited there as the counter-example.

The four predicates, checked against what the gate actually requires (verifyTypeSurfaceOnly, all four by name): (1) published — @objectstack/client has no private field; (2) no-spec-diff — the diff's 4 paths carry no packages/spec/ prefix; (3) no-metadata-surface-diff — none is a *.zod.ts, contracts/** entry or object definition; (4) narrowed-from-erased — the gate read all three named refs at both revs (log above). For the fourth narrowed member, oauth.applications.get, I read the diff hunk directly: get: async (clientId: string) => { at base → : Promise< OAuthApplication > at head. It satisfies predicate 4 on a direct reading, exactly as the marker's DISCLOSURE says.

The deliberately incomplete member list — acceptable, and forced. I replayed the gate's own readers on packages/client/src/index.ts at head:

parseSymbolRef("packages/client/src/index.ts#oauth.applications.get")  -> null     (IDENT_RE admits a bare identifier only)
definitions named `get`, in file order: 14
  1928:UNANNOTATED 2268:UNANNOTATED 2601:UNANNOTATED 2817:UNANNOTATED 3184:annotated 4072 … 6517:annotated
memberReturnAnnotation(HEAD, "get")                                 -> { annotation: null }
memberReturnAnnotation(text truncated just after line 1928, "get")  -> { annotation: null }
memberReturnAnnotation(text truncated BEFORE line 1928, "get")      -> null

So a marker naming #get would have been refused by predicate 4 for the wrong member ("still UNANNOTATED at HEAD" — about line 1928, which this PR never touched), and there is no spelling that addresses the right one. ADR-0087 D8 § What this does not decide explicitly excludes completeness from the gate's judgment; here the incompleteness is a defect of the reference grammar, not a choice of the author, and disclosing it in the why (which the gate prints in its log and --list) is the honest option. Not a defect of this PR. The marker says the ambiguity "is filed as its own card" — I could not find one (three semantic searches; the 40 newest issues by title, created 2026-09-04T21:16Z→2026-09-05T00:22Z; the commit message, changeset, PR body and all 13 #14312 comments carry no number), so I filed it: #15627, bare and unassigned. It will recur on cards 2 and 3 of the family (get/list/delete/update are all multiply declared in this file).

4. The bump — minor is the level the rule mandates

Source, at head: .github/workflows/pr-automation.yml, the Check Changeset step, section "WHICH LEVEL" (the step Require a changeset (or the skip-changeset label)), which scripts/check-changeset-no-major.mjs's header cites: "A purely additive widening of a published package's public surface (a new exported symbol on an index …) takes at least minor. The commit type may raise a bump but never lower it … During the launch window major stays refused by check-changeset-no-major and breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level." This diff exports four new symbols from packages/client/src/index.ts ⇒ at least minor; major is refused (gate run above, exit 0); fix( cannot lower it. minor is therefore prescribed, and the changeset's own sentence citing the no-major script is accurate. (Not in AGENTS.md; not quoted from there.)

5. The contract claims — verified against the producer, @better-auth/oauth-provider@1.7.2 (lockfile-pinned; read from the package's dist/)

Every JSON-answering route in this family goes through one serialiser, schemaToOAuth (dist/authorize-*.mjs, //#region src/oauthClient/…):

  • getPublic hand-picks seven columns — TRUE. getClientPublicEndpoint returns schemaToOAuth({ clientId, name, uri, contacts, icon, tos, policy }) — exactly those seven, nothing spread in. The vendor's .d.mts declares that endpoint OAuthClient (the full row), so the PR's Pick< … > projection is narrower than the vendor's declaration and matches the code.
  • redirect_uris always [] on that route — TRUE. redirect_uris: redirectUris ?? [], and redirectUris is not among the seven inputs. It is also the only member besides client_id with no ?? void 0, which is the JSDoc's "only client_id and redirect_uris are always present".
  • user_id / application_type non-nullable — TRUE, null is unreachable. user_id: userId ?? void 0, application_type: applicationType ?? void 0. The ...metadata spread precedes these explicit members, so provider metadata cannot reintroduce a null under either key. All three shapes (register → createOAuthClientRegistrationResponse → schemaToOAuth; get → schemaToOAuth(client) then res.client_secret = void 0; getPublic as above) pass through this fold. The only null I can construct anywhere in the shape is a corrupt-row edge on the timestamps (Math.round(NaN) from an unparseable stored date serialises as JSON null) — not a wire-normal path, not declared, not actioned.
  • Timestamps are number (epoch seconds) — TRUE. _expiresAt = expiresAt ? Math.round(new Date(expiresAt).getTime() / 1e3) : void 0, same for _createdAt; client_secret_expires_at: clientSecret ? _expiresAt ?? 0 : void 0. No Date and no ISO string reaches the wire; the vendor's own type says number. The ruling's prohibitions (no Date, no revival layer) hold; its ISO clause has no site — I concur with both prior readers.
  • register answers 201 — TRUE. createOAuthClientEndpoint ends ctx.setStatus(201); return ctx.json(responseBody); resources is attached only when non-empty (optional is right).
  • consent answers { redirect: true, url } for both decisions — TRUE, and stronger than the PR states. Deny returns that literal directly. Accept sets ctx.headers.set("accept", "application/json") on its own context (both accept branches) before delegating to authorize — consentEndpoint(ctx, opts, runOAuth2Authorize) passes the same ctx — and handleRedirect reads that same ctx.headers, so the JSON branch is unconditional on the HTTP path. redirect: false appears nowhere in the dist; the only redirect: values in returned JSON are true. See finding N3 — this refutes the prior verdict's non-blocking finding 2.
  • Served bare — as recorded. packages/plugins/plugin-auth/src/auth-route-ledger.ts rows for consent, create-client, delete-client, get-client, get-clients, public-client: all source: 'better-auth', disposition: 'sdk'. The vendor returns the bare body; the wire bytes themselves are the dev's measurement (see NOT MEASURED).

6. oauth.applications.delete, the ledger, and #15451 — consistent

deleteClientEndpoint ends at await ctx.context.adapter.delete({ model: "oauthClient", … }) with no return; the .d.mts declares the endpoint void. The client's return res.json() stands, so a 200/zero-byte answer rejects with SyntaxError — no annotation could be honest. Ledger at head: 36 entries; exactly one oauth.* entry, ObjectStackClient.oauth.applications.delete — the mechanism working, as the PR says. #15451 is open, priority:p1, pm:queue, and its body and triage comment (5547791432) prescribe the same fix shape the PR defers to (drop res.json(), declare Promise< void >, delete the ledger entry in the same PR, add a resolves-on-empty-200 pin) and the same sequencing constraint (hard serial behind this PR). Consistent in both directions.

7. ADR-0112 · governed surfaces · consumers

  • ADR-0112: the diff asserts on no thrown error — toThrow|rejects\.|toThrowError count 0 in the two-commit diff (every assertion is expectTypeOf). No site.
  • Governed surfaces: the 4 changed paths (.changeset/…, packages/client/exported-any-returns.json, packages/client/src/index.ts, packages/client/src/return-type-precision.test.ts) hit none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md. Governed Surface Queue Guard agrees (success).
  • In-repo consumers: git grep for the four bound calls outside the pin file → only the changeset's own prose; zero TypeScript call sites (positive control: the pin file hits 10×). Sibling repos (objectui, cloud): NOT MEASURED — not on this box.

FINDINGS

Blocking — none.

Non-blocking

N1 — The marker's prose has three inaccuracies; the "own card" it promises did not exist until this review filed it. Measured 14 definitions named get (marker says 13); the marker cites line numbers (3184, 1928) in text that ships into CHANGELOG.md and that this repo's own discipline says to never locate by; and "filed as its own card" was unsubstantiated — now #15627. What would address it: none of this needs a push for the contract's sake; if the branch is touched again for any reason, correct the count, drop the line numbers, and cite #15627. Do not re-push solely for this.

N2 — The PR body is stale against the head and contradicts its own changeset. It still says "Final commit c0dbf802c9" (head is 747b9e6, two commits), its gate-union claim (44/41/3) predates the ADR-0087 gate run that the second commit exists for, and its Scope note "The #13080 BREAKING-token gate is not addressed here" now sits beside a changeset that claims #13080's own category. The prior verdict's required change included correcting exactly these two sentences; that part was not done. What would address it: edit the PR description (no push, CI stays green) before the ready flip — name the second commit, state the ADR-0087 disposition and the gate run, and reword the #13080 sentence to "uses #13080's category; does not touch the gate".

N3 — The prior verdict's non-blocking finding 2 is refuted on the code; do not file the card it recommended. It held that accept answers JSON only when the request carries sec-fetch-mode: cors or an Accept containing application/json, and that the SDK sends neither. The SDK indeed sends neither (private fetch sets only Content-Type, Authorization, X-Environment-Id, Accept-Language) — but the server sets accept: application/json on its own ctx inside consentEndpoint before authorize → handleRedirect reads it, so the JSON branch does not depend on the client at all. Independently, Node's global fetch sends sec-fetch-mode: cors by default (measured on v22.22.2: {"sec-fetch-mode":"cors","accept":"*/*"}), as does a browser fetch() (default mode cors). The OAuthConsentResult JSDoc as written is correct and needs no header caveat. One precision nit, cosmetic: its "the provider's own type declares the literal" is true of the vendor's OAuthRedirectResult; the consent endpoint's declared type is a union that widens to redirect: boolean, and the literal is the wire fact because no redirect: false site exists in the provider — worth one clause if the JSDoc is ever touched.

N4 — NOT MEASURED, stated so as neither pass nor red: the wire bytes themselves (I verified the producer code at 1.7.2; the end-to-end read is the dev's and is corroborated by the code); the element type of OAuthApplicationRegistration.resources and the parsed shape of jwks (transcribed from the vendor, not traced); the remaining optional members' unions (response_types, subject_type, …) beyond spot checks; consumers in objectui / cloud.

Landing

⛔ Not flipped, not enqueued, not armed, not merged — none of that is this seat's. The contract is sound and the disposition is the one ADR-0087 prescribes; N2 is a description edit worth making before the flip, N1 needs no push, N3 needs no action except not filing a false card. The hard serial on packages/client/src/index.ts releases on MERGE, not on this verdict.


Generated by Claude Code


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract re-review: ACCEPT WITH FINDINGS — no blocking finding. Flipping ready and enqueueing.

Verdict at 5548185246, taken at tier against head 747b9e670c6.

⛔ First, correcting myself again

In my previous comment I wrote Check Changeset succeeded at 00:58:21Z. The API says 2026-09-04T23:58:21Z. I transcribed the hour wrong.

It changes no conclusion — the check is green either way — but a seat whose central rule is "a CI reading is a fact with a timestamp" does not get to be sloppy with the timestamp. The reviewer caught it, which is what an independent reviewer is for.

What the review established, independently of me

  • The old blocking ground is genuinely cleared. The reviewer ran both gates itself in a detached worktree against merge base e89fa923: ADR-0087 --self-test exit 0 (292 assertions), --base exit 0; no-major --base exit 0.
  • The marker is right on its merits, not merely present. ADR-0087's 2026-08-30 addendum (D8) defines type-surface-only as exactly this class and names A sixth client-SDK erasure spelling, larger than the other five combined: 43 exported methods return res.json() directly, whose lib.dom type is Promise< any > #12104 as its population. All four predicates hold.
  • ⭐ The disclosed omission of oauth.applications.get is FORCED, not sloppy. parseSymbolRef rejects dotted paths and memberReturnAnnotation returns the first of 14 members named get — so #get would assert a verified fact about the wrong member. ADR-0087 explicitly excludes completeness from the gate's judgment.
  • minor is the mandated level, read at pr-automation.yml → Check Changeset → "WHICH LEVEL", not from AGENTS.md.
  • Every falsifiable contract claim was checked against @better-auth/oauth-provider@1.7.2 dist and held — including the riskiest one: user_id / application_type really are folded to undefined via ?? void 0 after the metadata spread, on all three shapes, so null is genuinely unreachable. The seven-column getPublic projection, the redirect_uris ?? [], the Math.round(getTime()/1e3) timestamps, and the consent server-side accept: application/json all verified at source.
  • delete is consistent: handler ends at adapter.delete, declared void; the ledger at head has 36 entries with exactly one oauth.* entry. client SDK oauth.applications.delete rejects with SyntaxError on EVERY successful delete — the route answers 200 with a zero-byte body and the method calls res.json() on it #15451 (p1) prescribes the same fix shape.

⚠️ This PR body is stale in three places — the record, not the body, is authoritative

The reviewer flagged these and the previous verdict had already asked for two of them. Rather than rewrite prose I did not author, I am superseding them here:

body says truth
"Final commit c0dbf802c9, clean tree." Head is 747b9e670c6, 2 commits.
"the PR is draft and carries needs:contract-review. It waits for an at-tier contract reviewer; auto-merge is not armed and it is not enqueued." Superseded by this comment: the review is done, the label is removed, and the PR is being flipped and enqueued.
"The #13080 BREAKING-token gate is not addressed here" Contradicted by the changeset, which carries the BREAKING banner.
gate-union claim ("44 commands, 41 pass") Predates the ADR-0087 run; the reviewer's own gate runs supersede it.

One finding I am NOT acting on, and one card that now exists

NOT MEASURED, recorded as such

The wire bytes end-to-end (producer code verified; the end-to-end read was the dev's), resources element type and jwks shape, optional unions beyond spot checks, and the sibling repos. ⛔ None of these is a pass.

⇒ Green, mergeable_state: clean, reviewed at tier, no blocking finding, no governed surface touched. Flipping ready and enqueueing through the merge queue.


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 5, 2026 00:52
@os-litant
os-litant enabled auto-merge September 5, 2026 00:52
@os-litant
os-litant added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit e944fdb Sep 5, 2026
38 checks passed
@os-litant
os-litant deleted the claude/issue-14312-oauth-family-wire-shape-binding branch September 5, 2026 01:20
os-litant pushed a commit that referenced this pull request Sep 5, 2026
…ts route answers

`ObjectStackClient.oauth.applications.delete` ended `return res.json()` on a
route that answers HTTP 200 with a ZERO-BYTE body, so it rejected with
`SyntaxError: Unexpected end of JSON input` on EVERY successful delete — after
the row had already been committed away server-side. The method had no success
path a caller could observe, and the obvious recovery (retry) failed
DIFFERENTLY, with the route's 404 `not_found`.

Measured end to end, not inherited: real betterAuth + real oauthProvider over
the real ObjectQL adapter, a real signed-up user and session, driven through the
real client with only the socket stood in for.

    POST /oauth2/delete-client -> 200 · 0 bytes · content-type application/json
                                  · NO content-length header
    through the client, before -> REJECTED: SyntaxError
    the row, server-side       -> ALREADY GONE (get-client answers 404)
    through the client, after  -> RESOLVED | undefined

Emptiness is detected by READING the body. Both shortcuts were measured and both
are unusable here: the status is 200, not the 204 five other delete surfaces in
this file key off, and the response carries no `content-length` header at all.

A non-empty body is still parsed and its failure still thrown, so the ONLY
behaviour that moves is the zero-byte case. `void` is the wire fact: "deleted"
and "was already gone" are distinguished on the ERROR channel (404 `not_found`,
raised by `this.fetch` before any success value exists), so a synthesised
`{ deleted: true }` would be a shape the wire never sends.

`exported-any-returns.json` loses this method's entry in the same change — the
ledger is shrink-only, so the entry goes WITH the binding. Its last `oauth.*`
entry is now gone; 35 sites remain open. The `toEqualTypeOf<any>()` pin PR
#15445 left behind for exactly this moment is replaced by
`returnTypePrecisionPins15451`, and the reject/resolve flip — which no
compile-time assertion can observe — is pinned in a new runtime suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
os-litant pushed a commit that referenced this pull request Sep 5, 2026
….delete`

`**BREAKING**` on two independent axes: the declared return moves off an erased
`any` onto `void` (a compile break, though only for reads of a value the promise
never produced), and the runtime flips from always-rejecting to resolving, so a
caller's `catch` stops firing on success.

⚠️ The ADR-0087 disposition is NOT claimed, and the gate is expected to red on
this changeset until a maintainer settles it. Both legs were measured rather
than guessed:

  type-surface-only          REFUSED at predicate 4. A reference is a bare
                             identifier resolved to the FIRST same-named
                             definition, and index.ts declares TEN members named
                             `delete`; the first (line 2397) is unannotated at
                             both revs, so the gate reports "still UNANNOTATED"
                             about a member this diff never touched. Issue
                             #15627, filed off PR #15445 where the same
                             ambiguity cost the `get` member its place in the
                             marker — here it blocks the only member there is.

  no-migration-prescription  mechanically ACCEPTED, and deliberately not taken.
                             ADR-0087's D7 records that #8277 held this
                             exemption on a detector MISS rather than a positive
                             finding, and names that as the pattern the sixth
                             category exists to stop. Taking it here, with the
                             measurement in hand, would repeat it knowingly.

Dropping the `**BREAKING**` token is the third exit and ADR-0087's addendum
closes it for this class in as many words. So the honest state is a loud red on
one gate with the reasoning written down, rather than a green held by a category
that does not describe this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 9, 2026
…00 its route answers, instead of rejecting on every successful delete (objectstack-ai#15675)

* fix(client)!: bind `oauth.applications.delete` to the zero-byte 200 its route answers

`ObjectStackClient.oauth.applications.delete` ended `return res.json()` on a
route that answers HTTP 200 with a ZERO-BYTE body, so it rejected with
`SyntaxError: Unexpected end of JSON input` on EVERY successful delete — after
the row had already been committed away server-side. The method had no success
path a caller could observe, and the obvious recovery (retry) failed
DIFFERENTLY, with the route's 404 `not_found`.

Measured end to end, not inherited: real betterAuth + real oauthProvider over
the real ObjectQL adapter, a real signed-up user and session, driven through the
real client with only the socket stood in for.

    POST /oauth2/delete-client -> 200 · 0 bytes · content-type application/json
                                  · NO content-length header
    through the client, before -> REJECTED: SyntaxError
    the row, server-side       -> ALREADY GONE (get-client answers 404)
    through the client, after  -> RESOLVED | undefined

Emptiness is detected by READING the body. Both shortcuts were measured and both
are unusable here: the status is 200, not the 204 five other delete surfaces in
this file key off, and the response carries no `content-length` header at all.

A non-empty body is still parsed and its failure still thrown, so the ONLY
behaviour that moves is the zero-byte case. `void` is the wire fact: "deleted"
and "was already gone" are distinguished on the ERROR channel (404 `not_found`,
raised by `this.fetch` before any success value exists), so a synthesised
`{ deleted: true }` would be a shape the wire never sends.

`exported-any-returns.json` loses this method's entry in the same change — the
ledger is shrink-only, so the entry goes WITH the binding. Its last `oauth.*`
entry is now gone; 35 sites remain open. The `toEqualTypeOf<any>()` pin PR
objectstack-ai#15445 left behind for exactly this moment is replaced by
`returnTypePrecisionPins15451`, and the reject/resolve flip — which no
compile-time assertion can observe — is pinned in a new runtime suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

* chore(changeset): declare the BREAKING binding of `oauth.applications.delete`

`**BREAKING**` on two independent axes: the declared return moves off an erased
`any` onto `void` (a compile break, though only for reads of a value the promise
never produced), and the runtime flips from always-rejecting to resolving, so a
caller's `catch` stops firing on success.

⚠️ The ADR-0087 disposition is NOT claimed, and the gate is expected to red on
this changeset until a maintainer settles it. Both legs were measured rather
than guessed:

  type-surface-only          REFUSED at predicate 4. A reference is a bare
                             identifier resolved to the FIRST same-named
                             definition, and index.ts declares TEN members named
                             `delete`; the first (line 2397) is unannotated at
                             both revs, so the gate reports "still UNANNOTATED"
                             about a member this diff never touched. Issue
                             objectstack-ai#15627, filed off PR objectstack-ai#15445 where the same
                             ambiguity cost the `get` member its place in the
                             marker — here it blocks the only member there is.

  no-migration-prescription  mechanically ACCEPTED, and deliberately not taken.
                             ADR-0087's D7 records that objectstack-ai#8277 held this
                             exemption on a detector MISS rather than a positive
                             finding, and names that as the pattern the sixth
                             category exists to stop. Taking it here, with the
                             measurement in hand, would repeat it knowingly.

Dropping the `**BREAKING**` token is the third exit and ADR-0087's addendum
closes it for this class in as many words. So the honest state is a loud red on
one gate with the reasoning written down, rather than a green held by a category
that does not describe this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

* feat(spec): register the oauth.applications.delete binding in the ADR-0087 ledger

The changeset for this branch declared a breaking change and carried no
`adr-0087:` disposition marker, so `check-adr-0087-registration` refused it.
The maintainer ruling on objectstack-ai#15674 (comment 5549251533, 2026-09-05) settled the
disposition: this class routes through `registered`, not through any
`not-required` category, and the sixth category's text stays as written.

- `entries/semantic/18.client-oauth-applications-delete-void.ts` — one
  `SemanticMigration` for major 18, the open window (`PROTOCOL_VERSION` is
  17.0.0). surface: both halves of what a caller of
  `client.oauth.applications.delete` observes — the declared return, `any` to
  `void`, and the settle behaviour, reject-on-every-successful-delete to
  resolve. replacement: no value; the migration is on the settle path, the
  `catch` that fired on every successful delete now fires only on a real
  failure. reason: the wire is byte-identical and no `packages/spec`
  declaration moves, so the ledger is the only channel — and the half that
  actually ran has NO diagnostic, since a `try`/`catch` around the call
  compiles identically before and after while its `catch` stops executing.
  The TS2339 the type move produces names only code that was unreachable.
  acceptanceCriteria: every `catch` around the call re-read by hand, and the
  measured populations (this repo: zero production call sites; objectui at the
  pinned `.objectui-sha`: zero; cloud: NOT MEASURED).
- `migrations/registry.ts` — regenerated by `gen:migration-registry`
  (187 semantic, 161 retired-key, 116 retired-def); never hand-edited.
- The changeset's non-claim note becomes
  `adr-0087: registered client-oauth-applications-delete-void`; the rest of
  the file is byte-identical and keeps its `**BREAKING**` declaration.

Anchors live in a source comment above the type import rather than in the
entry's strings: the strings are projected into `spec-changes.json` and
`docs/protocol-upgrade-guide.md` when 18 becomes current, and this repo's
issue numbers do not resolve for the consumers who read those.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

* docs(spec): the ledger entry names the flat better-auth error body, not an ADR-0112 envelope

`entries/semantic/18.client-oauth-applications-delete-void.ts` listed "same
ADR-0112 error envelope" among the things this change leaves untouched on the
wire. The operative claim is true — the error bodies do not move — but the
label is wrong for this route. `POST {auth}/oauth2/delete-client` is
better-auth's, and its 404 answers the vendor's FLAT shape,
`{"error_description":"client not found","error":"not_found"}`, not
ObjectStack's nested ADR-0112 envelope. The repo states the distinction
verbatim at
`packages/plugins/plugin-auth/src/admin-remove-user-gate-ordering.test.ts:86`:
"ObjectStack's ADR-0112 envelope nests it; better-auth's flat shape does not".

Worth a correction rather than a follow-up because the string is
consumer-facing: entries prefixed `18.` project into `spec-changes.json` and
`docs/protocol-upgrade-guide.md` when major 18 becomes current, and a reader
told "same ADR-0112 error envelope" would write a nested read against a flat
body. What the entry asserts is unchanged; only the mislabel moves.
`migrations/registry.ts` is regenerated by `gen:migration-registry`, never
hand-edited, and a re-run at this head is a byte no-op — the entry is indexed,
not merely present.

This branch squashes, so its commit messages enter `main` verbatim and this
one carries the current state of the disposition question. The ADR-0087
disposition IS claimed: the changeset carries
`adr-0087: registered client-oauth-applications-delete-void`, and
`check-adr-0087-registration` exits 0 at this head, printing "1
declared-breaking changeset(s), each carrying an ADR-0087 disposition" against
that marker. An earlier message on this branch states the disposition is not
claimed and that the gate is expected to red until a maintainer settles it;
that expectation is superseded — the maintainer settled it as ruling D on
objectstack-ai#15674 (2026-09-05), and this class routes through ADR-0087 `registered`.

Measured at this head, exit codes captured by redirect-then-capture:
`gen:migration-registry` re-run byte no-op (`git hash-object` unchanged),
`check:spec-changes` "spec-changes.json is up to date.", `check:upgrade-guide`
"protocol-upgrade-guide.md is up to date.", `check:adr-0087-registration`
self-test (332 assertions) and main both exit 0, the spec migrations suite 117
passed, `@objectstack/spec` typecheck exit 0, and `check:nul-bytes`,
`check:doc-authoring`, `check:spec-parsed-alias` exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…s to the commits that decided them (stage 10) (objectstack-ai#20750)

Part of objectstack-ai#20234

Clause-②: no

Stage 10 of the dead-citation sweep: the migration registry's
hand-written entries. Every tracker number in
`packages/spec/src/migrations/entries/**` that no longer exists on the
board now cites the commit that decided it, in ruling C+D form C (ruling
`5749154545` on objectstack-ai#19123). Where the number alone carried the meaning, the
line now says what was decided. That is 131 sites over 21 numbers. All
of them are comments; the entries' string literals carry no dead number.
`migrations/registry.ts` moves only by `gen:migration-registry`. No
entry id, literal, order, `conversionIds` or code token moves, and no
live citation is removed.

## Boundary

- **In:** all of `entries/**`. The gate's census reads 117 sites there,
under the claim's ~120 slicing threshold, so there is no slice. My raw
walk finds 14 more comment sites that the census does not count (see
*Census* below), which gives 131 in total. They are the same dead card
on the line after a counted site, plus one README line, so they are
rewritten with it.
- **Regenerated only:** `migrations/registry.ts`, 128 lines.
`spec-changes.json` and `docs/protocol-upgrade-guide.md` do not move,
because entry comments are never projected into them. Both `check:`
scripts pass with no regeneration.
- **Out, per the claim:** `registry.ts`'s hand-written parts. They still
hold 13 dead sites, listed under Acceptance notes. The six
`18.*-unit-in-key.ts` entries that PR objectstack-ai#20706 edits carry no dead site,
and I did not touch them. I did not touch `conversions/` or other lanes'
sites.

## The anchors (one per number, reused from earlier stages where they
anchored the same number)

| number | sites | anchor | what the rewritten line says it decided |
|---|---|---|---|
| objectstack-ai#13135 | 26 (13 are `re-charter objectstack-ai#13135`) | commit 9e0ba21 | retires
the paper metadata-customization protocol; re-charter of objectstack-ai#12057, which
stays |
| objectstack-ai#8495 | 23 | commit 4bfe1a5 (PR objectstack-ai#8666 kept) | the precedent: the
first 17.x-line narrowing registered under protocol 18 (its own second
commit says so) |
| objectstack-ai#8715 | 16 | commit 2c86fe3 | the ApiKeySchema retirement; its
message names the "route 3" kit (no carrier key, no tombstone, no D2) |
| objectstack-ai#14691 | 11 | commit b3a63d3 | retires the ten inert
`RestServerConfig` keys |
| objectstack-ai#10724 | 11 | commit be21955 | retires the nine dead `contributes`
members |
| objectstack-ai#14369 | 10 | commit a3d5724 | the liveness census that recorded the
15 `dead` rows |
| objectstack-ai#10485 | 6 | commit 35ad101 | retires the `themes` carrier and
`ThemeSchema` |
| objectstack-ai#11846 | 5 | commit 0c2334f | retires preview mode; its own text
carries the ruling record (objectstack-ai#12428 kept) |
| objectstack-ai#14676 | 5 | commit 13c48c2 | retires `connector.errorMapping` |
| objectstack-ai#11332 | 3 | commit dce5cd4 | retires the manifest's three dead
containers |
| objectstack-ai#6361 | 2 | commit 90bbf25 | retires the notification-list `cursor`
on both halves |
| objectstack-ai#10627 | 2 | commit be21955 | that commit records the controlled
monorepo census |
| objectstack-ai#14365 | 2 | commit f60ab90 | the open `z.partialRecord` proposal
that commit's changeset recorded; the retirement leaves no record to
reshape |
| objectstack-ai#14526 | 2 | commit db16b94 | the landing of the client envelope
convergence (anchor block, and the README's measured case) |
| objectstack-ai#6363 | 1 | commit 17d0954 | "ruled jointly with the `unreadCount`
fix" |
| objectstack-ai#6239 | 1 | commit f549a0d | the ViewProtocol retirement sweep |
| objectstack-ai#10726 | 1 | commit bc56e18 | retires `contributes.routes` (Option
B) |
| objectstack-ai#10812 | 1 | commit be21955 | "the cloud census", whose 2026-08-24
reading @5b5925a that commit's message records |
| objectstack-ai#9041 | 1 | commit d491625 | the url-branch refinement (objectstack-ai#9147, live,
stays) |
| objectstack-ai#14996 | 1 | commit db16b94 | the ADR-0087 registration, which
landed in the same squash (its body: "Registration requested on objectstack-ai#14996")
|
| objectstack-ai#14312 | 1 | commit e944fdb | the `oauth.*` binding, which left
`applications.delete` out as a behaviour change (PR objectstack-ai#15445 kept) |

Two lines change without a number, so the sentence still reads:
`patterns`' follow-on line and the `oauth` anchor block's continuation
line. In total 133 lines are removed and 133 added in 86 files, and
every file is balanced.

## Verification record (final head `1ee5841c09`; base `fbec216e2d`)

**Census** (the gate's own `check-issue-citations.mjs --census --json`,
board enumerated, 186 pages):

| subtree | base (00:21Z, frontier objectstack-ai#20740) | head (01:18Z, frontier
objectstack-ai#20743) |
|---|---|---|
| `entries/retired-keys` | 73 | 0 |
| `entries/retired-defs` | 40 | 0 |
| `entries/semantic` | 4 | 0 |
| `registry.ts`, generated regions | 114 | 0 |
| `registry.ts`, hand-written parts | 2 | 2 |
| `chain.ts`, `types.ts`, `index.ts`, `spec-changes.ts`, tests | 0 | 0 |
| **`migrations/`** | **233** | **2** |
| `packages/spec/src` | 235 | 4 |

- **Site-set difference:** 254 sites are only at base. 231 are this
diff's (117 plus 114 copies). The other 23 are `plugin-audit`'s, from
`main`'s objectstack-ai#20737. 0 sites are only at head.
- **Kind** (every census site is a comment): a TypeScript 6.0.3 walker
classes all 131 entry sites as comments. It also finds 13 dead string
sites, all in `registry.ts`'s hand-written rationale (see Acceptance
notes).
- **Probe:** REST `issues/N` without following redirects, over all 302
distinct in-repo numbers of 100 or more in `migrations/`. 277 answer 200
and 25 answer 404. Controls were read at start, every 50 and end: lit
objectstack-ai#20234 200 (8/8), dead objectstack-ai#8710 404 (8/8).
- **Blind spots:** the census does not count 14 of the 131 entry sites.
- 13 are `re-charter objectstack-ai#13135`, because `NON_CITATION_HEADS` reads
`re-charter #N` as an ordinal.
- 1 is in `entries/README.md`, which is outside the gate's
`packages/**/src/**/*.ts` surface.
- **Numbers:** no tracker number is added. The only numbers on added
lines are the live ones kept from the same lines: objectstack-ai#8666, objectstack-ai#12057, objectstack-ai#8586,
objectstack-ai#12428, objectstack-ai#9147 and objectstack-ai#15445. The diff-scoped gate judges them: "every
citation this change adds resolves".

**Residue** (scratch walker over the TypeScript parser, per file, base
vs head, 86 `.ts` files):
- The file with every comment range cut out, everything else byte for
byte, is IDENTICAL.
- The leaf-token stream is IDENTICAL: 38,417 tokens, aggregate
`67c1f6db944e93ed`.
- **Controls** mutate the head text in memory only, 259 of 259 as
expected:
  - Expected identical: a comment insertion in every file.
- Expected to differ: a string-literal edit, a template-literal edit, a
regex-literal edit (where the file has one) and an appended declaration.

**Regeneration** (`gen:migration-registry`):
- `check:migration-registry` exits 1 before and 0 after.
- The registry diff is 128 lines out and 128 in. As (old, new) pairs
they equal the entry diff's pairs, re-indented by four spaces.
- 0 changed lines fall outside the generated regions.
- 5 entry pairs are deliberately not carried: the README line, and the
semantic "Anchors" header lines, which sit above a blank line and so are
not in the carried comment run.
- `check:spec-changes` and `check:upgrade-guide` exit 0 with no
regeneration.

**Build and tests** (under `os-verify-lock`):
- Build: `turbo run build` over `./packages/*` and `./packages/*/*`, 71
of 71, at `845e90fea4` and again at `1ee5841c09`.
- `check:generated`: all 15 artifacts up to date, at both heads.
- spec `local` project: 576 files, 16,991 passed and 1 todo, at both
heads.
- spec `repo` project: 41 of its 45 files, 666 passed, at both heads.
- spec `typecheck`: exit 0; 53 files / 251 errors / 138 pinned
signatures held.

**Gates:** `dispatch-gates --commands` derives 82 gates, and the
derivation is identical before and after the second merge. At
`1ee5841c09` all 82 exit 0. `--ran` reconciles: 82 derived, 82 run, 0
NOT-MEASURED, a derived zero.

**Lint** (a proven narrowing):
- `eslint --no-inline-config --format json` over the 86 touched `.ts`
files: 86 files, 0 errors, 0 warnings.
- `isPathIgnored` is false for all 86.
- `eslint.config.mjs:327-328` states that type-aware linting is never
enabled, so a comment edit cannot move an untouched file's verdict.

**Changeset:** `patch`.
- In the built `dist`, the new wording (for example "the precedent of
commit 4bfe1a5, PR objectstack-ai#8666") appears in `dist/migrations/index.js` and
`.mjs`, and the old wording appears in 0 files.
- Control: an unchanged phrase appears in the same 2 files.
- So the published `@objectstack/spec/migrations` entry carries these
comments.

**Merges:** `origin/main` was merged twice through
`scripts/pm/os-regen-merge.sh`. Neither merge left a regeneration to
commit.
- Since then, `main` has advanced to `97005aed04`, and none of those
commits touches `packages/spec`.
- Driver-free `merge-tree` probes (from a bare `--shared` scratch clone
with no `merge.*` config) exit 0 against that `main` and against PR
objectstack-ai#20706's head `d8bac3877d`.

## Acceptance notes

- **The next stage for this card: 13 dead sites left in `registry.ts`'s
hand-written parts.** They are outside this claim, which says
`registry.ts` is regenerated only.
  - **Comments (form C), 2:**
- `:5940` objectstack-ai#8495, the step-18 doc comment on the persistence placeholder
refusal.
    - `:22819` objectstack-ai#6239, the `RETIRED_DEFS_BY_MAJOR[17]` doc comment.
  - **Author-shown rationale strings (form D), 11:**
- Step 17's `:344` objectstack-ai#6345. It projects into
`docs/protocol-upgrade-guide.md:68`, so that stage regenerates the
guide.
- Step 18's fragments: `:5121` objectstack-ai#14676, `:5455` objectstack-ai#12868, `:5526` objectstack-ai#10329,
`:5544` objectstack-ai#8495, `:5555` objectstack-ai#13135, `:5742` objectstack-ai#10724, `:5745` objectstack-ai#10627, `:5752`
objectstack-ai#10726, `:5799` objectstack-ai#10485, `:5816` objectstack-ai#10926. These are not projected into
either artifact yet.
- **Census blind spots, measured here and not filed** (a coverage
boundary, not one of the three filing classes; new gates default to no):
- `NON_CITATION_HEADS` skips `re-charter #N` even where N is a tracker
card.
  - `.md` files under `packages/**/src` are outside the surface.
- **NOT MEASURED locally, left to CI:**
- `scripts/build-schemas-check-mode.test.ts` hit the foreground cap
alone (exit 124 after 560 s, no output). It parses `registry.ts`'s
source; the residue check above shows that source's code and string
tokens are unchanged.
- The other three heavy `repo`-project files (`def-key-collisions`,
`publish-smoke-boot-failure`, `publish-smoke-port-collision`) were not
run, as in stages 8 and 9.
- **Hypothesis 6's baseline half is falsified by construction.**
`scripts/doc-authoring-prose-id.baseline.json` has no `packages/spec`
row, because its leg excludes `packages/spec`. `check:doc-authoring`
reads strings only, so a comment-only diff cannot move it. It read 808
sites across 229 files at `845e90fea4` and 794 across 227 at
`1ee5841c09`. The difference is `main`'s own re-anchor commits; this
diff touches no row.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

---------

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.

2 participants