现象
POST /api/v1/batch(跨对象事务批,ADR-0034 / #1604)在事务里直接调 ql.insert / ql.update:
// packages/rest/src/rest-server.ts — /batch handler
if (op.action === 'create') {
out.push(await ql.insert(op.object, data, { context: trxCtx, onFieldsDropped }));
}
而单条创建走的是 protocol.createData,它在进引擎之前先跑一次 stripReadonlyForInsert:
// packages/metadata-protocol/src/protocol.ts — createData
const data = stripReadonlyForInsert(
this.engine.registry?.getObject(request.object),
request.data,
request.context,
);
这层入口剥离存在的原因是引擎的 INSERT 路径对静态 readonly 免疫(#3413:stripReadonlyFields 只在 update 分支跑)。也就是说:
POST /api/v1/data/:object → 非系统调用者塞 readonly 列 → 被剥离;
POST /api/v1/batch(action: 'create')→ 同一个列 → 原样落库。
同一个语义,两条写路径两个结果。
复现思路
对象 A 上声明一个 readonly: true 字段(例如 approval_status),以普通用户身份:
POST /api/v1/data/A {"approval_status":"approved", ...} → 该字段被丢弃,取默认值(并在 droppedFields 里报出来);
POST /api/v1/batch {"operations":[{"object":"A","action":"create","data":{"approval_status":"approved", ...}}]} → 该字段被写进去。
影响
readonly 在 #2948/#3043 之后是写路径约束,不只是渲染提示。批量创建这条路留着口子,意味着任何能调 /batch 的客户端都能给审计戳、来源字段、审批状态镜像等只读列赋任意值——而这类字段常常正是别处逻辑信赖的。Console 的主记录表单(master-detail)走的就是 /batch,所以这不是一条冷门路径。
建议方向
按 PD #12「修生产者、别在消费者兜底」:把 create 侧的入口剥离提到两条路共用的位置,而不是在 /batch 里再抄一遍 stripReadonlyForInsert。两种可行落点:
第二种更接近「一个契约一处执行」,但要先确认 #3413 当初豁免 insert 的理由(系统种子 / seed 写入路径)在下沉后仍走得通——那批调用是 isSystem,本来就在剥离的豁免名单里。
来源
在 #3794 排查中发现(修复 PR:#3834,该 PR 只给 /batch 接上了 onFieldsDropped 的写可观测性,没有动这里的剥离行为——按 AGENTS.md PD #10 单独立此 issue,不扩大原范围)。
现象
POST /api/v1/batch(跨对象事务批,ADR-0034 / #1604)在事务里直接调ql.insert/ql.update:而单条创建走的是
protocol.createData,它在进引擎之前先跑一次stripReadonlyForInsert:这层入口剥离存在的原因是引擎的 INSERT 路径对静态
readonly免疫(#3413:stripReadonlyFields只在 update 分支跑)。也就是说:POST /api/v1/data/:object→ 非系统调用者塞readonly列 → 被剥离;POST /api/v1/batch(action: 'create')→ 同一个列 → 原样落库。同一个语义,两条写路径两个结果。
复现思路
对象 A 上声明一个
readonly: true字段(例如approval_status),以普通用户身份:POST /api/v1/data/A {"approval_status":"approved", ...}→ 该字段被丢弃,取默认值(并在droppedFields里报出来);POST /api/v1/batch {"operations":[{"object":"A","action":"create","data":{"approval_status":"approved", ...}}]}→ 该字段被写进去。影响
readonly在 #2948/#3043 之后是写路径约束,不只是渲染提示。批量创建这条路留着口子,意味着任何能调/batch的客户端都能给审计戳、来源字段、审批状态镜像等只读列赋任意值——而这类字段常常正是别处逻辑信赖的。Console 的主记录表单(master-detail)走的就是/batch,所以这不是一条冷门路径。建议方向
按 PD #12「修生产者、别在消费者兜底」:把 create 侧的入口剥离提到两条路共用的位置,而不是在
/batch里再抄一遍stripReadonlyForInsert。两种可行落点:/batch的 create 分支走protocol.createData的同一层(而不是裸ql.insert);或readonly的 INSERT 剥离下沉进引擎(撤销 feat(automation): update_record/create_record 步骤对被静默剥离的写入字段挂 warning(#3407) #3413 的 insert 豁免),入口层那次剥离随之退化为冗余并可移除。第二种更接近「一个契约一处执行」,但要先确认 #3413 当初豁免 insert 的理由(系统种子 / seed 写入路径)在下沉后仍走得通——那批调用是
isSystem,本来就在剥离的豁免名单里。来源
在 #3794 排查中发现(修复 PR:#3834,该 PR 只给
/batch接上了onFieldsDropped的写可观测性,没有动这里的剥离行为——按 AGENTS.md PD #10 单独立此 issue,不扩大原范围)。