Skip to content

评估:plugin-dev「stub 填满槽位」这个早期设计是否整体作废 —— #4000/#4058 逐个退役到第 4 个之后,该问的是台面本身 #4093

Description

@os-zhuang

按 Prime Directive #10 记录。#4000 退役 1 个 dev stub,#4058 / PR #4086 又退役 3 个并把剩下的分了类 —— 两轮都是"逐个判断"。这条 issue 问上一层的问题:「任何未被真实插件占用的核心服务槽位,都注册一个 dev stub 填满」这个设计本身是否还应该存在。

不是要求立刻删,是要求评估。下面是评估所需的证据,以及一个分档建议。

现状(#4086 之后)

类 槽位 plugin-dev 里的实现
degraded(真干活,内存内) cache, queue, job, i18n 委托 core 的 createMemory*
metadata 手写第二份(见证据 3)
file-storage, search, realtime, workflow 只有 plugin-dev 有
stub(编造) data, auth, security.permissions, security.rls, security.fieldMasker 手写
ui 无 factory → 无形状占位 {}
已退役 analytics(#4000), automation, notification, ai(#4058) —

证据

1. 原设计理由已被反证

代码里的理由是"填满完整的 kernel service map,让下游拿到正确的返回类型(数组、布尔、对象,而不是 undefined)"。

但生产环境本来就是空槽 —— 所有这些服务都是 optional,没装插件就没有。所以每个下游本来就必须处理缺失,否则生产早就崩了。#4086 是直接反证:一次清空 4 个槽位,runtime 923 / plugin-dev 8 / objectql 1183 / metadata-protocol 99 全绿,端到端起 kernel + dispatcher 正常服务。没有任何"拿到 undefined 就崩"的下游冒出来。

这个理由成立的前提,恰恰是它想避免的那个 bug —— 如果真有下游不处理空槽,那个下游在生产里就是错的,应该修它,而不是在 dev 里喂假数据把它盖住。

2. 安全形状的假实现,撞在 D12/#3891 自己定的红线上

ADR-0076 D12 从 #3891 学到的结论写得很明确:「A fallback may degrade features, never security semantics」。plugin-dev 的这三个 stub 做的正好是后者:

security.permissions → checkObjectPermission() { return true; }   // allow-all
security.rls         → compileFilter()        { return null; }    // 无行过滤
security.fieldMasker → maskResults(r)         { return r; }       // 不脱敏

而 packages/spec/src/contracts/security-service.ts:20 明确说这三个句柄"are implementation internals and deliberately NOT part of this contract",由 plugin-security 注册 —— plugin-dev 却给别人的实现内部造了假,且方向与同一份契约要求的 fail-closed 相反("Access-narrowing answers fail CLOSED … A consumer must never treat a thrown error or a deny filter as 'no restriction'")。

触发条件是现实的:plugin-dev 对每个子插件都是 try/catch 优雅降级,所以只要 @objectstack/plugin-security 没装上(一条 warn 日志),allow-all + 无 RLS + 不脱敏就静默上线。这一档与"该不该保留 stub 台面"无关,本身就该走。

补充一个精确性:auth stub 在真实身份路径上是死代码 —— resolve-execution-context.ts:104/120 走的是 authService.api.getSession() / getApi() / verifyMcpAccessToken(),而 stub 只实现了 verify() / getCurrentUser() / handleRequest(),所以实际降级为匿名(fail-closed)。它不是活的越权洞。但 verify() → { success: true, user: { roles: ['admin'] } } 正是下一个消费者会直接信的形状,而且它占着槽位让 discovery 声称 auth 在场、让 /api/v1/auth 被广告出去。潜在,而非当下。

3. 与 packages/core/src/fallbacks/ 重复,且有"同一个 bug 修两处"的收据

cache/queue/job/i18n 是委托 core 的 createMemory*(这没问题)。但 metadata 是 plugin-dev 手写的第二份内存注册表,与 packages/core/src/fallbacks/memory-metadata.ts(63 行)并存。代价已经付过一次,packages/core/CHANGELOG.md:1386 原文:

Both in-memory metadata fallbacks (@objectstack/core's createMemoryMetadata and @objectstack/plugin-dev's dev stub) now implement registerInMemory …

一个 registerInMemory 缺失的 bug,要在两个地方修 —— 与 #3891 记录的"同一个安全门被造了两遍"完全同形,只是这次代价小。

4. 这个包已发布、无护栏、仓库内无人使用、文档描述还是错的

  • @objectstack/plugin-dev 不是 private(17.0.0-rc.0),会发到 npm。
  • 没有任何 NODE_ENV / 环境护栏:dev-plugin.ts 里没有一处检查运行环境,装上就注册整套假实现(包括第 2 条那三个)。
  • 仓库内没有真实使用者:os dev 走的是 serve 的真实 capability 装配(commands/dev.ts 不引用 DevPlugin),examples / create-objectstack 模板 / apps 全无引用;只剩 CLI 一处测试注释和文档。
  • 文档描述与实际不符:content/docs/plugins/packages.mdx:347-350 说它是"Metadata validation, schema introspection, debugging tools"。实际它是"一键装配 objectql + driver-memory + auth + security + hono + rest + dispatcher"外加 stub 台面 —— 两件事都不是文档写的那件。

建议:分三档,不一刀切

A. 造假类 → 直接删(data、auth、security.permissions、security.rls、security.fieldMasker、无形状 ui)
空槽就是生产语义,#4000/#4058 已经把这条路走通四次。第 2 条的三个安全槽位是这一档里最该先走的。

B. core 已有 fallback 的 → 删掉 plugin-dev 那一份(metadata,以及把 cache/queue/job/i18n 的包装收干净)
一份实现,一处修。与 #4089 合并做更省:那条要给 core 的五个 fallback 打 __serviceInfo,正好一起把"谁是唯一那份"定下来。

C. 只有 plugin-dev 有的真实内存实现 → 唯一值得讨论保留的一档(file-storage、search、realtime、workflow)
它们确实有 dev 价值(不装服务也能试功能)。但正确归宿是真实服务自己的 InMemory 策略(@objectstack/service-analytics 已是先例 —— #4000 的迁移路径就是"装真引擎,它有 InMemory 策略")或 core fallback,而不是一份平行的假台面。所以这一档的问题不是"删不删",而是"退役前要不要先把对应服务的 InMemory 策略补上"。

剩下的 plugin-dev = 纯装配插件。那才是它真正的价值,也应该顺手把文档描述改对。

待定问题

  1. 已发布包的 breaking 面:外部使用者可能真的依赖某个 stub。17.x 还在 rc,是直接切 + changeset 写清 FROM→TO,还是留一个 deprecation 窗口(stub 保留但打 stub 标记 + 启动 warn)?
  2. C 档的顺序:先补 InMemory 策略再退役,还是先退役、把"dev 里试不了"当可接受的短期回退?前者更慢但不产生体验回退。
  3. 是否顺带加环境护栏:给 plugin-dev 加 NODE_ENV !== 'production' 断言(逃逸阀按 PD [WIP] Create a new release version #9 用 OS_ALLOW_*)。考虑到第 2 条的安全语义问题,这条可能不该等这个评估 —— 也许应该独立成 issue 先走。

关联:#4058、#4000、#4089、#4087、#3891、#3989、ADR-0076 D12。

Activity

  1. self-assigned this
    on Jul 30, 2026
  2. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    开完这条之后 #4082(step 1)落地了分类,PR #4086(step 2)落地了门。补三条据此更新的事实,都影响上面的分档:

    1. 第 3 条证据(与 core fallback 重复)已被 #4082 部分消化

    _fallback: true 那个标记问题已在源头修好(本仓另开的 #4089 已关闭)。但B 档的实质没变:packages/core/src/fallbacks/memory-metadata.ts 与 plugin-dev 的 createMetadataStub 仍然是两份并存的内存元数据注册表,现在还各自带一份内容几乎相同的 __serviceInfo.message。registerInMemory 那个"一个 bug 修两处"的收据依然成立,只是现在多了一处要保持同步的文案。

    2. A 档里 automation / notification / ai 的 HTTP 面已经不再说谎 —— 所以删除是一个纯粹的"要不要",不再是"必须"

    PR #4086 的门让这三个(step 1 已标成 stub)的 HTTP 面等于空槽,端到端实测:

    services:  automation  stub  handlerReady=false  route=—
               notification stub handlerReady=false  route=—
               ai          stub  handlerReady=false  route=—
    requests:  GET /automation handled=false   GET /notifications handled=false
               POST /ai/chat 404              GET /ai/agents 200 {"agents":[]}
    

    留下的只是进程内的那一半 —— execute() / send() 仍会对进程内调用者报成功。这仍然是删除它们的理由(#4058 原文倾向的正是这条),但严重性比开这条 issue 时低了一档:不再有任何 HTTP 消费者能读到编造数据。

    3. 新发现:data 槽位的标签根本到不了 discovery

    packages/runtime/src/http-dispatcher.ts:1010:

    data: svcAvailable(routes.data, 'kernel'),   // 第三参 svc 省略

    svcAvailable(route, provider, svc?) 只在传了 svc 时才去读 readServiceSelfInfo,而 dataSvc 从头到尾没被解析(getDiscoveryInfo 的 Promise.all 里没有它)。所以 data 槽位被 dev stub 占住时,discovery 照样报 status: 'available', handlerReady: true —— step 1 给它打的 stub 标签被这个调用点丢在地上。metadata-protocol 那侧的 builder 也把 data 放在硬编的 kernel-provided 块里,同样不读标记。

    这正是 D12 根因段点名的那个 hardcode("svcAvailable hardcodes available")在 data 上的残留 —— #3028 修了函数,没修这个不传参的调用点。

    顺带确认了 /data 域根本不消费 data 服务槽位:它走 callData(protocol-first + ObjectQL fallback,domains/data.ts:73),只用 deps.success / deps.error / deps.isMultiTenantHost。所以:

    • 删掉 data dev stub 对 HTTP 面零影响(A 档因此更容易走);
    • 但在删掉之前,那条 data: 讨论线本身该修 —— 否则任何占住 data 槽位的假实现都会被 discovery 报成真的。

    同理确认 ui:/ui 域读的是 protocol 服务(domains/ui.ts:37),不是 ui 槽位。所以删掉无形状的 ui 占位会误伤 routes.ui —— 那条路由实际是能用的,却因为 hasUi 按 ui 槽位判定而消失。A 档删 ui 之前,得先把 routes.ui 改成按真正服务它的东西(protocol)判定。这条是"declared ≠ enforced"的反向:广告挂在一个不服务它的槽位上。


    据此,A 档的执行顺序建议改成:

    1. security.permissions / security.rls / security.fieldMasker —— 撞红线的那三个,先走(可与环境护栏一并);
    2. auth(真实身份路径上的死代码)、data(HTTP 面零影响)—— 但 data 要先修上面第 3 条的 data: 调用点,否则删了 stub、下一个占槽者又会被报成 available;
    3. ui —— 要先把 routes.ui 的判据从 ui 槽位改到 protocol,否则是回退。

    Generated by Claude Code

  3. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    A 档第一步已开 PR #4126:三个 security.* stub 退役 + plugin-dev 在 NODE_ENV=production 下拒绝初始化(逃逸阀 OS_ALLOW_DEV_PLUGIN=1)。

    同时 A 档的 auth 一项挂住了,原因是准备删它的时候发现了 #4113 —— dispatcher 的 /auth 域自带 mock 登录,任意邮箱+任意密码返回 200 + 伪造 session token,而且它在 packages/runtime 里(跟发行版走,不是 dev-only)。关键点:plugin-dev 的 auth stub 从来没被那个分支调用过 —— 域要求 .handler,stub 实现的是 handleRequest。所以在 #4113 修掉之前,删 auth stub 对 /auth/* 的诚实度毫无作用。

    据此,A 档剩余项的状态:

    项 状态 前置
    security.permissions / .rls / .fieldMasker PR #4126 —
    环境护栏 PR #4126(同一个 PR,同一处发现) —
    auth 阻塞 #4113(先处理 dispatcher 的 mock 登录)
    data 待做 先修 http-dispatcher.ts 的 data: svcAvailable(routes.data, 'kernel')(不传 svc → 任何占槽者被报 available)
    ui 待做 先把 routes.ui 的判据从 ui 槽位改到真正服务 /ui 的 protocol

    B 档(与 core fallback 的 metadata 重复)和 C 档(真实内存实现该归到各服务的 InMemory 策略)都还没动。


    Generated by Claude Code

  4. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    A 档第二步已开 PR #4146:data 与 ui 退役(security.* ×3 + 生产护栏已随 #4126 合入 main)。

    两个前置的处置:data 的前置(discovery 那行 hardcode)已由 #4130/#4141 在 main 上修掉,直接受益;ui 的前置(routes.ui 改判据)由 #4146 自带 —— discovery 现在按 protocol.getUiView(即 /ui 域自己的守卫)广告,placeholder 删除因此不会误伤实际能用的路由,反而修正了两个方向的既有失真(无 protocol 的 dev 启动过去广告一条只能 503 的路由;生产启动过去隐藏一条能用的路由)。

    A 档状态更新:

    项 状态
    security.permissions / .rls / .fieldMasker ✅ 已合入(#4126)
    环境护栏(NODE_ENV=production 拒绝启动,OS_ALLOW_DEV_PLUGIN=1 逃逸) ✅ 已合入(#4126)
    data PR #4146
    ui PR #4146
    auth ⛔ 仍阻塞在 #4113 —— dispatcher /auth 域自带的 mock 登录才是空槽时的真正应答者,不先退役它,删 auth stub 无效果

    #4146 合并后,A 档只剩 auth 一项,且它的实质工作在 #4113。B 档 / C 档未动。


    Generated by Claude Code

  5. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    A 档完成。 最终落点与本 issue 开出时的逐槽位计划不同 —— ADR-0115(#4137)把问题整体收掉了:stub`` 表作为设计退役,plugin-dev 只做装配、不注册任何自有实现,空槽在 dev 与生产含义一致。

    按合入顺序的完整收口:

    落地 内容
    #4126 security.* ×3 退役(D12 红线)+ NODE_ENV=production 拒绝启动(OS_ALLOW_DEV_PLUGIN=1 逃逸)—— ADR-0115 收尾更新将其选择定为 canonical
    #4137 整张 stub 表退役(Tier A+B+C 一次落地,含 data/ui/auth 及全部 in-memory 实现;Tier C 以真实 service-storage / service-realtime 装配替代)
    #4146 ADR-0115 刻意留下的 runtime 缝隙:routes.ui/services.ui 改按真正服务 /ui 的 protocol.getUiView 判定(域自己的守卫,hasMcp 规则)—— stub 表退役后 ui 槽位在任何模式都为空,旧判据会永久隐藏一条在服务的路由

    前置调查中发现的 discovery hardcode(data 槽位)由 #4130/#4141 修掉;#4089 由 #4082 在源头解决。

    本 issue 范围内仅存的 fabricator 在 plugin-dev 之外:#4113 —— dispatcher /auth 域自带的 mock 登录(任意邮箱+任意密码 → 200 + 伪造 24h session token,在 packages/runtime 里随发行版走)。ADR-0115 的收尾更新点名它是"那类假实现仅存的地方"。B/C 档的遗留问题已由 #4137 的 Tier B/C 处置覆盖。

    建议:本 issue 可以关闭(或改题收窄为跟踪 #4113 的收口)。


    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

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions