Repository navigation
readonly: true 在 insert 路径上完全不生效,且 update 路径的剥离会连 hook 自己写的戳一起删掉 —— 已发布文章可落成 published_at = null #788
Description
Activity
- addedupstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstreamBlocked on / caused by the ObjectStack platform — tracked upstream
on Aug 5, 2026 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
- 「任何有 create 权限的 API 调用方今天就能填任意值」对普通
- added a commit that references this issue
on Aug 10, 2026 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.6via #1066).the card's two points status on rc.6 evidence from the sweep 1. readonly: truedoes not take effect on the insert path⚠️ still liveProbe with a non-system context inserted published_at: "2024-03-01T00:00:00.000Z"oncrm_knowledge_article(field isreadonly: true) and it stored as given, no warning. BothstripReadonlyFieldscall sites in rc.6 are still on the update branch.2. the update path's stripping deletes a hook's own stamp ✅ fixed upstream stripReadonlyFieldsnow guards withif (!Object.is(result[name], supplied[name])) continue;and takessuppliedValues(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
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: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
stripReadonlyFieldsguard (if (!Object.is(result[name], supplied[name])) continue;takingsuppliedValuesrather 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:
- Scope from here is the insert path only. Point 2 (the update path's stripping deleting a hook's own stamp) is fixed upstream.
- The card remains
finding+upstream:objectstack, open, not promoted. - The insert-path probe is the only part measured on a recent platform pin, and should be re-run first by whoever picks this up — noting the repo has since moved from rc.6 to 17.0.0 GA (chore(deps): upgrade @objectstack/* 17.0.0-rc.6 → 17.0.0 GA (maintainer order), sweep the new formula-filter refusal, and run the full verification farm #1146 / PR chore(deps)!: upgrade @objectstack/* 17.0.0-rc.6 → 17.0.0 GA, absorb four behaviour changes, sweep the formula-filter refusal #1147), so that re-run is now a GA measurement.
⛔ Per #1151's guard, nothing on this card was changed: body untouched, labels untouched, still open.
Generated by Claude Code
Generated by Claude Code
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.
stripReadonlyFieldsdeleting 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 —stripReadonlyFieldsnow takessuppliedValuesand 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
DataProtocolingress (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.insertunder non-system contexts, and stripping there broke dev-admin login and metadata event logging outright. - Stripped fields fall back to
defaultValue;isSystemis 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.tspins this behaviour today: a non-system caller forgingapproval_status: 'approved'has it stripped beforeengine.insert; a system-context caller may seed it; per-row stripping holds forcreateManyDataandbatchData; 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 thepreserveAuditexemption 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+bindHooksToEngineand writes throughengine.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 "bothstripReadonlyFieldscall 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 inmetadata-protocol. It was never evidence of a live defect.⇒ The card's stated impact is false on the path that carries it.
crm_knowledge_articleis an author-custom business object withenable.apiMethodsincludingcreate, so it sits squarely inside the strip's scope: a RESTPOSTcarryingpublished_at,view_count,helpful_countorlast_reviewed_athas those keys removed at ingress and re-derived fromdefaultValue. "任何有 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 — theupstream:objectstacklabel, 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
POSTon 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_articleon GA carryingpublished_atand finds it stored as given, then theDataProtocolingress 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
- [Transfer sweep] Move all 22 platform-defect cards to the repo the fix lands in, and close them here with a pointer #1156 (transfer sweep), part 1 — outcome 3: "do not file an upstream card for a defect that is gone." Point 2's defect is fixed; point 1's was never one. Filing either upstream would hand objectstack a question objectstack#3043 already answered.
- Maintainer ruling, 2026-08-14 — verbatim, untranslated:
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
- The insert-side exemption was tightened, but the strip was placed at the
- removedupstream:objectstackBlocked on / caused by the ObjectStack platform — tracked upstreamBlocked on / caused by the ObjectStack platform — tracked upstream
on Aug 14, 2026
发现于 #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 字段
对照 update 路径,同一个字段同一个值:
源码侧一致:
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 上快照的:stripReadonlyFields对每个readonly且落在suppliedKeys里的 key 做delete result[name]—— 删的是当前值,而当前值可能已经被 hook 覆写过。于是「调用方回传了该 readonly 字段」这一件事,会连带把 hook 在同一次写里打的戳一起抹掉:一篇 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 上:
第 3 行两侧一致,所以第 2 点是既有行为,不是 #780 的修复引入的。第 2 行的差异是 #780 修复的正向结果(不再覆写调用方给的历史日期)—— 但它也让第 1 点的后果更实:修复前 hook 会把非法写入的
published_at顺手盖掉,相当于偶然地掩盖了 insert 路径不设防这件事;修复后调用方给什么就存什么。这不是修复的缺陷(保留导入历史正是 #780 的裁定),而是把第 1 点从「被掩盖」变成「可见」,所以一并记在这里。归属与建议
两点都是引擎写路径的行为,大概率需要 upstream 定夺:
readonly到底是「不可写」还是「建后不可改」—— 定了之后,要么 insert 路径补上剥离,要么本仓把这批字段的注释/文档改成后一种语义(并接受 create 时可被填任意值)。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 服务的 privateregisterAuditHooks()装的,裸引擎拿不到)与真knowledge_article.hook,然后用非系统上下文按上表三种写法各写一次,读回即见。探针脚本按越界规则未入库。