Skip to content

readonly: true 在 insert 路径上完全不生效,且 update 路径的剥离会连 hook 自己写的戳一起删掉 —— 已发布文章可落成 published_at = null #788

Description

@yinlianghui

发现于 #780(published_at 重新打戳)的实测过程。与 #780 的判据无关,是引擎写路径上 readonly 语义的两个相邻事实,越界,单独记录。#780 的修复不引入也不放大第 2 点(下有对照实测)。

事实(实测,pinned 17.0.0-rc.2,真 object schema + 真 hook + sys_fetch_previous_update 内建的复刻)

探针:真 ObjectQL.create + InMemoryDriver + bindHooksToEngine,crm_knowledge_article 真 schema(published_at 声明 readonly: true),非系统上下文 ql.createContext({ userId: 'u1' })。

1. insert 路径根本不剥离 readonly 字段

insert(非系统): { status: 'published', published_at: '2024-03-01T00:00:00.000Z', … }
=> 落库 published_at = "2024-03-01T00:00:00.000Z"
=> 无 "Field 'published_at' is read-only" 警告

对照 update 路径,同一个字段同一个值:

update(非系统): { id, status: 'published', published_at: '2024-03-01T00:00:00.000Z' }
=> WARN Field 'published_at' is read-only — ignoring incoming change (#2948)

源码侧一致:stripReadonlyFields 在 @objectstack/objectql 里只有两个调用点,都在 update 分支(单行与 multi 各一),insert 分支没有。所以 readonly: true 目前是「建后不可改」,不是「不可写」——任何有 create 权限的 API 调用方都能在建记录时给 published_at、last_reviewed_at、view_count、helpful_count、not_helpful_count 这类「系统计算」字段填任意值。crm_knowledge_article.enable.apiMethods 含 create,这条路今天就通。

这有可能是平台的有意语义(Salesforce 式 createable / updateable 二分),那样的话缺陷就不在引擎而在本仓的预期:17 个对象上所有 readonly: true 的字段注释与文档都是按「系统维护、用户不可填」写的。哪一种都需要定夺,不该靠默认。

2. update 路径的剥离会把 hook 自己写的值一起删掉

suppliedKeys 是在 hook 运行之前从调用方 payload 上快照的:

const suppliedKeys = new Set(Object.keys(opCtx.data ?? {}));
…
await this.triggerHooks("beforeUpdate", hookContext);
…
hookContext.input.data = stripReadonlyFields(updateSchema, preRo, suppliedKeys, …);

stripReadonlyFields 对每个 readonly 且落在 suppliedKeys 里的 key 做 delete result[name] —— 删的是当前值,而当前值可能已经被 hook 覆写过。于是「调用方回传了该 readonly 字段」这一件事,会连带把 hook 在同一次写里打的戳一起抹掉:

draft -> published,非系统 update,payload 里带 published_at:
=> 落库 status = "published", published_at = null, last_reviewed_at = 2026-08-05T…

一篇 status 为 published、published_at 为 null 的文章。all_articles 视图按 published_at desc 排序、published_articles 也读这个字段,这行的排位与展示都无定义。

可达性:需要一个在 update 时回传只读字段的客户端。Console 表单把 published_at 渲染成只读、不提交,所以今天的 UI 路径不触发;REST/集成调用方整记录回传是常见写法。据此判断这半条偏 observation-class,第 1 点则是今天就通的。

与 #780 的关系(对照实测,证明不是 #780 引入的)

同一个探针,分别跑在 #780 修复前后的 hook 上:

                                  修复前            修复后
archived -> re-published          戳被移到今天       保持原始日期   ← #780 修的
insert 带 published_at(非系统)    被覆写成今天       保留 2024-03-01
update 带 published_at(非系统)    published_at=null  published_at=null   ← 两侧相同

第 3 行两侧一致,所以第 2 点是既有行为,不是 #780 的修复引入的。第 2 行的差异是 #780 修复的正向结果(不再覆写调用方给的历史日期)—— 但它也让第 1 点的后果更实:修复前 hook 会把非法写入的 published_at 顺手盖掉,相当于偶然地掩盖了 insert 路径不设防这件事;修复后调用方给什么就存什么。这不是修复的缺陷(保留导入历史正是 #780 的裁定),而是把第 1 点从「被掩盖」变成「可见」,所以一并记在这里。

归属与建议

两点都是引擎写路径的行为,大概率需要 upstream 定夺:

  1. readonly 到底是「不可写」还是「建后不可改」—— 定了之后,要么 insert 路径补上剥离,要么本仓把这批字段的注释/文档改成后一种语义(并接受 create 时可被填任意值)。
  2. stripReadonlyFields 删的应当是「调用方给的那个值」,而不是「该 key 上的当前值」—— hook 在剥离之前写入的派生值不该被调用方的一个回传字段连坐。

本仓能先做的:把「已发布但无发布日期」这一行状态挡在校验规则里(而不是靠 hook 的戳),或者在文档里明确 readonly 的实际边界。两条都是行为决策,先定夺再动手。

复现

ObjectQL.create({ datasources: { default: new InMemoryDriver({ persistence: false }) }, objects: { crm_knowledge_article } }),bindHooksToEngine 绑上 sys_fetch_previous_update 的复刻(priority 5 / beforeUpdate / object *,真内建是 kernel 服务的 private registerAuditHooks() 装的,裸引擎拿不到)与真 knowledge_article.hook,然后用非系统上下文按上表三种写法各写一次,读回即见。探针脚本按越界规则未入库。

Activity

  1. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    CollaboratorAuthor

    PM 分诊(修复线):第 1 点被上游在案记录大半消解,第 2 点成立已镜像

    第 1 点(insert 不剥离 readonly)——上游已定夺并已修,探针量的是有意豁免的那条路径。 objectstack#3043(7/18 关闭,PR #3162)把 insert 面的剥离落在入口级:DataProtocol 的 create 汇聚点(createData/createManyData/batchData/cloneData),REST CRUD、GraphQL/MCP dispatcher、批量 import 全部经此,仅作用于作者自定义业务对象,剥离后回落 defaultValue;而可信内部写入方直接调 engine.insert,有意豁免(引擎级剥离在实测中误伤 better-auth/metadata-repo 等 40+ 核心调用点,故上游明确放弃)。本单探针用的是裸 ObjectQL.create + 引擎直写——正是豁免路径。因此:

    • 「任何有 create 权限的 API 调用方今天就能填任意值」对普通 readonly: true 字段不成立:REST POST 里的 published_at 在入口就被剥掉、回落默认值;
    • 该结论的一个真实例外是 type:'autonumber'(入口剥离不当它是 readonly)——已由验收线在 objectstack#5503 记录,不重复立单;
    • hotcrm 侧无行动项:17 个对象的「系统维护、用户不可填」注释对外部 API 路径仍然成立;直连引擎的本仓写入方(seeds/scripts)本就走 system 上下文。

    第 2 点(update 剥离连坐删 hook 写的值)——成立,REST 可达,已镜像 objectstack#5591。查重确认与 #4903(插件身份判定,已关)、#3407(剥离无可观测性,已关)均不同族:本单是时序缺陷——suppliedKeys 在 hook 前快照、剥离在 hook 后执行、删的是当前值,于是 hook 派生写的存亡取决于调用方 payload 里有没有碰巧出现同名键。镜像里已给出三个可选修法方向与「hook 写新 key 能落库 vs 覆写回传 key 被连坐」的对照论证。

    处置:finding + upstream:objectstack 持有,不阻塞本仓任何在途工作。可选的本仓加固(校验规则拦「published 且 published_at 为空」)与 #779 的批量路径语义纠缠,一并等上游答复(#5574/#5591)后再定,不单独派。


    Generated by Claude Code

  2. yinlianghui commented on Aug 12, 2026

    @yinlianghui
    CollaboratorAuthor

    PM re-scope: this card is now HALF fixed upstream. Point 2 is gone; point 1 is still live. Staying finding, but the scope line has moved and a dev picking this up against the body as written would chase a bug that no longer exists.

    Source: the #1106 finding-sweep re-measured this card against rc.6 (it was originally measured on rc.2, and the repo moved to @objectstack/spec@17.0.0-rc.6 via #1066).

    the card's two points status on rc.6 evidence from the sweep
    1. readonly: true does not take effect on the insert path ⚠️ still live Probe with a non-system context inserted published_at: "2024-03-01T00:00:00.000Z" on crm_knowledge_article (field is readonly: true) and it stored as given, no warning. Both stripReadonlyFields call sites in rc.6 are still on the update branch.
    2. the update path's stripping deletes a hook's own stamp ✅ fixed upstream stripReadonlyFields now guards with if (!Object.is(result[name], supplied[name])) continue; and takes suppliedValues (values, not keys), so a hook-written stamp is no longer deleted by the caller echoing the field.

    ⇒ Scope from here is the insert path only. The card's headline consequence — "已发布文章可落成 published_at = null" — needs re-deriving against point 1 alone before anyone quotes it as the impact; it was argued from both halves together.

    ⛔ Not closing and not promoting. This stays a finding: point 1 is a platform-side defect (upstream:objectstack), so the actionable output here is an upstream report, not a hotcrm fix — and this repo does not work around platform defects. Whoever picks it up should re-run the insert probe first: it is cheap, and it is the only part of this card that has been measured on the current platform pin.

    Refs #1106 (the sweep) · #1066 (the rc.6 bump)


    Generated by Claude Code

  3. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Scope-correction check (from #1151, group 2): already applied — no action taken

    #1151 dispatched a scope-correction comment onto this card, on the premise that the #1106 sweep's re-scope recommendation for #788 was "an unapplied output of the same table" that "was never executed".

    That premise is false. The re-scope is already on this card, posted 2026-08-12T01:50:57Z:

    #788 (comment)

    It records exactly what was asked for, and in more detail: the two-row rc.6 table, "Point 2 is gone; point 1 is still live", the stripReadonlyFields guard (if (!Object.is(result[name], supplied[name])) continue; taking suppliedValues rather than keys) as the evidence for point 2 being fixed upstream, the still-live insert-path probe for point 1, and the instruction that the card's headline consequence needs re-deriving against point 1 alone.

    No duplicate scope-correction has been posted, because posting a second copy of a correction that already stands would add nothing to this card and would misrepresent the record. This note exists only so the verification is traceable from #1151.

    Re-read and confirmed still accurate as of today:

    ⛔ Per #1151's guard, nothing on this card was changed: body untouched, labels untouched, still open.


    Generated by Claude Code


    Generated by Claude Code

  4. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Transfer sweep (#1156, part 1) — outcome 3: closing. Point 2 is fixed upstream; point 1's attribution does not survive checking.

    ⚠️ This contradicts the disposition this card was handed to the sweep with. #1156 briefed #788 as "a two-part card and only one part is live … point 1 (readonly ignored on the insert path) is what remains. Transfer only what is actually still live." Checked rather than inherited, point 1 is not a live platform defect, so there is nothing to transfer. The card's own instruction to treat every inherited claim as unproven is what produced this result.

    The outcome test that was applied

    Where would the fix land today? Answered per point, against the shipped platform source and the upstream rulings — not against the label.

    Point 2 — fixed upstream. Uncontested.

    stripReadonlyFields deleting a hook's own stamp: mirrored as objectstack#5591, now closed / completed via merged PR objectstack#6343 ("update 剥离作用于调用方提交的值,不再连坐抹掉 beforeUpdate hook 的写入"). Verified upstream directly. The rc.6 re-measurement on this card found the guard in place — stripReadonlyFields now takes suppliedValues and skips a key whose current value differs from what the caller supplied. A hook-written stamp is no longer collateral.

    Point 1 — the behaviour reproduces; the defect does not

    The card raised this possibility itself and asked for a ruling rather than assuming one: 「这有可能是平台的有意语义(Salesforce 式 createable / updateable 二分)…哪一种都需要定夺」. It is the intended semantics, and the ruling already exists.

    objectstack#3043 asked exactly this question — "静态 readonly 的 INSERT 豁免让审批/状态字段可在创建时被直接播种" — and closed on 2026-07-18 via merged PR objectstack#3162. Its resolution:

    • The insert-side exemption was tightened, but the strip was placed at the DataProtocol ingress (createData / createManyData / batchData / cloneData) — the seam every external REST / GraphQL / MCP / bulk-import create funnels through.
    • Engine-level stripping was tried first and deliberately abandoned: roughly 40+ core framework writers (the better-auth adapter, metadata-repo, …) legitimately seed readonly columns through engine.insert under non-system contexts, and stripping there broke dev-admin login and metadata event logging outright.
    • Stripped fields fall back to defaultValue; isSystem is exempt; author-custom business objects only — platform (sys_ / managedBy) objects pass through to their own dedicated guards, which reject rather than silently strip.

    Verified in current main, not taken from the closing note. packages/metadata-protocol/src/protocol.readonly-insert.test.ts pins this behaviour today: a non-system caller forging approval_status: 'approved' has it stripped before engine.insert; a system-context caller may seed it; per-row stripping holds for createManyData and batchData; platform objects are passed through to their own guards. The file header states the boundary in as many words — "trusted internal writers call engine.insert directly and are unaffected." The surface has since been hardened further (#6640): a non-system INSERT that requests the preserveAudit exemption is now stripped and warned by name, rather than silently.

    Both probes on this card measured the exempt path. The original repro drives ObjectQL.create + InMemoryDriver + bindHooksToEngine and writes through engine.insert; the #1106 rc.6 re-measurement did the same. That is precisely the path upstream chose to exempt, for stated reasons. And the rc.6 observation that "both stripReadonlyFields call sites are still on the update branch" is true and entirely consistent — that function lives in @objectstack/objectql, while the insert-side strip lives one layer up in metadata-protocol. It was never evidence of a live defect.

    ⇒ The card's stated impact is false on the path that carries it. crm_knowledge_article is an author-custom business object with enable.apiMethods including create, so it sits squarely inside the strip's scope: a REST POST carrying published_at, view_count, helpful_count or last_reviewed_at has those keys removed at ingress and re-derived from defaultValue. "任何有 create 权限的 API 调用方都能在建记录时给这类字段填任意值" does not hold. The repo's 17 objects' "system-maintained, user cannot fill" comments remain accurate for every external API path.

    The one genuine exception upstream recorded — that the ingress strip did not treat type:'autonumber' as readonly — was filed and fixed separately as objectstack#5503 → merged PR objectstack#5627. It is not this card's residue.

    The account-book failure worth naming

    Both readings were already on this card and were never reconciled. The 2026-08-05 PM triage got this right in detail — "本单探针用的是裸 ObjectQL.create + 引擎直写——正是豁免路径" — and concluded 「hotcrm 侧无行动项」. The 2026-08-12 re-scope then re-measured on rc.6 with the same engine-direct probe, reported point 1 "still live", and did not engage the earlier triage's argument. Everything downstream — the upstream:objectstack label, the 2026-08-14 scope-correction check, and #1156's briefing of this card — inherited the later reading. The re-measurement was honest and its observation was correct; what it did not carry forward was that the observed path is exempt by design.

    Actions taken on this card

    • upstream:objectstack — removed. This is the specific claim being corrected: there is no open platform need here. Point 2 was a real platform defect and is fixed; point 1 is ruled intended behaviour.
    • finding — kept. The observation is genuine and worth keeping findable.
    • Closed as completed. Nothing to transfer, and nothing to fix in this repo.

    What was NOT measured — stated plainly, because it is the one thing that would change this

    No live REST POST on 17.0.0 GA. The reading above rests on the shipped platform source, its pinned tests and two upstream rulings — not on a booted server. This sweep is routing and bookkeeping only: no app boot, no probe scaffolding, no code.

    The reopen condition, made concrete: if anyone measures a non-system POST /api/v1/data/crm_knowledge_article on GA carrying published_at and finds it stored as given, then the DataProtocol ingress strip has regressed or its object-classification no longer covers this app's objects. That is a new upstream defect against a guard that is supposed to exist — file it fresh against objectstack, link objectstack#3043 and this card, and quote the response. It is a five-minute check and it is the only thing standing between this reading and certainty.

    Authority

    hotcrm 席位的原则是用平台的能力做元数据应用的开发,平台的需求应该转给平台

    The companion disposition 「关掉,只留指向镜像的指针」 is satisfied by the pointers above: objectstack#5591 / PR#6343 for point 2, and objectstack#3043 / PR#3162 (plus objectstack#5503) for the ruling that disposes of point 1.


    Generated by Claude Code


    Generated by Claude Code

  5. removed
    upstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstream
    on Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions