fix(spec,rest): 三条路由不再应答各自 responseSchema 从未声明的形状 (#5882 #5950 #6442) - #6596
Merged
Conversation
… never declared (#5882 #5950 #6442) Sweep #6487 — one admission criterion: a route serves a response shape that its declared `responseSchema` does not describe. Three members, one direction each, each stated with its reason rather than picked for cheapness. ## #5950 — `GET /meta/:type/:name` declares the ADR-0010 protection envelope The uncached branch has always sent the protection envelope on top of `{ type, name, item }` — `translateMetaEnvelope` does `{ ...envelope, item }`, so `metadata-protocol`'s `getMetaItem` return reaches the wire verbatim — while `GetMetaItemResponseSchema` declared three keys. `.parse()` therefore STRIPPED every carrier, and `GetMetaItemResponse` could not even name them, so reading `lock` meant a cast: the consumer-side tolerance Prime Directive #12 rejects. `lock` is the read half of the ADR-0008 optimistic-concurrency chain whose write half #5745 declared on `SaveMetaItemResponseSchema`; this is that same gap, read side. Measured against `origin/main` rather than taken from the issue: the envelope is TEN keys, not the three the issue named (it quoted the return statement up to `lockSource` and stopped). Declaring only three would have left seven still undeclared — the member's own criterion unmet. The full set is `lock`, `lockReason`, `lockSource`, `lockDocsUrl`, `provenance`, `packageId`, `packageVersion`, `editable`, `deletable`, `resettable`. All ten are OPTIONAL, and that is measured too. The route reaches a body by two branches that publish different amounts: the cached branch — THE DEFAULT, since `enableCache` defaults to `true` — rebuilds the envelope as `{ type, name, item }` and deliberately never consults the lock resolver, while the uncached branch always sets `lock` / `editable` / `deletable` / `resettable` and sets the other six only when the resolved document carries the matching `_`-prefixed field. So `optional` means "this branch did not publish it", never "unlocked". Declaring them required would make the default deployment's own response fail its own contract — the #5563 defect in mirror image. Zero runtime change. Whether lock presence should depend on a server-side cache setting at all is the larger question #5950 raises explicitly, and it is NOT decided here. ## #5882 — the layered projection gets its own path `?layers=true` made one route answer two unrelated resource representations: the ordinary envelope, and a diagnostic projection showing the packaged baseline, the tenant overlay and the merged result side by side (Studio's "code default vs override vs effective" tabs). The route declared one `responseSchema`, so any client generated from the route table parsed the flagged call wrongly. Per the maintainer's 2026-08-06 ruling: the projection becomes `GET /meta/:type/:name/layers`, declared by a new `GetMetaItemLayeredResponseSchema`. One path, one shape. The rejected alternative was teaching the route declaration to express "two shapes chosen by a flag" — a new primitive every future tool must understand, and conditional response selection is exactly where codegen and AI-written clients go wrong. The declared shape is measured from the producer, and it corrects two claims that were wrong in both the issue body and the in-code comment: the projection carries `_diagnostics`, not `validation`, and it carries the same ADR-0010 protection envelope as the ordinary read, so it is eighteen keys rather than seven. On this path there is exactly ONE producer (the layered view skips the cache), so `lock` / `editable` / `deletable` / `resettable` are guaranteed and declared REQUIRED — the asymmetry with the ordinary read is real and stated. `?layers=` stays for a deprecation window and answers the IDENTICAL body: both entry points call one extracted helper, so the window's promise cannot quietly stop being true. It now carries `Deprecation: true` (RFC 9745) and a `Link` header naming the successor path (RFC 8288) — the same machine-readable pairing `versioning.zod.ts` already describes for retiring API versions. No `Sunset` date: choosing the hard cut-off is a maintainer call, and an invented date is worse than none. The route is registered before `/:type/:section/:name`, which would otherwise capture the path as section=<name>, name="layers" under a first-match router; the ordering is pinned by a test rather than left to reading order. ## #6442 — `GET /analytics/meta` narrows to the shape it serves `AnalyticsMetadataResponseSchema.data` declared `{ cubes: CubeSchema[] }` while `AnalyticsService.getMeta` and its `driver-memory` twin both return a bare `CubeMeta[]` that `runtime/src/domains/analytics.ts` hands to `success()` verbatim. A client written against the published contract read `data.cubes` and got `undefined`; validating a live response against the schema failed outright. `packages/spec` stated both shapes itself — the TS contract in `contracts/analytics-service.ts` already agreed with the runtime, and this schema was the lone outlier. Per the maintainer's 2026-08-08 ruling: narrow the declaration. Zero runtime change. The projection is declared once and bound to the `CubeMeta` interface by an exported compile-time assertion, so the two statements of one shape can no longer drift — which was the root cause. The generated `references/api/analytics.mdx` corrects itself; the hand-written `data-api.mdx:393-395` already described the array form and is untouched. No new `CubeMeta` type alias: `contracts/analytics-service.ts` already owns that name, and a second name for one type is both the ADR-0122 D3 permanent synonym and a new dual-source export. ## Verification Reverse-verified per member, direction predicted before running: dropping the protection mixin turns the uncached assertions red (`lock` reads `undefined` — Zod strips what is undeclared) and the type-level pin red at `tsc`; renaming the new route turns seven layered end-to-end tests red; restoring the wide analytics declaration turns the narrowed parse assertions red. The type-level pins are exported at module scope deliberately: an unread alias in a test body is TS6196, and `packages/spec` compiles its tests through `tsconfig.test.json`, so these go red at `pnpm typecheck` rather than being phantom checks. Fixture triage per case rather than a batch re-spell: the analytics suite's "should reject missing cubes" would have kept passing against the new schema for the WRONG reason (`{}` is not an array either), pinning a `cubes` key that no longer exists, so it is replaced by the assertion that carries the change's actual load — the previously-declared wrapper is now rejected.
…eclared-response-sweep
…edger the new route Three corrections found by running the gates the sweep touches, none of them a change of direction. ## The analytics projection is inlined rather than separately exported `CubeMetaSchema` / `CubeMetaMemberSchema` were exported named schemas. `gen:docs` rejects a documented schema with no type alias, and the alias those two want is `CubeMeta` — a name `contracts/analytics-service.ts` already owns. Taking it would be both the ADR-0122 D3 permanent synonym and a new dual-source export, and the gate's own prescription for a dual-source finding is "import the existing one, or pick a different name". So the projection is declared inline in `AnalyticsMetadataResponseSchema`, which also matches the style of its neighbour `AnalyticsSqlResponseSchema`. Nothing is lost: `CubeMeta` remains THE name for the shape, and `analytics.test.ts` binds the declaration to it with an exported compile-time assertion, so the two cannot drift. ## The new route needed a reviewed ledger disposition `rest-route-ledger.conformance.test.ts` fails on a mounted route with no entry — the #3587 guard against a working route the SDK cannot call. Added with `disposition: 'server-only'`, deliberately NOT `gap`: the gap count is a ratchet pinned at zero and a new `gap` row is defined to require its own reviewed decision, which this PR does not carry. `server-only` is also accurate on its own terms — `@objectstack/client` expressed no layered read under the `?layers=` spelling either, and Studio consumes the view straight over HTTP — so the row records the status quo under a new path rather than opening or closing a gap. Whether the SDK should express it is a separate product call. Both metadata-item rows now carry a `responseSchema`, which the field's rule permits only where the mount has conformance coverage of its own. That coverage is added here rather than asserted: `meta-item-layered-route.test.ts` parses the body this mount really answers against `GetMetaItemResponseSchema` on BOTH branches — uncached, carrying the newly-declared ADR-0010 envelope, and cached, where every protection key is absent — which is the end-to-end half of #5950 and the reason the envelope is optional rather than required. ## Generated artifacts regenerated wholesale from the merged tree `content/docs/references/api/analytics.mdx` now publishes the array shape instead of the `{ cubes: [...] }` wrapper with `sql` in it, and `protocol.mdx` gains the layered response. `authorable-surface.base.json` is deliberately NOT re-anchored: it trails `1a53a0253356` by keys belonging to unrelated retirements, `check:authorable-surface` is green with it trailing, and the generator itself says re-anchoring is a deliberate act with its own reviewed diff — never a side effect of another PR's build (#5358).
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…eclared-response-sweep # Conflicts: # packages/spec/src/type-alias-convention.pin.test.ts
Recovery commit: the dev agent was killed by a container restart after committing the merge of origin/main and before the regen chain finished; this completes the four-step (docs references, api-surface, ledger counts). check:generated 10/10.
Semantic merge, not just textual: main landed ADR-0106 metadata-face FLS (#3682) whose D5 covers the layered exit, while this branch extracted that exit into serveMetaItemLayered() behind two entry points. Resolution keeps both methods and threads the resolved maskPosture through BOTH entry points (the canonical /layers route resolves its own posture with the normalized type, #3984/#6241) so the extraction cannot become a mask bypass. Pin test: receipts stacked in merge order, layered pin renumbered Iso833 (main's #4593 batch owns 760..832), count recomputed from the file = 823. Generated files regenerated in the follow-up commit.
… ADR-0106 + #4593 backfill merge)
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 114 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
qq9340100
marked this pull request as ready for review
August 8, 2026 09:30
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5882
Fixes #5950
Fixes #6442
Fixes #6487
Sweep 卡 #6487:三条路由应答了各自
responseSchema从未声明的形状。一分支、一 PR、三条成员,方向逐条给出理由,不挑便宜的做法。复核清单(一行一成员)
GET /meta/:type/:name,非缓存分支{ type, name, item }lock/lockReason/lockSource/lockDocsUrl/provenance/packageId/packageVersion/editable/deletable/resettablelock是 ADR-0008 乐观并发链的读侧载体,写侧已由 #5745 声明;读侧不声明意味着 SDK 调用方只能 cast——正是 PD #12 反对的消费端宽容。零运行时改动GET /meta/:type/:name?layers=trueGetMetaItemResponseSchemacode/overlay/effective等 18 键,含同一套保护信封)GET /meta/:type/:name/layers+ 新增GetMetaItemLayeredResponseSchema(维护者 2026-08-06 裁决 B)。一条路径一个形状;⛔ 不给路由声明加「按 query 分岔两形状」的表达能力。?layers=留弃用窗口GET /analytics/metadata: { cubes: CubeSchema[] }data: CubeMeta[](裸数组,窄投影)CubeMeta[]投影(维护者 2026-08-08 裁决)。零运行时改动;⛔ 不放宽端点到完整 cube 定义(会把sql推给客户端)前提复核:两处与 issue 正文不符,按实测走
Issue 正文是线索不是规格,两条成员的实际形状都比正文宽,已按
origin/main实测声明:GET /meta/:type/:name非缓存分支发lock/lockReason/lockSource,GetMetaItemResponseSchema只声明{type,name,item}#5950 是 10 键,不是 3 键。 正文引用getMetaItem的 return 到lockSource为止就停了,其后还有lockDocsUrl/provenance/packageId/packageVersion/editable/deletable/resettable。只声明 3 键会留下 7 键仍未声明——本成员自己的验收标准都不满足。GET /meta/:type/:name?layers=true应答一个 spec 未声明的第三形状 —— 路由只声明了一个responseSchema#5882 的层投影带的是_diagnostics,不是validation。 issue 正文与rest-server.ts里那段注释都写作validation,实际生产者发的是_diagnostics(MetadataValidationResult形状);另外它同样携带保护信封,所以是 18 键而非 7 键。若照正文声明validation,等于把一个不存在的键写进契约。为什么保护信封全 optional(实测,非假设)
这条路由有两条分支且发的量不同:
enableCache默认true):REST 层把信封重建为{ type, name, item },刻意不解析锁——它是已发布值的快路径。10 键全部缺席。lock/editable/deletable/resettable恒有;其余 6 键仅当解析出的文档带对应_前缀字段时出现。所以 optional 的含义是「这条分支没发」,绝不是「没锁」。声明成 required 会让默认部署的应答通不过它自己的契约——正是 #5563 缺陷的镜像。
层投影那条路径只有一个生产者(层视图刻意跳过缓存),所以那四个判定键在新 schema 里是 required——这处不对称是实测出来的,写进了 describe。
弃用窗口
?layers=仍答完全相同的 body:两个入口调用同一个抽出来的 helper,不是两份拷贝,所以「窗口期形状不变」这句承诺不会悄悄失效(有测试逐字段比对两个入口的 body)。同时补上Deprecation: true(RFC 9745)与指向后继路径的Link(RFC 8288)——versioning.zod.ts已有的同一套机器可读约定。不设Sunset日期:硬截止是维护者的决定,编一个日期比不写更糟。新路由注册在
/:type/:section/:name之前——否则后者会以 section=名字、name="layers" 抢走这条路径;顺序本身由测试钉住,不靠阅读顺序保证。逆向验证(方向先判后跑,逐成员)
× parses the uncached body WITHOUT stripping the protection envelope—expected undefined to be 'no-overlay'(Zod 把未声明的键剥掉了);× rejects a lock value outside the ADR-0010 vocabulary。另tsc报TS2339: Property 'lock' does not existError: route not registered: GET /api/v1/meta/:type/:name/layersaccepts the body the endpoint actually serves/accepts an empty cube list/declares exactly the CubeMeta contract shape类型层断言导出在模块作用域,不是写在
it()体内:函数体里没人读的 alias 是 TS6196,而且packages/spec通过tsconfig.test.json编译测试,所以它们在pnpm typecheck会真红——不是幻影检查。上面 #5950 那行tsc读数就是这一点的证据。fixture 逐条分诊,不整批改写
analytics.test.ts的三条旧用例逐条重判:第一条钉的是没有任何实现产生过的 body(整条替换为真实 body);第二条只需去掉 wrapper(改写);第三条should reject missing cubes换成新 schema 后会因为错误的理由继续通过({}同样不是数组),钉着一个已经不存在的cubes键——所以替换为真正承载本次改动的断言:旧声明的 wrapper 现在被拒绝。消费半径
按规则的消费者枚举而非按改动包扫描:
DEFAULT_METADATA_ROUTES/getDefaultRouteRegistrations的消费者只有 spec 与 rest(均已全绿);三个 schema 在 runtime / client / client-react 里只有注释引用,无解析点。新路由另需rest-route-ledger的评审性 disposition(#3587 闸门),取server-only而非gap——gap 计数是钉在 0 的棘轮,新增 gap 按定义需要单独评审;且 SDK 在?layers=时代同样没有层读取,这一行记录的是现状而非新开的缺口。两条 metadata 行现在都带responseSchema,该字段规则要求「无 conformance 覆盖不得填」,覆盖是本 PR 补的:端到端用真RestServer对两条分支的 body 做 parse。生成物
已按四步序在合并最新
origin/main后整体重生成(合并先提交,⛔ 未在 MERGE 状态跑 gen:schema)。references/api/analytics.mdx不再发布带sql的{ cubes: [...] }错形状;protocol.mdx增加层响应。手写的data-api.mdx:393-395本来就描述正确,未动。authorable-surface.base.json刻意未重锚:它落后的键属于无关的退役 sweep,check:authorable-surface在落后状态下是绿的,且生成器自己写明重锚是「有独立评审 diff 的刻意行为,绝非另一个 PR 构建的副作用」(#5358)。验证
check:generated10/10 绿pnpm --filter @objectstack/spec test— 343 文件 / 8772 用例全绿;typecheck绿(含check:test-typecheck)pnpm --filter @objectstack/rest test— 67 文件 / 951 用例全绿pnpm --filter @objectstack/client test— 21 / 263 绿(route-ledger 客户端半边)pnpm --filter @objectstack/runtime test— 111 / 1611 绿pnpm lint干净;check:route-envelope/check:error-code-casing/check:meta-type-normalized/check:nul-bytes/check:spec-parsed-alias(1443 bare / 749 pinned / 694 paired)/check:adr-anchors/check:dual-source-exports全绿GetMetaItemResponseSchema加了 10 个 optional 键仍保持同构(枚举/布尔/字符串无 default),所以沿用原有的 Iso134git diff --stat与三成员清单逐行对应,零 rider。联动(本 PR 不动 objectui)
Studio 元数据编辑页的三层对比页签仍在调
?layers=true,需在 objectui 立跟迁单:改调GET /api/v1/meta/:type/:name/layers,body 完全一致、无需改解析。调用点为 objectui 侧 metadata 编辑器的层对比数据源。弃用窗口在本 PR 落地后开启,未设硬截止——Sunset日期待维护者决定后再定,届时才删除?layers=分支。Generated by Claude Code