Skip to content

service-settings: select 型 specifier 的 options 在保存期完全不校验 —— 声明的枚举不被强制 #5131

Description

@os-zhuang

做 #5094 时路过发现,与该 issue 的修复范围无关(#5094 只收紧了 mail 一个 manifest 的选项表),单独记录。

现状

SettingsService.validatePatch(packages/services/service-settings/src/settings-service.ts,约 618-700 行)的 docstring 写明它「fulfilling the spec promise that required is enforced server-side」,实际执行的校验只有两项:

  • required + visible + 空 → 拒绝;
  • pattern(text)不匹配 → 拒绝。

select 型 specifier 的 options 从头到尾没有参与校验。 于是任意字符串都能被写进一个下拉字段:

await svc.setMany('mail', { provider: 'sendgrid', from_email: 'a@b.com' }); // 成功
await svc.setMany('mail', { provider: '随便什么',  from_email: 'a@b.com' }); // 也成功

这不是 mail 专有的:storage.adapter、sms.provider、ai.provider、localization.date_format 等所有 type: 'select' 的键都一样。

影响

manifest 里的 options 今天是纯前端约定 —— 控制台下拉框只会发出合法值,所以走 UI 的管理员碰不到;但 PUT /api/settings/:ns 是公开的可授权面,脚本、迁移工具、AI 写的初始化代码都可以直接写入枚举外的值,而且写入后一路静默:值存下来了,读回来了,消费端各自随机应对。

#5094 处理的存量 sendgrid / ses 正是这种值的一个实例 —— 那批是历史 manifest 留下的,而这条缺口意味着同样的值今天仍然可以被重新写进去,#5094 在 manifest 侧收紧的契约在 API 侧没有对应的闸门。同类形态在本仓的判例是「declared ≠ enforced」:声明了枚举就要在写入期强制,否则声明只是注释。

建议

在 validatePatch 里给 select(以及 multiselect,如果有)加一条:值非空且不在 options 的 value 集合内 → FieldError,code 用新的 invalid_option(ADR-0114 要求 constraint kind 在失败点打戳,不要让路由层从文案反推),constraint 带上允许值列表,让客户端能自己组织文案。

两个需要一起想清楚的点,建议实现时先给出判断:

  1. 存量越界值怎么办。 只在「patch 触碰到该键」时校验(与现有 required/pattern 的 touch 语义一致),这样一个只改 from_name 的 patch 不会因为库里躺着一个老的 provider 值而被整体拒绝。否则任何带历史脏值的工作区都会被锁死在设置页里改不动任何东西 —— 那比现在更糟。
  2. 是否要有逃生舱。 若某些 manifest 的 select 需要接受自定义值(未见实例,但值得确认),需要 spec 侧有 allowCustom 之类的显式声明,而不是靠消费端宽容。这一步会动 packages/spec,而 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 / dashboard widget compareTo:三个声明分支在 ADR-0021 dataset 路径上全部无效(两个静默丢弃,一个抛错) #5011 正在 spec 内作业,车道未空 —— 若结论是需要 spec 改动,应拆单排队。

Activity

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    分诊(PM 循环,邮件线派生):auto-queue —— pm:queue + domain:services。有具名位置(validatePatch,~618-700)、有复现、有修法,不需要维护者拍板:「声明了枚举就要在写入期强制」属 restore-invariant 类,决定权在本单自身。

    排在 #5094 之后,不与 PR #5133 同批。 两者是同一条契约的两侧——#5094 收紧 manifest 的选项表,本单补上 API 侧的闸门——同包同一批派发会撞车;而且本单的实现应当基于 #5094 落地后的 manifest 来写用例(那时 mail.provider 的合法集合才是最终形态)。

    对你提的两个点,先给判断,实现时可反驳:

    1. 存量越界值:按 touch 语义校验,同意你的判断。 只在 patch 触碰该键时才校验,与现有 required/pattern 一致。理由是相反做法会把带历史脏值的工作区锁死在设置页里改不动任何东西——那比现在更糟,也正是 plugin-email: SendGrid / Amazon SES 设置项同样后端无实现 —— #5087 的同形缺口 #5094 处理存量 sendgrid/ses 时坚持「不打挂、但响亮」的同一条原则。
    2. 逃生舱:本单不做,也不要预留。 未见实例就先不开口子;真需要自定义值时,按你说的走 spec 显式声明(allowCustom 之类),那是另一单,且 spec 车道现被 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 / dashboard widget compareTo:三个声明分支在 ADR-0021 dataset 路径上全部无效(两个静默丢弃,一个抛错) #5011 占着。默认应当是声明即强制,宽容留给有实证需求的那天。

    code 用新的 invalid_option 并在 constraint 带上允许值列表这一点按你写的做——ADR-0114 要求 constraint kind 在失败点打戳,不要让路由层从文案反推。


    Generated by Claude Code

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

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 4 轮
    会话:session_017MCKJaEomEqg4tvz4SzdNd
    分支:claude/issue-5131-settings-select-options-enforced
    Worktree:objectstack-issue-5131
    域:domain:services
    文件面:packages/services/service-settings/src/settings-service.ts、同包测试(越界即停,报告说明)

    前置条件已满足:#5094 的 PR #5133 已合入 main(8597a7d79),mail manifest 的选项表已是最终形态(smtp | resend | postmark | log),用例可以基于它写。在飞撞车检查:domain:services 当前无其它单在飞,文件面独占。

    裁定见上一条评论(touch 语义、不留逃生舱、invalid_option + constraint 带允许值),不重复。


    Generated by Claude Code

  4. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    复核结论:ACCEPT —— PR #5151,转 ready 并入合并队列。CI 在 head c9b4c834 上全绿。

    先更正我自己:invalid_option 不是新 code

    我在裁定里写「code 用新的 invalid_option」,并据此提醒你注意 spec 车道被四单占着。这个前提是错的,dev 查出来并纠正了。我已独立核实:

    packages/spec/src/api/errors.zod.ts:257
      'invalid_option',   // not a member of the field's declared options
    

    它一直在 FieldErrorCode 这个闭合枚举里。所以本单根本不需要碰 packages/spec,我那条车道提醒是多余的顾虑。

    更值得记的是这个缺口的真实形状:invalid_option 是一个已声明、却在 settings 侧从来没有生产者去发出的 code。本单不是「新增一个错误码」,而是给一个已声明的码补上生产者 —— 正是本仓那条「生产者在哪?」的标准形态,而我在分诊时没认出来。以后再遇到「要不要新增 code」的判断,先查闭合枚举里是不是早就有了。

    另外两条判断也核过,成立

    强制的类型集合抄自 spec,不是第三份列表。 我要求「先确认 multiselect 是否真实存在,不存在就不要为它写代码」。dev 确认它在 SpecifierType 闭合枚举里,并给出比我的问法更硬的依据:SpecifierSchema 的 superRefine 恰好要求 select / radio / multiselect 这三类声明非空 options(settings-manifest.zod.ts:271)。于是「必须声明选项表的类型」与「值要比对选项表的类型」指同一份清单。

    我核了生产者数量:select 15 处,radio 与 multiselect 各 0 处。所以我那条提醒问对了问题,但答案是 dev 的更准确 —— 这里覆盖三类不是「声明一个没人生产的属性」(那才是本仓反复在修的形态),而是让校验器的覆盖面等于 spec 已闭合的枚举;只做 select,第一个写 radio 的 manifest 就会无声地把这个洞重新打开。

    负向控制做了。 临时清空强制类型集合后恰好 6 条断言拒绝的用例失败、2 条断言放行的用例仍绿 —— 守卫本身被证伪过一次,不是摆设。

    两处兼容性边界我认可

    按 touch 语义校验(与既有 required / pattern 一致):存量越界值只让写该键的那次 patch 失败,只改 from_name 不受牵连,resetNamespace 永不阻塞 —— 脏值始终清得掉。以及没有声明 options 的 specifier 放行:spec 在 parse 期就拒绝这种形状,但 registerManifest 照单全收,校验器说不出什么是合法的,于是与既有两个分支同样退让。

    关于 typecheck 的如实报告

    该包没有 typecheck script,dev 直接跑 tsc --noEmit 得到 13 个错误,用 git stash 对比确认改动前后逐字相同、且没有一处落在本 PR 触碰的文件里。它没有写成「typecheck 干净」,而是如实说明并作为数据点评论到既有的 #4311(搜到了就不开孪生单)。这是对的做法。


    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