Skip to content

#4488 审计发现的四个"授权门断连":email_template / job / validation 的元数据条目到不了执行点,action 导航项点不动 #4509

Description

@os-zhuang

#4488 把最后 9 个类型纳入活性账本时,逐类型闭合调用图暴露出四个结构性断连 —— 不是单个死键,而是"整扇授权门通向空地"。每一个都是 webhook #3461 的形状,而 webhook 那扇门已经用 #3489(materializer 桥)修好了,所以修法有现成范本。全部按 ADR-0049 enforce-or-remove 处置:要么建桥,要么关门(撤销注册/收窄 schema),不许维持现状。

1. email_template —— 整个元数据表面到不了 sendTemplate(最严重,涉及认证邮件)

  • 执行端:sendTemplate 只读 sys_email_template 行(plugin-email/src/email-service.ts:404-465),行的唯一写入者是内置 auth 模板 + 代码构造的 EmailServicePluginOptions.templates(email-plugin.ts:358-371, :431-468);没有任何 bootstrapper 传 templates(serve.ts 的 email 参数组装里没有它)。
  • 授权端:stack emailTemplates:(被 metadata plugin 摄入为元数据条目)、*.email-template.ts、Studio(studio.app.ts:331 的导航指向 metadata-admin 列表)、PUT /meta —— 全部落进没人读回的元数据存储;包导入路径还显式排除 emailTemplates(runtime/src/domains/packages.ts:569-571)。
  • 后果:管理员在 Studio"修好"了密码重置邮件,保存成功,用户继续收到内置版 —— 认证邮件上的 ADR-0078 false compliance。
  • 修法:upsertTemplate 的字段映射(email-plugin.ts:432-449)就是未来桥的映射表,和 fix(webhooks): materialize stack-declared webhooks into the dispatcher (#3461) #3489 用 sys_webhook 列映射的方式完全一致。或者:撤销 email_template 的 allowRuntimeCreate 并从 stack schema 移除 emailTemplates:,让 sys_email_template 数据门成为唯一入口。
  • 账本:packages/spec/liveness/email_template.json(全表面 dead,warn 载于 name)。

2. job —— 运行时创建的 job 条目永远不会被调度

  • 只有编译后 bundle 的 jobs 到达调度器(app-plugin.ts:766-802,handler 从 bundle 函数表解析);job 注册为 allowRuntimeCreate: true(metadata-plugin.zod.ts:640),但没有任何代码把运行时 job 条目送进 IJobService.schedule —— 而且结构上不能:运行时条目的 handler 在 bundle 函数表之外无从解析。
  • 修法:要么撤 allowRuntimeCreate(诚实:job 是代码工件),要么设计 handler 可解析的运行时 job(比如 handler 限定为已注册 flow / 具名函数)再建桥。
  • 账本:packages/spec/liveness/job.json 类型注记。

3. validation —— 独立 validation 条目绑不到任何对象

  • 写路径只消费对象内嵌的 object.validations(engine.ts:3703/:4017/:4085 → rule-validator);ValidationRuleSchema 本身没有对象绑定键,没有任何合并代码,只有引用追踪器假设条目上有 object/objectName(metadata-protocol/src/protocol.ts:1306 —— 这个键甚至不在 schema 里,parse 会剥掉它)。
  • 后果:在 Studio 里创建一条 state_machine 规则(ADR-0020 记录状态机!),保存成功,从不拦截任何写入。
  • 修法:要么给 schema 加 objectName + 建合并桥,要么撤 allowRuntimeCreate 并把独立 *.validation.ts filePatterns 收掉(规则只在对象里授权)。
  • 账本:packages/spec/liveness/validation.json 类型注记(规则词汇本身全活 —— 经由内嵌路径)。

4. action 导航项渲染但点击无事发生

  • NavigationRenderer 的 action 分支把点击派给宿主传入的 onAction prop(NavigationRenderer.tsx:976),但 objectui 的两条 console 侧边栏路径(AppSidebar / UnifiedSidebar)都不传 onAction —— actionDef.actionName 到不了任何 dispatcher,条目照常渲染、照常被门控,点了没反应。
  • 修法:objectui 侧接 onAction → ActionEngine(或在接通前把 action variant 标记 experimental)。跨仓库:objectui 仓库需要对应 issue。
  • 账本:packages/spec/liveness/app.json 类型注记(actionDef 在 union 走查边界之外,故记录在注记而非行)。

顺带的三个小清理候选(不阻塞,同 PR 顺手也行)

  • app.contextSelectors[].includeAll:渲染器有意忽略(ContextSelectors.tsx:242-246,mandatory-scope 语义,注释即处方)→ retiredKey 候选。placement 同理(无消费者)。
  • book.groups[].include 的 { tag } 变体:resolver 实现了 tag 匹配,但 DocSchema 没有 tags 属性,永远匹配不中 → either 给 doc 加 tags or 收掉变体。
  • mapping.errorPolicy / batchSize:死键但因 schema 默认值在编译期物化而不可 warn(_authorWarnSkipped)→ 想让作者听到警告只能走移除路线。

未指派 —— 认领哪一项请在下面说一声。每项独立可做;1 和 3 是安全形状,优先。

Activity

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

    @os-zhuang
    ContributorAuthor

    🔒 CLAIM — 「顺带的三个小清理候选」这一节

    • session: session_01E5CYr5SDwe85gH2Jr5KSgu
    • branch: claude/auth-gate-disconnect-issues-6wz8ew
    • worktree: objectstack-4509

    只认领这一节。 正文的四条主线已经完成并合并:#4558(email_template 建桥 + job 关闸 + validation 退役 kind)、objectui#3180(导航 action 接 onAction)。


    为什么现在做:这批是限时的

    @objectstack/spec 现在 17.0.0-rc.1,.changeset/pre.json 仍是 mode: pre / tag: rc,latest 停在 16.1.0。破坏性移除现在落地进 17.0.0;一旦 changeset pre exit,同样的移除要等 v18。

    更要紧的是这四个键的共同性质 —— mapping.errorPolicy、mapping.batchSize、app.contextSelectors[].includeAll、app.contextSelectors[].placement 全部带 _authorWarnSkipped:它们都有 schema 默认值,默认值在编译期物化,活性 lint 分不清「作者写的」和「schema 填的」,所以永远无法 authorWarn。让作者听到的唯一路线就是移除。错过 v17,整个 17.x 生命周期里作者写这些键都收不到任何信号。

    最尖锐的证据来自平台自己 —— packages/platform-objects/src/apps/studio.app.ts:76,79 同时写了 includeAll: true 和 placement: 'sidebar_header',而渲染器故意忽略 includeAll(ContextSelectors.tsx:242-246):选择器是 mandatory-scope 语义,一个 "All" 行会解除作用域,对 Studio 的 package 过滤器等于泄露系统元数据。同一文件的注释还写着「we deliberately hide the platform's own system/cloud kernel packages」,而它下面三行的 includeAll: true 如果真被读,做的正好相反。


    处置与范围(相对正文有两处调整)

    项 键 路线 变更类别
    A mapping.extractQuery / errorPolicy / batchSize strict 删除 major(限时)
    B app.contextSelectors[].includeAll / placement strict 删除 major(限时)
    C book.groups[].include: { tag } 接通,不移除 minor(不限时)

    调整 1 —— 加收 mapping.extractQuery。 正文只点了 errorPolicy/batchSize,但 extractQuery 在同一个 schema、同一条 conversion、同一份账本里,而且它是 authorWarn 级(比另外两个更严重:「promises an export path that does not exist」)。分开做要动两次 mapping.zod.ts,两条 conversion 的 fixture 还得互相避让。

    调整 2 —— book 的 { tag } 变体走 enforce,不是 remove。 正文写的是「either 给 doc 加 tags or 收掉变体」。查下来这是一条只差最后一厘米的完整链路:

    • 消费端已实现:matchesInclude 的 tag 分支 —— packages/spec/src/system/book.zod.ts:213
    • 传输端已接:packages/rest/src/rest-server.ts:3218 已经在转发 tags: d.tags
    • resolver 接口已声明:ResolverDoc.tags?: string[] —— book.zod.ts:172-173,注释写着 (P3d; absent today)
    • 唯一缺口:DocSchema 是 strictObject 且没有 tags 键 —— 所以在 doc 上写 tags: 是解析错误,d.tags 永远 undefined,include: { tag } 永远匹配不中

    ADR-0049 的原话是功能存在、只差接线时 enforce 胜出。而且移除 union 成员没有干净的墓碑机制(retiredKey 只适用于 object 键),作者只会拿到泛泛的 union 报错。


    清单

    认领与分支

    项 A — mapping 三键退役

    • data/mapping.zod.ts:删 shape 三键(:145/:148/:149)
    • 删指向它们的 alias(:118-120 query / onError·errorHandling·errorMode / batch·chunkSize)—— 留着就是把作者指向已不存在的键
    • 重写 guidance.skipErrors(:126 现在说「errorPolicy: 'skip' 是默认值且已经这么做了」,移除后指向空气)
    • 加三条 guidance 处方;必须消歧:bulk-action / offline / sync / connector / events-integrations / seed-loader / driver-nosql 上都有活的 batchSize,处方要明说被删的是 mapping.batchSize。不提供改名建议 —— 没有等价物(导入错误处理走请求自己的 options,写路径自己定批大小,导出走普通 query API)
    • liveness/mapping.json 删这三行(strict 删除 ⇒ 留行会报 ORPHAN)

    项 B — app.contextSelectors[] 两键退役

    • ui/app.zod.ts:删 shape 两键(:767/:777)、删 knownKeys(:782)、删 alias showall·location(:783)
    • 加 guidance 两条处方(includeAll 的处方要写出安全理由)
    • 连带:allValue 重新核验 —— 账本 note 明写 "re-verify if includeAll is ever removed"。它仍 live(哨兵读法为真),但其声明用途「value emitted when All is selected」永不可能发生:改 .describe() 为哨兵语义 + 改账本 note + 新 verifiedAt
    • platform-objects/src/apps/studio.app.ts:76,79 停止 author 这两个键,原地留注释
    • liveness/app.json 删两行、改 allValue 行

    项 C — book { tag } 接通

    • system/doc.zod.ts 加 tags: z.array(z.string()).optional()
    • book.zod.ts:172-173 注释从 (P3d; absent today) 改为指向 DocSchema.tags 的实况
    • 端到端测试:两个 doc 带 tag、一个不带,group 用 include: { tag },断言只有带 tag 的进组

    ADR-0087 D2/D3 注册(A/B 必须,C 不需要)

    • 新增 conversion mapping-inert-keys-removed(toMajor: 17,retiredFromLoadPath: true,expectedNotices: 3)。⚠ 必须 mapCollection(stack, 'mappings', …) 限定集合,全局 strip 会误删 connector/sync/bulk-action 上活的 batchSize
    • 扩已有的 app-dead-authoring-keys-removed(registry.ts:1595,同 toMajor: 17),不新开 —— 同 major 同类型新开会撞 fixture 互斥契约。contextSelectors 是数组,需下钻一层并 copy-on-write。expectedNotices 4 → 6
    • migrations/registry.ts 的 step17.conversionIds 加 mapping-inert-keys-removed + 扩 rationale。⚠ id 打错不会报错、只在 replay 时静默跳过
    • fixture 互斥自检(before 最小化,不带别的 conversion 的键;retiredFromLoadPath fixture 还要能零通知走默认加载路径)

    生成物与周边

    • gen:schema → authorable-surface.json 五行消失(strict 删除的特征,闸门 (a) 的绊线,本 PR 刻意删)+ json-schema.manifest.json
    • gen:spec-changes / gen:upgrade-guide / gen:api-surface / gen:docs
    • forms:剪掉这五个键的 { field: '<key>' } 输入(若有),留一行注释
    • pnpm i18n:extract(剪 form 输入会改抽取标签),check:i18n 把关
    • lint-liveness-properties 的测试(账本驱动会自动停止告警,但测试跑在真实 shipped 账本上 —— 上一轮 datasource 账本判定的 20 条死键至今无人处置:三个块整块无人读,其中 readOnly 让一个 shipped 示例的「只读副本」可写(ADR-0049 enforce-or-remove) #4583 在这里翻过车)
    • pin 测试:每个被删键一条否定断言(.toThrow(/…/s))
    • 文档:references/data/mapping.mdx:63-65、references/ui/app.mdx:131,134 是生成的,由 gen:docs 重出;散文/示例另 grep。⛔ 不碰 content/docs/releases/
    • changesets ×2:major(A+B,带 FROM → TO 和一行修法)+ minor(C)

    验证

    • 全量 pnpm build → pnpm test(⛔ 不用 --filter <pkg>...,部分失败会留陈旧 dist)
    • 十道 spec 闸门 + check:i18n
    • check:liveness:mapping dead 3 → 0;app open dead 6 → 4
    • 回归钉:bulk-action/sync/connector/offline/seed-loader 的 batchSize 仍正常 parse,未被 conversion 误伤
    • os migrate meta --from 16 在带这五个键的 fixture 上重放,第二次 applied 为空(幂等)

    明确不做

    进度会随着做随时更新本条清单。


    Generated by Claude Code

  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    进度更新 —— 三项全部实现完毕,PR #4664(draft,待 CI)

    清单逐条对照:

    认领与分支

    • 认领评论
    • 分支从最新 main(21676eb)重开

    项 A — mapping 三键退役 ✅

    • 删 shape 三键 + 删指向它们的六个 alias + 重写 guidance.skipErrors
    • MAPPING_RETIRED_KEY_GUIDANCE 三条处方,batchSize 那条点名 bulkActionDef / connector / sync / offline / seed-loader / NoSQL cursor 是 DIFFERENT 键
    • liveness/mapping.json 删三行 → mapping 死键 3 → 0

    项 B — contextSelectors 两键退役 ✅

    • 删 shape 两键、knownKeys、两个 alias,加 guidance
    • allValue 重新核验(账本 note 点名要求的那一步):仍 live,但 .describe() 不再叫它「All 被选中时发出的值」—— 那个事件不可能发生
    • studio.app.ts 停止 author 这两个键
    • liveness/app.json 删两行、改 allValue 行

    项 C — book { tag } 接通 ✅

    • DocSchema.tags + ResolverDoc 注释更正 + 端到端测试(三个 doc 带/不带 tag,断言只有带 tag 的进组)

    ADR-0087 ✅

    • 新增 mapping-inert-keys-removed,限定 mappings 集合
    • 扩展 app-dead-authoring-keys-removed 下钻 contextSelectors 数组,expectedNotices 4 → 6
    • step17.conversionIds + rationale 扩写

    生成物与周边 ✅

    验证 ✅

    • pnpm build 71/71、typecheck 122/122、lint 干净、test 132/132
    • 十三道 spec 闸门 + check:i18n 全绿
    • 回归钉:bulk-action/sync/connector/offline/seed-loader 的 batchSize 未被误伤

    两个分出去的东西

    #4651 —— 核验项 B 时确认 filterAppForUser 只走顶层 navigation,从不读 item.areas(rest-server.ts:1823 在 navigation 缺失时提前返回),客户端 area 切换器也照渲染所有 area。所以 app.areas[].visible / requiredPermissions 是 fail-open 的访问闸门,不只是没人读。已如实写进账本并单开 issue 走决策 —— 没在退役 PR 里就地发明授权机制,理由同 #4584。它同样限时(移除路线是破坏性的)。

    objectui#3208 —— objectui 的 AppContextSelectorSchema 按引用派生自 spec,所以下次抬 spec pin 时 report-chart-query-spec-parity.test.ts:76-77 的两条断言会如期失败(漂移守卫正常工作)。已记录要改的两处,现在不动 —— objectui 还 pin 在 ^17.0.0-rc.0,提前改等于对着未发布的形状写代码。


    Generated by Claude Code

  4. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    状态更新 —— PR #4664 已入合并队列

    CI 全绿(Build Core、Test Core ×2、Dogfood Regression Gate ×2、Temporal Conformance、TypeScript Type Check、ESLint、Build Docs、Spec property liveness、Docs Drift、Duplicate Fix Guard、Check Changeset),已标 ready 并入队,mergeable_state: clean。

    中途遇到的一次冲突,记一下

    推上去之后 PR 挂了一段时间一个 CI check 都没有,只有 Vercel 的 ignored build。我一开始读成"队列慢"——读错了:main 在我建分支后前进了六个合并(到 ce5242c),PR 处于 mergeable_state: dirty,GitHub 算不出 merge ref,于是 pull_request 触发的工作流根本没被触发。

    PR 上一个 check 都没有,是冲突信号,不是排队信号。

    冲突两处:

    合并后重跑:全量 build 71/71、十三道 spec 闸门全绿。

    两个 bot 评论都不需要动作

    Vercel 是 ignored build。Docs Drift Check 明说是 advisory —— 它列的是所有引用 @objectstack/spec 的手写文档,任何 spec 改动都会触发全表;不是漂移判定。手写文档里对这五个键的 grep 是零命中,生成的 reference 页已由 gen:docs 重出。


    三项全部完成,合并后本 issue 可关。app.areas 的两个 fail-open 闸门在 #4651,跨仓后续在 objectui#3208。


    Generated by Claude Code

  5. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 核验:三部分全部落地,关闭本单

    队列扫描时复核,本单已无剩余工作:

    部分 承载 状态
    正文四条主线(email_template 建桥 / job 关闸 / validation 退役 kind) #4558 ✅ 已合并
    导航 action 接 onAction objectui#3180 ✅ 已合并
    「顺带的三个小清理候选」(mapping 三键 / contextSelectors 两键 / book {tag} 接通) PR #4664 ✅ 已合并,main 上为 5966c2a

    5966c2a feat(spec)!: retire the five keys the lint could never warn about, and connect doc.tags (#4509) (#4664) —— 破坏性移除赶在 changeset pre exit 之前进了 17.0.0,这批键的限时窗口已兑现。

    分出去的两项各自独立跟踪,不阻塞本单关闭:

    同时移除 pm:queue,退出派发队列。


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions