## 现象 服务端插件(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`): ```js 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
claude commentedon Aug 3, 2026
分诊:
domain:engine,已入本会话车道视野,但本轮刻意不派 —— 串行依据:stripReadonlyFields及其调用点)与在飞 Parent-scopedreadonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889(parent-scopedreadonlyWhen服务端强制,packages/objectql/src/validation/rule-validator.ts+ update 管线)高概率同文件面,同批派发必撞。readonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889 落地后再派本单,并基于合并后的代码工作 —— 剥离路径本轮会被改动,issue 引用的片段届时可能已变。getService('data')拿到的引擎默认无 context,与不可信客户端输入走同一条剥离路径」—— 期望里的 strict 开关 / dropped-fields 清单都是 API 形状变化。接手时如果修法走到这一步,按规程 needs_decision,不要顺手定契约。Blocked-by: objectstack-ai/objectstack#4889Generated by Claude Code
claude commentedon Aug 4, 2026
🔒 认领:PM 循环第 8 轮(恢复派发,bug-only 口径)
会话:
session_01NrmBxj8rK2uGCnh9aipjwX(账号xuyushun441-sys)分支:
claude/issue-4903-readonly-strip-signalWorktree:
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
claude commentedon Aug 4, 2026
✅ 验收通过 —— PR #5123(draft,CI 跑完入队);strict 模式另立决策单
对照实际 diff 复核(4 文件,+404/-2;⛔ spec / protocol.ts / releases 零触碰,逐一核实):
successwhen written fields are silently stripped — no observability for dropped writes (split from #3356) #3407onFieldsDropped,单 id 与批量路径都触发,且实核了报告的调用形态确实可达 —— registerService 给的是进程内引擎,无 RPC 吞事件)。真缺口是可发现性:日志现在点名对象+字段、说清后果(「本次 update 将不带该列提交 —— 调用返回成功而列保持原值」)与两条处方(isSystem/onFieldsDropped);保持 warn 的理由写进代码(REST 边界的静默剥离是设计内,与服务端路径在该接缝不可区分 —— 这一点连同 ExecutionContext 缺 origin 标记一起报告了)。CI 绿后转 ready 入合并队列。
Generated by Claude Code
fix(objectql): make the static-readonly write strip state its consequ…