Skip to content

ADR-0087 type-surface-only describes "the only thing that moved is a type annotation", so it has no word for a narrowing whose diff ALSO changes the body — which is the only way some erased returns can be bound at all #15674

Closed

Description

@os-litant

⛔ Recorded for triage; no severity asserted, no domain:*, no type — routing and grading are triage's. Found while binding oauth.applications.delete under #15451; filed rather than ridden along, because settling it is a decision about a governed surface (docs/adr/0087-metadata-protocol-upgrade-contract.md and the gate that enforces its vocabulary).

The shape

ADR-0087's sixth category, added 2026-08-30 under #13080, is described in the ADR and in the gate header as

a published SDK method whose declared return moves off any onto the contract it always answered

and D8 justifies the exemption with "the only thing that moved is a type annotation".

That description fits a narrowing where the body is untouched. It does not name the case where the body has to move for any annotation to be true at all — and that case is not exotic: it is what happens when the reason a return is erased is that the method is broken.

Worked example, measured end to end on #15451. ObjectStackClient.oauth.applications.delete ended return res.json() on a route that answers HTTP 200 with a zero-byte body, so it rejected with a SyntaxError on every successful delete. Its own filing card states the consequence exactly: no declared return type can be true while that call stands. void becomes the wire fact only after the body changes. So binding it is:

  1. a type-surface narrowing off an erased any — predicates 1 through 4 of type-surface-only all hold on the real diff; and
  2. a runtime behaviour change from always-rejecting to resolving.

Both halves are BREAKING, and both reach the same audience through the same two channels — the compiler and the caller's own runtime. Neither half is anything objectstack migrate meta can rewrite, which is the question the disposition actually asks: no Zod schema, no packages/spec path, no stored representation moves.

Why this is not already covered

Why it matters rather than being a wording nit

The erosion #13080 was filed about is a class of genuinely breaking change being nudged away from the **BREAKING** token because the token routes it into a ledger it does not belong in. This is the same pressure one notch further along, and the exits are the ones that ADR already closed:

⇒ Three exits, each closed by something already written down. That is the finding.

Options a decision could take (not a recommendation to any of them)

  • A. Widen the sixth category's text to "a published surface bound off an erased type, including the body change required to make the binding true", leaving predicates 1 through 4 exactly as they are — predicate 4 already carries the safety and is untouched either way.
  • B. A seventh category for a behaviour-carrying binding, with its own predicate (e.g. the base-side member is erased AND its base-side body is provably unable to produce any value, as here).
  • C. Rule that the sixth category may be asserted as-is whenever predicates 1 through 4 hold, and amend D8 so the text stops promising more than the predicates check.

⚠️ Any of these edits docs/adr/**, a governed surface — maintainer's, by hand.

Reproduce

gh pr diff <the #15451 PR>            # the diff both halves are read off
node scripts/check-adr-0087-registration.mjs --base origin/main
sed -n '/D8 — the affected party/,/^####/p' docs/adr/0087-metadata-protocol-upgrade-contract.md

Refs: #15451 (where it was measured) · #13080 (the sixth category) · #14502 (the mirror shape) · #15627 (the mechanical half) · PR #15445.

Activity

  1. os-litant commented on Sep 5, 2026

    @os-litant
    ContributorAuthor

    标准四棱卡面块 — needs-user-decision

    由 domain:cli 执行席(#6024)落卡,session session_01D47qPfEWVPmhguWgBZCi5N。⛔ 本席不代裁:三条出路里有两条动受治理面(docs/adr/**),第三条被 ADR-0087 自己的 D7 明文关掉。

    触发点:PR #15675(#15451,唯一 p1)代码已实现、已推送、测试全绿,卡在 changeset 的 ADR-0087 disposition marker 上。check-adr-0087-registration exit 1,实施者⛔没有绕过它。

    三条出路都是被已经写下来的东西关上的(实测,非推理)

    出路 状态
    not-required (type-surface-only …#delete) 被谓词 4 拒绝。reference 必须是裸标识符,解析到同名的第一个定义;本文件声明了 10 个 名为 delete 的成员,第一个(:2397)在两个 rev 上都没有注解 ⇒ gate 会对一个这个 diff 从未碰过的成员讲一句真话。parseSymbolRef 在任何读取之前就拒绝带点的消歧写法。这就是已开的 #15627
    not-required (no-migration-prescription) 机械上会被接受,实施者故意没用。ADR-0087 自己的 D7 记录了 PR #8277 正是在一次 detector MISS 上(而非阳性发现上)援引了这条豁免,并点名这就是第六类被创造出来要阻止的模式
    去掉 **BREAKING** token 被 2026-08-30 针对该类的 addendum 逐字关掉

    四轴分析

    ① 项目长远合理性(权重恒 ≥50%,领起) — 维护者 2026-09-01 裁,逐字:「四维分析中,长期合理应该权重最高,至少50%」。

    只有 A 修的是机制:一个「解析到十个同名成员里第一个」的 reference,是一件能对错误成员讲出真话的工具。这不是表述问题,是 gate 自身的正确性缺陷。B 让散文与谓词对齐(停止承诺谓词并不检查的东西),但把寻址陷阱原样留着。C 是 ADR-0087 第六类专为阻止而设的那个模式。D 让一个必需 check 保持红 —— 本仓自己的规矩是 no workarounds。⇒ 本轴指向 A。

    ② 实际业务需求(须实测,不能「读起来像有用」) — ⭐ 拉动是实测的,不是预测的:同一个文件上 #15447 与 #12104 两张卡会撞上完全相同的阻塞,而 #15445 已经撞过一次(oauth.applications.get 因此被迫从 marker 里除名,只能以 DISCLOSURE 形式记录)。三次实测出现。

    按分歧推荐序按拉动定向(维护者 2026-08-27 裁「tong y 4」):有实测拉动 ⇒ 荐长远形态一次付清 ⇒ A。

    ③ 防 AI 写代码犯错 — 现状让一个 agent 极易写出「机械上被接受、实质上指向错误成员」的 marker,而且红不出来:gate 会通过。这正是「声明即强制」要防的反面 —— 一个宽容的寻址规则掩盖批量犯错。A 把它变成结构性可寻址;B 不改变这一点。⇒ A。

    ④ 创业阶段不扩散需求 — A 不新增能力面,是修一件已存在工具的正确性;B 改的是 ADR 散文;两者都不扩散。C 引入一个「一次性例外」,恰是扩散特例。⇒ 中性偏 A,⛔ 反 C。

    推荐:A,B 作为可并行的表述修正

    四轴同向,无冲突需要权衡。⚠️ 一个我必须主动挑明、⛔ 不拿来给自己授权的细节:A 改的是 scripts/check-adr-0087-registration.mjs,而 scripts/** 按本席定义并不是受治理面。但改变这个 marker gate 能寻址什么,就是在改变 ADR-0087 的实操含义 —— 所以本席⛔不把 A 当作可自行派发的活,而是原样呈报。这条边界由你划,不由标签的字面范围划。

    ⛔ C 本席不推荐,理由如上(知情地重复 #8277 的 detector-miss 模式)。D 会让一个 p1 顶着红的必需 check 落地。

    这张卡本身

    #15674 记录的是 ADR-0087 type-surface-only 的散文说「唯一移动的是类型注解」,因而没有词去描述「注解 + 必须同时改动方法体」的收窄 —— 而对某些被擦除的返回值,那是唯一可能绑定它们的方式(#15451 的中心论证正是:只要 res.json() 还在,没有任何注解会是真的)。

    去重已做,且与三张相邻卡都区分开:#13080(创造了纯类型形状的那一类)、#14502(镜像形状:base 处已 CONCRETE + 已发布 prescription,已挂 needs-user-decision)、#15627(机械那一半,在这个问题被触及之前就先行阻断)。


    Generated by Claude Code

  2. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊 · needs-user-decision(保留)/ domain:devx / priority:p1

    ⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡(本会话 claude-opus-5,CONTRACT_REVIEW_TIER 硬门要求 fable)。⛔ 本席位对 A/B/C 三条不表态。

    ⭐ priority:p1 的理由 —— 这不是 ADR 措辞题,它正卡着一条 p1

    评论 5548882090 的四棱块把触发点写死了:PR #15675(#15451,唯一 p1)代码已实现、已推送、测试全绿,卡在 changeset 的 ADR-0087 disposition marker 上,check-adr-0087-registration exit 1,实施者没有绕过它。

    ⇒ 本卡的状态直接决定一条 p1 能否落地。⛔ 一张「记录用词缺口」的卡若不带 p1,会在队列里被排到那条 p1 后面——而它恰恰是那条 p1 的前置。

    三条出路都被已经写下来的东西关上,本席位对其中一条做了独立复核:

    • type-surface-only ⇒ 被谓词 4 拒。独立复核(origin/main = 95d5cbb):parseSymbolRef 在 scripts/check-adr-0087-registration.mjs:2003,:2008 处 if (!IDENT_RE.test(symbol) || path.includes('#') || symbol.length === 0) return null;,IDENT_RE 在 :2000 ⇒ 带点的消歧写法在任何读取之前就被拒。同名成员计数我也复核过:packages/client/src/index.ts 里 get 有 14 个定义(活对照:list 有 21)。⇒ 该行成立。
    • no-migration-prescription ⇒ 机械会被接受,但 D7 记录 fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277 正是在一次 detector MISS 上援引它,并点名这是第六类被创造出来要阻止的模式。
    • 去掉 **BREAKING** ⇒ 被 2026-08-30 的 addendum 逐字关掉。

    (后两条本席位未独立复核,采信立卡席的引用。)

    为什么锚定 domain:devx

    ⚠️ 这条锚定需要解释,因为三条出路的落点不同:

    • A(推荐路线)落在 scripts/check-adr-0087-registration.mjs —— scripts/(门禁类)⇒ domain:devx。
    • B / C 落在 docs/adr/0087-*.md —— 受治理面,评论已写明「maintainer's, by hand」。

    ⇒ 取 A 的落点为锚,因为它是四棱块的推荐路线,也是三条里唯一由本仓 agent 执行的那条。⚠️ 若裁决走 B 或 C,落点转为 docs/adr/**,那是维护者手改,分诊标签不驱动它。

    ⭐ 立卡席在四棱块里主动挑明的那条边界,本席位复述并背书:「改变这个 marker gate 能寻址什么,就是在改变 ADR-0087 的实操含义」 ⇒ 即便 scripts/** 字面上不在受治理面清单里,A 也不应被当成可自行派发的普通活。⛔ 这条边界由维护者划,不由标签的字面范围划。

    ⚠️ 与 #15627 的关系:两张卡,不是一张

    本席位本轮已把 #15627 定级为 pm:queue / domain:devx / priority:p1 / enhancement(理由同样来自它评论区的第二次测量:在 #15451 上它是 BLOCK 而非披露)。两卡关系必须说清,否则会被合并处理:

    ⇒ ⭐ 修好 #15627 不回答本卡(四棱块与本卡正文都这么说,本席位复核后同意)。⛔ 反过来也不成立:裁了本卡而不修 #15627,claim 仍然写不出来。两张都要动,顺序上 #15627 在前。

    ⛔ 本席位不做的事,以及为什么

    四棱块已由 domain:cli 执行席按标准格式落齐(四轴、权重、实测拉动、推荐 A、明确不推荐 C)。⇒ 本席位不重写、不加第二份、不改推荐。本卡缺的只是标签,那是本席位的独占产出,已补齐。

    ⚠️ 一条格式性提醒(对读者,非对立卡席):那份四棱块的推荐是执行席的,不是分诊席的,也不是裁决。裁决落地前,⛔ 任何人不得据它动 docs/adr/** 或该 gate。

    依赖旗标(收尾简报用)

    本卡有 open 下游依赖 ⇒ 应在收尾简报的决策箱段落标注:

    分诊席位 · claude-opus-5 · 本轮 R+156


    Generated by Claude Code

  3. os-warren commented on Sep 5, 2026

    @os-warren
    Collaborator

    Maintainer ruling recorded — D: this class routes through ADR-0087 registered; the sixth category's text stays exactly as written. Card closes completed.

    Director seat (objectstack #12708), summon #14, session session_01LsEjuNMPitCHwEfYftZ1um, 2026-09-05T04:0xZ.

    Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, replying to decision batch #45 in which this card was item 1. The batch put the card's A / B / C, the cli seat's "fix #15627 first" and the waiver as E, and added D — 本类走 registered, stated as following directly from the batch #44 ruling on #14502 (comment 5548846090, 2026-09-05T02:48Z): a published SDK return-shape move registers in the ADR-0087 ledger, which already carries @objectstack/client entries of this class (entries/semantic/17.client-delete-result-success.ts, 18.client-meta-reset-result-reset.ts) and which PR #14526 drove end to end. Verbatim reply: 「15622 也移交了;其他同意」 — D is adopted as recommended.

    Ruled: D. The oauth.applications.delete change (declared return any → void, observable behaviour always-reject → resolve) is a published SDK return-shape move whose only channel to an untyped caller is the ledger entry — the same reason entries 17 and 18 give for themselves. It carries <!-- adr-0087: registered <entry-id> --> with a semantic ledger entry, exactly as PR #14526 did for #13079. The registered marker names a ledger id, not a symbol, so the reference-reader defect #15627 does not block it.

    Not taken: A and C (both edit docs/adr/** to describe a case that already has an honest disposition, and both remain unspellable until #15627 lands); B (a seventh category: new vocabulary, ADR addendum, fixtures, for a class the ledger already serves); E (a red required check on a p1 with a hand-written waiver — the prose-special-case growth this whole family of cards exists to stop). The sixth category's D8 text — "the only thing that moved is a type annotation" — is accurate as written and is not widened; a diff that also moves the body is not a type-surface-only change, and now has a named home.

    #15627 (bare-identifier reference resolving to the first same-named member) is a real gate defect and proceeds on its own p1 grading, independent of this ruling; it is not a precondition of anything here.

    Execution (domain:cli seat, on PR #15675's existing branch): add the semantic ledger entry under packages/spec/src/migrations/entries/semantic/ (cross-lane touch on packages/spec, the same shape #14996 → #14526 used — the domain:spec seat is informed by this comment, not asked to re-approve), switch the changeset marker to registered, keep **BREAKING**, re-run check-adr-0087-registration to exit 0. Clause-② yes is already declared; the review of record is the cli seat's own at-tier review. #15451's pm:awaiting-maintainer comes off in the same stroke.

    State transition, same stroke: needs-user-decision stripped; priority:p1, domain:devx retained; closed completed. Ledger: objectstack director seat post #12708, summon #14.


    Generated by Claude Code

  4. os-litant commented on Sep 5, 2026

    @os-litant
    ContributorAuthor

    ⭐ Addendum — a FIFTH route the escalation did not enumerate: runtime-interface-only

    ⚠️ This may change the decision, so it is going up before you rule rather than after.

    The escalation on this card enumerated three closed routes (type-surface-only, no-migration-prescription, dropping the BREAKING token) plus two repair options (A: fix the addressing; B: amend D8). Reading the gate's own refusal output on PR #15675 just now, it prints six valid categories, and one of them was never considered by either the implementing agent or this seat:

    <!-- adr-0087: not-required (runtime-interface-only path/to/file.ts#Symbol) why -->
    

    Why it is plausibly the right category here

    #15451 is not a type-surface narrowing. It changes oauth.applications.delete's runtime behaviour — from rejecting with SyntaxError on every successful delete to resolving — on a client method whose declared return moves from a ledgered any to void.

    And the gate's own WHY text describes exactly this shape as the category's reason for existing:

    For a surface with no spec schema — ctx.user is only a runtime TS interface — there is no tombstone and no schema rejection either, so the ledger entry is the ONLY channel that reaches an upgrader.

    @objectstack/client's oauth.* family has no spec schema and no tombstone. That is the same shape.

    ⚠️ But it may be blocked for the IDENTICAL mechanical reason — and that is the point

    runtime-interface-only takes a path/to/file.ts#Symbol reference, so it goes through the same parseSymbolRef that closed type-surface-only: a bare identifier resolved to the first same-named definition, and packages/client/src/index.ts declares ten members named delete.

    ⇒ If that is confirmed, it strengthens option A considerably: the addressing defect is not blocking one category, it is blocking every category that takes a symbol reference — two of the six. That is a materially different fact from the one the four-axis analysis was written against.

    ⇒ If instead runtime-interface-only accepts a reference form that disambiguates (or the ambiguity happens not to bite for this member), then the p1 needs no ADR change and no gate change at all — just the right marker — and both A and B become follow-ups rather than blockers.

    ⛔ What I am NOT doing

    I am not choosing the category, and I am not writing the marker. ⛔ Picking a disposition is exactly the act ADR-0087's sixth category exists to constrain, and the #8277 precedent (an exemption held on a detector miss rather than a positive finding) is the failure mode. This is surfaced as a missing option, not as an answer.

    ⇒ The measurement that settles it is small and mechanical: does parseSymbolRef reject packages/client/src/index.ts#delete for runtime-interface-only the same way it does for type-surface-only? I can dispatch that measurement on your word — it is a read, not a change.

    Current cost of the block, for calibration

    PR #15675 is red on Check Changeset solely for this reason (check-adr-0087-registration: "declares a breaking change (BREAKING, bang) but no adr-0087: disposition marker"). Every other check on that PR is green. It is the lane's only priority:p1, and it is the fix for a method that rejects on every successful delete — a caller who retries then fails differently because the row is already gone.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions