|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +// Anchors for this entry, kept in source rather than in the strings below: the |
| 4 | +// entry's prose is projected into `packages/spec/spec-changes.json` and |
| 5 | +// `docs/protocol-upgrade-guide.md`, which are read by consumers who cannot |
| 6 | +// resolve this repo's internal issue numbers. |
| 7 | +// |
| 8 | +// card objectstack-ai/objectstack#15451 |
| 9 | +// landing PR objectstack-ai/objectstack#15675 |
| 10 | +// disposition objectstack-ai/objectstack#15674 (ruled D, 2026-09-05: this |
| 11 | +// class routes through ADR-0087 `registered`) |
| 12 | +// precedents objectstack-ai/objectstack#13023, #13079 (the three sibling |
| 13 | +// entries this is shaped on: `client-delete-result-success`, |
| 14 | +// `client-meta-reset-result-reset`, |
| 15 | +// `client-envelope-convergence-analytics-automation`) |
| 16 | +// family objectstack-ai/objectstack#14312 (the `oauth.*` binding card |
| 17 | +// whose ruling fenced this method out, PR #15445) |
| 18 | +// pins `packages/client/src/oauth-applications-delete.test.ts`, |
| 19 | +// `packages/client/src/return-type-precision.test.ts` |
| 20 | +// vendor `StrictEndpoint<'/oauth2/delete-client', ..., void>` in |
| 21 | +// `@better-auth/oauth-provider` |
| 22 | + |
| 23 | +import type { SemanticMigration } from '../../types.js'; |
| 24 | + |
| 25 | +export const entry: SemanticMigration = { |
| 26 | + id: 'client-oauth-applications-delete-void', |
| 27 | + surface: |
| 28 | + 'client.oauth.applications.delete(clientId) — both halves of what a caller of this ' |
| 29 | + + 'published `@objectstack/client` method observes: the DECLARED return, ' |
| 30 | + + '`Promise<any>` before and `Promise<void>` after, and the SETTLE BEHAVIOUR, which ' |
| 31 | + + 'rejected with `SyntaxError: Unexpected end of JSON input` on every successful ' |
| 32 | + + 'delete before and resolves after', |
| 33 | + replacement: |
| 34 | + 'no value — `void`. There is nothing to move a read TO, because the promise never ' |
| 35 | + + 'resolved for a caller to read anything off it. The migration is on the settle ' |
| 36 | + + 'path instead: `try { await client.oauth.applications.delete(id); } catch { ' |
| 37 | + + '/* it probably worked */ }` → drop the workaround, the `catch` was executing on ' |
| 38 | + + 'EVERY successful delete and now executes only on a real failure. A read off the ' |
| 39 | + + 'resolved value — `(await client.oauth.applications.delete(id)).deleted` — was ' |
| 40 | + + 'unreachable code that has never executed and now stops compiling (TS2339). Same ' |
| 41 | + + 'call, same request, same wire body', |
| 42 | + reason: |
| 43 | + 'The route answers HTTP 200 with a ZERO-BYTE body: `POST {auth}/oauth2/delete-client` ' |
| 44 | + + 'returns nothing from its handler, the vendor declares the endpoint `void`, and the ' |
| 45 | + + 'response carries `content-type: application/json` with NO `content-length` header ' |
| 46 | + + 'at all. The method ended `return res.json()`, so it rejected `SyntaxError: ' |
| 47 | + + 'Unexpected end of JSON input` on every successful delete — after the row had ' |
| 48 | + + 'already been removed server-side. There was no success path a caller could ' |
| 49 | + + 'observe, and the obvious recovery made it worse: the retry failed DIFFERENTLY, ' |
| 50 | + + 'with the route\'s 404 `not_found`, because the client was already gone. The method ' |
| 51 | + + 'now reads the body as text, returns on the empty case, and still parses (and still ' |
| 52 | + + 'throws on) a non-empty one — so the ONLY behaviour that moved is the zero-byte ' |
| 53 | + + 'case, which is the defect itself. THE WIRE IS BYTE-IDENTICAL: same route, same ' |
| 54 | + + 'request body, same status codes, same ADR-0112 error envelope; no Zod schema and ' |
| 55 | + + 'no `packages/spec` declaration moves, no authorable key and no stored ' |
| 56 | + + 'representation is involved, so a raw-HTTP caller is unaffected and ' |
| 57 | + + '`objectstack migrate meta` has nothing to rewrite. This is registered rather than ' |
| 58 | + + 'exempted because the change is NOT compiler-delivered where it matters, and the ' |
| 59 | + + 'gap is exact rather than theoretical. The change has two halves and only one of ' |
| 60 | + + 'them has a diagnostic. (1) The declared return moves from a ledgered `any` to ' |
| 61 | + + '`void`, so a typed caller that read a property off the resolved value now gets ' |
| 62 | + + '`error TS2339` — but that read was UNREACHABLE, since the promise never resolved, ' |
| 63 | + + 'so the compiler names only code that has never run. (2) The half that DID run on ' |
| 64 | + + 'every call — a `try`/`catch` wrapped around the delete — compiles identically ' |
| 65 | + + 'before and after, with no diagnostic anywhere, while its `catch` block stops ' |
| 66 | + + 'executing. So for the only behaviour that was ever observable, `tsc` names ZERO ' |
| 67 | + + 'sites; and for an untyped JS caller there is no constrained channel at all. That ' |
| 68 | + + 'is why the ledger entry is the only notification that reaches an upgrader — the ' |
| 69 | + + 'same argument the three sibling entries on this package make ' |
| 70 | + + '(`client-delete-result-success`, `client-meta-reset-result-reset`, ' |
| 71 | + + '`client-envelope-convergence-analytics-automation`). ⚠️ Note the DIRECTION, which ' |
| 72 | + + 'is the inverse of the usual break: this does not stop working code from working, ' |
| 73 | + + 'it makes a method that could never succeed succeed. The hazard is therefore ' |
| 74 | + + 'inverted too — code written to survive a permanent failure is now inert, and any ' |
| 75 | + + 'alerting or error budget fed by this method\'s rejections goes quiet. ⛔ Do not ' |
| 76 | + + 'keep the old behaviour behind a flag or a wrapper that re-throws: there is one ' |
| 77 | + + 'producer shape, and the rejection was never a contract, it was a parse of an empty ' |
| 78 | + + 'string. ⛔ Do not synthesise `{ deleted: true }` either: the 200 carries zero bytes ' |
| 79 | + + 'and therefore zero information, and "it was already gone" is distinguished on the ' |
| 80 | + + 'ERROR channel — a client that is not there answers 404 `{ error: \'not_found\' }`, ' |
| 81 | + + 'which `ObjectStackClient.fetch` raises as a throw before the body reader runs — so ' |
| 82 | + + 'a synthesised success value would be a shape the wire never sends and strictly ' |
| 83 | + + 'less informative than the 404 the caller already receives. ADR-0087 D3.', |
| 84 | + acceptanceCriteria: |
| 85 | + '⚠️ The real work is behavioural and NOTHING will report it: every `try`/`catch` ' |
| 86 | + + 'wrapped around `client.oauth.applications.delete()` has to be re-read one by one, ' |
| 87 | + + 'because it compiles identically before and after while its `catch` block goes from ' |
| 88 | + + 'running on every successful delete to running only on a real failure. Anything ' |
| 89 | + + 'that block did — treating the delete as failed, retrying it (the retry answered ' |
| 90 | + + '404 `not_found`, which may itself have been swallowed), skipping post-delete ' |
| 91 | + + 'cleanup, cache invalidation, audit writes or a UI refresh, or reporting the delete ' |
| 92 | + + 'to a user as failed — is now on the other branch, and the cleanup paths that were ' |
| 93 | + + 'skipped run for the first time. Verify that is what you want rather than assuming ' |
| 94 | + + 'it restores prior behaviour. Alerting, error budgets and dashboards fed by ' |
| 95 | + + '`SyntaxError` rejections from this method drop to zero: that is the fix landing, ' |
| 96 | + + 'not an outage. Any test that passed while asserting this call rejects on a ' |
| 97 | + + 'successful delete was asserting on the defect and needs rewriting, not renaming. ' |
| 98 | + + 'On the type side, no code reads a property off the resolved value; `tsc` names ' |
| 99 | + + 'those sites for a typed caller (TS2339), but every one of them was unreachable, so ' |
| 100 | + + 'a clean type-check is NOT evidence that the sweep above was done. An untyped JS ' |
| 101 | + + 'caller gets no report at all. Nothing about the request, the route, the status ' |
| 102 | + + 'codes or the thrown error shapes changes and no server needs upgrading — the ' |
| 103 | + + 'server has always answered this way; only the client stopped mis-reading it. ' |
| 104 | + + 'Populations, measured at this landing: in the ObjectStack repo, ZERO production ' |
| 105 | + + 'call sites — the only references are the pins that ship with this change ' |
| 106 | + + '(`oauth-applications-delete.test.ts`, `return-type-precision.test.ts`); in ' |
| 107 | + + 'objectui at the pinned `.objectui-sha`, ZERO — neither `oauth.applications` nor ' |
| 108 | + + '`delete-client` appears anywhere in that tree; `objectstack-ai/cloud` is NOT ' |
| 109 | + + 'MEASURED, and a `catch` there that swallowed this method\'s rejection is now dead ' |
| 110 | + + 'code that this entry is the only notice of.', |
| 111 | +}; |
0 commit comments