Skip to content

[objectql] readonly 字段:服务端插件 data.update 直写被静默剥离(200但值不变),与 beforeUpdate Hook 补写可落库不对称 #4903

Closed

Description

@baozhoutao

现象

服务端插件(cron/后台任务)经 ctx.getService('data') 的 update() 写 readonly: true 字段时,该字段被静默剥离——调用返回成功(REST 侧同样 200),其余字段正常落库,只有 readonly 字段悄悄丢弃,仅有一条 logger.warn(Field 'x' is read-only — ignoring incoming change (#2948))。调用方拿不到任何机器可读的失败信号。

下游项目实测现场(os-project-titanwind-ehr #750):考勤对象 work_duration(readonly,系统结算的工时)由超时自动下工 cron 落库,data.update(att, { status, check_out_time, work_duration }) 返回正常,但 work_duration 恒为 null。表单/扫码等 REST 路径同字段一切正常,症状呈「只有 cron 写不进」的分裂状,排查成本高。

根因

packages/foundation/objectql core 的 update 管线(stripReadonlyFields):

if (!opCtx.context?.isSystem) {
  hookContext.input.data = stripReadonlyFields(updateSchema, preRo, suppliedKeys, this.logger, ...);
}
  • 剥离条件是 !opCtx.context?.isSystem + 字段 ∈ suppliedKeys;
  • 插件从 ctx.getService('data') 拿到的引擎默认无 context,即服务端可信代码与不可信客户端输入走同一条剥离路径;
  • 与 REST 路径行为不对称:REST 客户端 supplied 的 readonly 键在边界剥离,但 beforeUpdate Hook 内补写的键不在 suppliedKeys 里,能落库——同为服务端代码,Hook 里写可以、插件里写不行。

期望(任一)

  1. 失败可见:readonly 剥离不应只 logger.warn——至少在返回值/options 提供 strict 开关,让被丢字段以错误或 dropped-fields 清单形式回到调用方;
  2. 服务端插件默认可信:PluginContext.getService('data') 出来的写路径给出显式的系统身份约定(或文档明确要求 { context: { isSystem: true } }),消除「Hook 能写、插件不能写」的不对称;
  3. 或在文档把这条语义(剥离条件/suppliedKeys 口径/Hook 豁免)写清楚。

复现

  1. 任一对象声明 readonly: true 字段(如 work_duration);
  2. 插件内 ctx.getService('data').update(obj, { id, work_duration: 1.5, status: 'x' })(不带 context);
  3. 返回成功,status 落库,work_duration 不落,仅服务端 warn 日志。

传 { context: { isSystem: true } } 后同一调用正常落库(项目侧已按此绕行)。

版本:@objectstack/* 17.0.0-rc.1。下游登记:os-project-titanwind-ehr docs/平台缺陷登记.md PLAT-DEF-042。

Activity

  1. claude commented on Aug 3, 2026

    @claude
    Contributor

    分诊:domain:engine,已入本会话车道视野,但本轮刻意不派 —— 串行依据:

    1. 修法落点(readonly 剥离路径 stripReadonlyFields 及其调用点)与在飞 Parent-scoped readonlyWhen is unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889(parent-scoped readonlyWhen 服务端强制,packages/objectql/src/validation/rule-validator.ts + update 管线)高概率同文件面,同批派发必撞。
    2. Parent-scoped readonlyWhen is unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889 落地后再派本单,并基于合并后的代码工作 —— 剥离路径本轮会被改动,issue 引用的片段届时可能已变。
    3. 本单还夹一个契约面问题:「服务端插件经 getService('data') 拿到的引擎默认无 context,与不可信客户端输入走同一条剥离路径」—— 期望里的 strict 开关 / dropped-fields 清单都是 API 形状变化。接手时如果修法走到这一步,按规程 needs_decision,不要顺手定契约。

    Blocked-by: objectstack-ai/objectstack#4889


    Generated by Claude Code

  2. claude commented on Aug 4, 2026

    @claude
    Contributor

    🔒 认领:PM 循环第 8 轮(恢复派发,bug-only 口径)
    会话:session_01NrmBxj8rK2uGCnh9aipjwX(账号 xuyushun441-sys)
    分支:claude/issue-4903-readonly-strip-signal
    Worktree:objectstack-issue-4903
    域:domain:engine
    文件面:packages/objectql/src/**(readonly 剥离路径与其结果面)及同目录测试;docs 相应页(非 releases)

    ⛔ 硬约束:spec/** 及生成物零改动;metadata-protocol/src/protocol.ts 零改动(借道 #5088 在飞占用);releases/ 零触碰。
    全局在飞不相交已核:#5088(protocol.ts,借道)、#5087(plugin-email)、#5089/#5090(#5040 执行器)、#5096/#5026(lint)、#5011,与本文件面均不交。


    Generated by Claude Code

  3. claude commented on Aug 4, 2026

    @claude
    Contributor

    ✅ 验收通过 —— PR #5123(draft,CI 跑完入队);strict 模式另立决策单

    对照实际 diff 复核(4 文件,+404/-2;⛔ spec / protocol.ts / releases 零触碰,逐一核实):

    1. 前提部分证伪,修法随之校准:机器可读信号早已存在(service-automation: update_record reports success when written fields are silently stripped — no observability for dropped writes (split from #3356) #3407 onFieldsDropped,单 id 与批量路径都触发,且实核了报告的调用形态确实可达 —— registerService 给的是进程内引擎,无 RPC 吞事件)。真缺口是可发现性:日志现在点名对象+字段、说清后果(「本次 update 将不带该列提交 —— 调用返回成功而列保持原值」)与两条处方(isSystem / onFieldsDropped);保持 warn 的理由写进代码(REST 边界的静默剥离是设计内,与服务端路径在该接缝不可区分 —— 这一点连同 ExecutionContext 缺 origin 标记一起报告了)。
    2. 裁定执行精确:方向 2(默认可信)零实现;hook-vs-plugin 不对称按裁定 5 钉 pin 且注明「不背书」,机制(suppliedKeys 是引擎入口的键快照,先于 hook)已实证并写进 docs;方向 3 落在 security.mdx §3。
    3. strict/reject 半边止步于 spec 是正确的:四个落点选项 A/B/C/D 两轴分析完整,推荐 B(挂 WriteObservabilityOptions,与 onFieldsDropped 同类 —— 进程内、客户端不可达),明确拒绝 D(实现而不声明 = PD#12 的反形)。已另立决策单交维护者。
    4. objectql 1820 全过,10 例 pin 套件覆盖回归/逃生口/双路径信号/日志契约/不对称机制。

    CI 绿后转 ready 入合并队列。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions