按 Prime Directive #10 记录,#4087(PR #4112)的根因排查时顺带扫出来的。
为什么要单独开一条
#4087 的根因不是"忘了门控",而是 dispatcher domain 的调用点和 packages/spec/src/contracts/* 对不上——而现有的所有 gate 都不查这件事:route-ledger.conformance.test.ts 只查"域有没有 ledger 行",client-url-conformance 只查"SDK 拼的 URL 有没有人挂",check-route-envelope 只查响应信封。没有任何一条查"这个 domain 调服务的方式,契约认不认"。
所以修完 #4087 我把其余 domain 按同一把尺子扫了一遍。结论:同类问题还有,但方向是反的。#4087 是调用点编了一个没人实现的形状;下面这些是调用点和实现是对的,契约没声明。危害小得多(这些路由能正常工作),但它让契约不再是契约——packages/spec 是 Prime Directive #12 里"metadata 生产者和运行时之间那唯一一份合同",一份漏了半个面的合同挡不住下一个 #4087。
扫描结果
1. /notifications —— 三条 SDK 路由,三个方法契约里都没有
INotificationService(packages/spec/src/contracts/notification-service.ts)声明的全部是:
send(message): Promise<NotificationResult>;
sendBatch?(messages): Promise<NotificationResult[]>;
getChannels?(): NotificationChannel[];
domains/notifications.ts 调的是:
| 行 |
调用 |
路由 |
ledger disposition |
| 72 |
service.listInbox(userId, { read, type, limit }) |
GET /notifications |
sdk → notifications.list |
| 79 |
service.markRead(userId, ids) |
POST /notifications/read |
sdk → notifications.markRead |
| 85 |
service.markAllRead(userId) |
POST /notifications/read/all |
sdk → notifications.markAllRead |
实现在 packages/services/service-messaging/src/messaging-service.ts:284 / :365,工作正常。
值得注意的是 domain 自己已经在 duck-type 了:
if (!isServiceServeable(service) || typeof service.listInbox !== 'function') return { handled: false };
而 #4058 给这行加的注释写得很清楚——typeof service.listInbox !== 'function' 这个判断"顺带把 dev stub 挡在外面了(那个只实现 send / sendBatch)"。dev stub 只实现 send/sendBatch,恰恰是因为它照着契约实现的。 契约漏了收件箱面,于是唯一按契约写的实现反而是"不完整"的那个,靠 duck-type 兜住。
2. /automation —— 三个方法契约里没有
IAutomationService 没有声明:
trigger(name, payload, { request }) —— domains/automation.ts:61,路由 POST /automation/trigger/:name
getConnectorDescriptors() —— :120,实现在 service-automation/src/engine.ts:1404
getFlowRuntimeStates() —— :140,实现在 engine.ts:1555
另外 :66 的 automationService.execute(triggerName, body) 把 HTTP body 直接塞进契约声明为 AutomationContext 的第二个形参。同一文件 :201 传的是真正构造过的 automationContext,两处不一致——这个更接近 #4087 的形状,建议单独确认一下。
3. /i18n —— getFieldLabels 契约里没有
II18nService 声明了 t / getTranslations / loadTranslations / getLocales / getDefaultLocale? / setDefaultLocale? / getCoverage? / suggestTranslations?,没有 getFieldLabels。domains/i18n.ts:105 同样在 duck-type:
if (typeof i18nService.getFieldLabels === 'function') { ... }
路由 GET /i18n/labels/:object/:locale 在 ledger 里是 sdk → i18n.getFieldLabels。
扫干净的
/analytics、/ui、/security、/keys、/share-links、/packages、/meta、/data 的调用点都在契约内。/ai 走的是自己的 route table(真实表在 cloud 的 service-ai/src/ai-route-ledger.ts),不在本次尺子范围内。
建议
按 Prime Directive #12,修契约,不是修调用点。 这三处的调用点和实现是一致的、能用的,错的是那份漏声明的合同:
INotificationService 补 listInbox / markRead / markAllRead(可选方法,因为收件箱是 service-messaging 才有的能力),然后 domains/notifications.ts 的 duck-type 可以退成 isServiceServeable 一个判断。
IAutomationService 补 trigger? / getConnectorDescriptors? / getFlowRuntimeStates?;顺便确认 execute(triggerName, body) 那处是不是应该和 :201 一样构造 AutomationContext。
II18nService 补 getFieldLabels?,domains/i18n.ts 的 duck-type 同样可以退化。
外加一条更值钱的:给这个类别加个 gate。 三处 duck-type(typeof x.foo === 'function')是同一个信号——每一处都是有人当时发现"契约里没有",然后选择绕过而不是补。可行的做法是让 domain 拿到的服务句柄带上契约类型(而不是 as any),调用不上就是编译错误。domains/notifications.ts:44 现在写的就是 as any。
顺带:测试侧的同一个洞
#4087 能活这么久,直接原因是覆盖它的 5 个用例全都 mock 了 handler 想要的形状而不是契约声明的形状(upload 只断言"被调用过"、download mock 成 { data, mimeType },而契约是 Promise<void> 和 Promise<Buffer>)——包括 #4086 上周才加的那两个。如果 domain 测试的 mock 用 satisfies IStorageService 之类约束住,这类 mock 会直接编译不过。这比再加一条运行时 gate 便宜,也更早失败。
关联:#4087 / PR #4112、#4058、#4000、ADR-0076 D12、Prime Directive #10 / #12。
按 Prime Directive #10 记录,#4087(PR #4112)的根因排查时顺带扫出来的。
为什么要单独开一条
#4087 的根因不是"忘了门控",而是 dispatcher domain 的调用点和
packages/spec/src/contracts/*对不上——而现有的所有 gate 都不查这件事:route-ledger.conformance.test.ts只查"域有没有 ledger 行",client-url-conformance只查"SDK 拼的 URL 有没有人挂",check-route-envelope只查响应信封。没有任何一条查"这个 domain 调服务的方式,契约认不认"。所以修完 #4087 我把其余 domain 按同一把尺子扫了一遍。结论:同类问题还有,但方向是反的。#4087 是调用点编了一个没人实现的形状;下面这些是调用点和实现是对的,契约没声明。危害小得多(这些路由能正常工作),但它让契约不再是契约——
packages/spec是 Prime Directive #12 里"metadata 生产者和运行时之间那唯一一份合同",一份漏了半个面的合同挡不住下一个 #4087。扫描结果
1.
/notifications—— 三条 SDK 路由,三个方法契约里都没有INotificationService(packages/spec/src/contracts/notification-service.ts)声明的全部是:domains/notifications.ts调的是:service.listInbox(userId, { read, type, limit })GET /notificationssdk→notifications.listservice.markRead(userId, ids)POST /notifications/readsdk→notifications.markReadservice.markAllRead(userId)POST /notifications/read/allsdk→notifications.markAllRead实现在
packages/services/service-messaging/src/messaging-service.ts:284/:365,工作正常。值得注意的是 domain 自己已经在 duck-type 了:
而 #4058 给这行加的注释写得很清楚——
typeof service.listInbox !== 'function'这个判断"顺带把 dev stub 挡在外面了(那个只实现 send / sendBatch)"。dev stub 只实现 send/sendBatch,恰恰是因为它照着契约实现的。 契约漏了收件箱面,于是唯一按契约写的实现反而是"不完整"的那个,靠 duck-type 兜住。2.
/automation—— 三个方法契约里没有IAutomationService没有声明:trigger(name, payload, { request })——domains/automation.ts:61,路由POST /automation/trigger/:namegetConnectorDescriptors()——:120,实现在service-automation/src/engine.ts:1404getFlowRuntimeStates()——:140,实现在engine.ts:1555另外
:66的automationService.execute(triggerName, body)把 HTTP body 直接塞进契约声明为AutomationContext的第二个形参。同一文件:201传的是真正构造过的automationContext,两处不一致——这个更接近 #4087 的形状,建议单独确认一下。3.
/i18n——getFieldLabels契约里没有II18nService声明了t/getTranslations/loadTranslations/getLocales/getDefaultLocale?/setDefaultLocale?/getCoverage?/suggestTranslations?,没有getFieldLabels。domains/i18n.ts:105同样在 duck-type:路由
GET /i18n/labels/:object/:locale在 ledger 里是sdk→i18n.getFieldLabels。扫干净的
/analytics、/ui、/security、/keys、/share-links、/packages、/meta、/data的调用点都在契约内。/ai走的是自己的 route table(真实表在 cloud 的service-ai/src/ai-route-ledger.ts),不在本次尺子范围内。建议
按 Prime Directive #12,修契约,不是修调用点。 这三处的调用点和实现是一致的、能用的,错的是那份漏声明的合同:
INotificationService补listInbox/markRead/markAllRead(可选方法,因为收件箱是 service-messaging 才有的能力),然后domains/notifications.ts的 duck-type 可以退成isServiceServeable一个判断。IAutomationService补trigger?/getConnectorDescriptors?/getFlowRuntimeStates?;顺便确认execute(triggerName, body)那处是不是应该和:201一样构造AutomationContext。II18nService补getFieldLabels?,domains/i18n.ts的 duck-type 同样可以退化。外加一条更值钱的:给这个类别加个 gate。 三处 duck-type(
typeof x.foo === 'function')是同一个信号——每一处都是有人当时发现"契约里没有",然后选择绕过而不是补。可行的做法是让 domain 拿到的服务句柄带上契约类型(而不是as any),调用不上就是编译错误。domains/notifications.ts:44现在写的就是as any。顺带:测试侧的同一个洞
#4087 能活这么久,直接原因是覆盖它的 5 个用例全都 mock 了 handler 想要的形状而不是契约声明的形状(
upload只断言"被调用过"、downloadmock 成{ data, mimeType },而契约是Promise<void>和Promise<Buffer>)——包括 #4086 上周才加的那两个。如果 domain 测试的 mock 用satisfies IStorageService之类约束住,这类 mock 会直接编译不过。这比再加一条运行时 gate 便宜,也更早失败。关联:#4087 / PR #4112、#4058、#4000、ADR-0076 D12、Prime Directive #10 / #12。