fix(lint): report a non-system create_record / ctx.api insert of a static-readonly field — the create-verb scan gap on the flow and hook readonly rules - #16248
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
…he flow and hook readonly rules Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
…hich the engine's insert strip does not judge Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
📓 Docs Drift CheckThis PR changes 1 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d75d9f3b373e07a81c11113de600e878ee9a0bf7 && git checkout d75d9f3b373e07a81c11113de600e878ee9a0bf7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 618f70d74d934c09fa58f1eaa8493e95e4f847e9 9b76e9a495a4e9c3d83d604371fb724b98a05f68 && git checkout -B drift-repro 618f70d74d934c09fa58f1eaa8493e95e4f847e9 && git merge --no-ff 9b76e9a495a4e9c3d83d604371fb724b98a05f68
node scripts/docs-audit/affected-docs.mjs --json 618f70d74d934c09fa58f1eaa8493e95e4f847e9
|
Fixes #15394
Summary
flow-update-readonly-fieldandhook-api-update-readonly-fieldnow report the staticreadonlyshape on the create verb — a flowcreate_recordnode, and an L2 hook body'sctx.api.object('LIT').insert()— at the sameerrorseverity the static shape carries on update, under the samerunAs: 'system'exemption. Both rules had scanned only the update verb, justified by the premise that INSERT is engine-exempt from the author-declaredreadonlystrip; the maintainer's 2026-09-03 option-C ruling on #14147 (landed in #15395) made that false —engine.insertruns the sameisSystem-gatedstripReadonlyFields(packages/objectql/src/engine.tsinsert path,staticReadonlyInsertSubject) — so a non-system create of areadonlycolumn became a silent no-op that nothing reported at build time.Clause-②: yes — two published
os lintrules start reporting a new error-severity finding, narrowing whatos lint/os validate/os buildaccept. Labelneeds:contract-reviewapplied.What now fails that passed before (measured blast radius)
Exactly one new shape per rule, at
error:create_recordnode whose literalfieldsmap writes a field the target object declaresreadonly: true, on a flow withoutrunAs: 'system';ctx.api.object('LIT').insert({ … })writing such a field, on a hook withoutrunAs: 'system'.Blast radius over this repository at
origin/main159dbad(git grep create_recordoverexamples/**andpackages/**, non-test, crossed with everyreadonly: truefield declaration underexamples/**; hook bodies grepped forctx.api.object(...).insert|create():examples/app-showcase/src/automation/flows/index.ts:1407showcase_inbound_task_webhook › create_taskshowcase_tasksystemexamples/app-todo/src/flows/task.flow.ts:236task_completion › create_next_tasktodo_taskcompleted_dateexamples/app-todo/src/flows/task.flow.ts:317quick_add_task › create_tasktodo_taskcompleted_datectx.api…insert()/.create()inexamples/**orpackages/**(non-test)app-crm(crm_opportunity.approval_statusreadonly) declares nocreate_record;app-showcasecontact.lead_scoreis readonly but nothing creates a contact from a flow or hook. Zero in-repo sites go red; no example file was touched. Out-of-repo consumers (e.g.hotcrm) learn from the changeset, which carries the migration (declarerunAs: 'system'when seeding the column is the intent, otherwise drop the key or stamp it in abeforeInserthook).Rulings executed and mechanism assumptions verified
engine.tsstill reads "INSERT stays exempt" for thereadonlyWhenstrip (the insert-path note and the bulkstripReadonlyWhenFieldssite), so no conditional finding is produced on a create, on either surface; pinned green with an update-verb control in both test files.READONLY_ACTION_INSERT_SILENCEexists onorigin/main(validate-readonly-action-writes.ts:182, pinned at its test:304).CreateRecordConfigSchemainpackages/spec/src/automation/builtin-node-config.zod.tsanchors onobjectNamewithfields: record, the same keys as update; theflow-node-crud-object-aliasconversion covers the whole CRUD quartet (packages/spec/src/conversions/registry.ts:920), so the existingreadLiteralObjectName(canonical + alias) is the right read. Verified — no new anchor code.ObjectRepositorycontract (packages/spec/src/contracts/scoped-context.ts:154) declaresinsertonly; the hostObjectRepository(packages/objectql/src/engine.ts) aliasescreate()toinsert(); but this rule reads L2 bodies, which run in QuickJS, andinstallCtx(packages/runtime/src/sandbox/quickjs-runner.ts:556) installsinsert / update / delete / updateMany / deleteMany / upsert— nocreateleaf. A body's.create()is thereforeTypeError: not a functionon its first run: loud, not silent. So the hook rule widens toinsertonly;createis recorded as a reasoned method exclusion (READONLY_HOOK_METHOD_EXCLUSIONS) and pinned, and the partition against the extractor's declared verb list is pinned too. The card'sinsert / .createshorthand was hedged for a reason.create-record-readonly-drop.test.ts: the row is created without the column, the step reports success, a run-time warning names the field.pnpm --filter '...@objectstack/lint' typecheck— the downstream direction (see Verification).git log origin/main --since=2026-09-04 -- packages/lint/src/validate-readonly-*→b398ad25(fix(objectql,metadata-protocol): a staticreadonlyfield is stripped from a non-system INSERT insideengine.insert, and the boundary copy is deleted #15395, rewrote both headers) and7dafaaed(fix(lint): no authoring rule throws on a non-record entry of any stack collection #15751); worktree cut from159dbad, after both.Design notes
readonlyfield that the same strip removes), so the existing ids carry the create verb and the message names the verb it was judged on. A second id would only split one finding's suppression, docs and counts.runAs: 'system'acreate_recordwriting a field declaring BOTHreadonlyandreadonlyWhenis clean (no static strip by elevation, no conditional strip on INSERT), where the sameupdate_recordstill draws the conditional warning — pinned, with the update control.idin aninsertpayload is not the row address (The read-only strip still logs a WARN calling the addressed row's ownida forged caller write, on every single-record update of a platform object #8141 applies to the payload-addressedupdateonly) — pinned.Surface
Declared:
packages/lint/src/validate-readonly-flow-writes.ts,validate-readonly-hook-writes.ts, their tests,validate-flow-node-writes.test.ts(comment of the GREEN control only), one changeset (@objectstack/lint: minor, following the precedent ofdecision-predicate-envelope-refused/expression-source-non-string-refusedfor a new build-time refusal).Declared increment:
content/docs/automation/hook-bodies.mdx(two sentences at the rule bullet and the scope paragraph). The paragraph literally said "nothing reports it at build time yet (#15394)"; merging a PR that closes #15394 while that sentence stands would leave the published docs asserting a gap that no longer exists, so the two sentences are updated in the same landing. No other doc names the create-side gap (content/docs/data-modeling/fields.mdx:319already states the engine behaviour correctly).维护者速读(草稿)
改了什么。
os lint的两条 readonly 规则(flow 的flow-update-readonly-field、hook 的hook-api-update-readonly-field)现在也检查"创建"动作:非runAs: 'system'的 flowcreate_record节点、或 hook body 里的ctx.api.object('…').insert(),若写入对象声明为readonly: true的字段,按 error 报出,与 update 同级。仅报静态readonly;readonlyWhen在创建时不报(引擎在 INSERT 上不跑条件剥离)。hook 侧只覆盖.insert(),不覆盖.create():沙箱里没有create这个方法,调用会直接抛错,不是静默丢失。为什么改。 2026-09-03 的 option C 裁决(#14147)让
engine.insert也跑readonly剥离后,非系统身份创建记录时写入的 readonly 字段会被静默丢弃、步骤仍报成功;两条规则的注释已改口称之为"扫描缺口",但规则本身没补。本 PR 把缺口补上,让作者在构建期而不是运行日志里发现问题。风险与代价(含回滚)。 收紧了
os lint/os build的接受面:以前通过的一类写法现在报 error。仓内三个示例应用实测零命中(表见上文),外部消费方(如 hotcrm)可能有需要处理的站点——修法是声明runAs: 'system'(确实要由自动化写入时)、删掉该字段、或改用目标对象自己的beforeInsert钩子打戳。回滚即 revert 本 PR,规则退回只查 update。席位意见。 (留空,待席位定稿)
你要做的。 确认"创建动作的静态 readonly 写入按 error 报"这一收紧方向,以及 hook 侧只覆盖
.insert()不覆盖.create()的取舍;确认后由队列合并(非受管面)。Verification
All at final head
9b76e9a49(branchclaude/issue-15394-readonly-create-scan-gap, baseorigin/main159dbad), in the issue worktree; every build/test throughscripts/pm/os-verify-lock.sh, exit codes captured before any pipe.pnpm --filter '@objectstack/lint^...' build—VERDICT command-exit 0.pnpm --filter @objectstack/lint build—DTS Build success,VERDICT command-exit 0.pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-readonly-flow-writes.test.ts src/validate-readonly-hook-writes.test.ts src/validate-flow-node-writes.test.ts—Test Files 3 passed (3),Tests 106 passed.pnpm --filter @objectstack/lint test—Test Files 98 passed (98),Tests 3375 passed (3375).pnpm --filter @objectstack/lint typecheck— exit 0 (tsc --noEmit+check:test-typecheck: OK).^build):pnpm exec turbo run typecheck --filter='...@objectstack/lint' --concurrency=2—Tasks: 118 successful, 118 total,VERDICT command-exit 0.packages/cliunit tier (direct consumer):pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2—Test Files 179 passed (179),Tests 2406 passed | 6 expected fail (2412). The integration tier is declared to CI (no diff on it).READONLY_FLOW_WRITE_NODE_TYPES→[UPDATE_NODE_TYPE],READONLY_HOOK_STRIP_SUBJECT_METHODS→['update','updateById']); on-disk blob hashes changed (213191a1→236f5f31,c3ec36f0→f8a90e0b) with the injected marker counted 1 and the removed anchor 0 per file; the two flipped test files went red on exactly the 11 create/insert cases (Tests 11 failed | 63 passed (74)); restore bygit checkout HEAD -- PATH, proven bygit diff HEADempty,git status --porcelainempty and on-disk hashes equal to the HEAD blobs. The tests import the rules relatively fromsrc, so no dist is on the ablation path.node scripts/pm/dispatch-gates.mjs --changed --commandsderived 81 families at33ca99a5(list byte-identical when re-derived at9b76e9a49); all 81 run,--ranreconciles81 derived famil(ies) accounted for — 81 run, 0 NOT-MEASURED. One real red found and fixed in-flight:check:doc-authoring(tracker id#14147inside the new runtime message string — moved to the adjacent comment; green on rerun, 829 pinned sites, no growth). Reran green on the final tree:check:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:doc-authoring,check:docs-transcript-drift,check:dts-closure(66 packages swept, 157/157),check-undeclared-dep-imports,check-affected-docs,check-drift-comment, lint'scheck:doc-formula-expressions/check:doc-security-posture, spec'scheck:skill-examples(after building client-react),check-plugin-teardown-shape --self-test(after deepening the clone to its pinned commit).check-adr-0087-registration --base origin/main: "adds no declared-breaking changeset (1 non-breaking changeset(s) seen)" — no disposition marker owed.check-changeset-no-major/check-empty-changeset: green.check:dual-build-cjs-loadsandcheck:type-check-debtexit 3PREREQUISITE NOT MET— both read every package's built output and this container did not build the whole repo (84 packages without dist); the diff touches none of the packages they ledger.eslint(repo scan is CI's):eslint.config.mjs:328states noparserOptions.project/ typed rules, so the diff cannot move any untouched file's verdict;pnpm exec eslint --no-inline-config --format jsonover the 5 changed.tsfiles at9b76e9a49— 5 files, 0 errors, 0 warnings;.changeset/*.mdandcontent/docs/**/*.mdxare outside eslint's population ("File ignored because no matching configuration").content/docs/automation/hook-bodies.mdx):check:docs-transcript-drift(4 declared values equal the registry),check-doc-frontmatter,check-doc-route-spelling,check-docs-section-name,check:doc-anchors,check:docs-single-h1all green.git log origin/main --since=2026-09-04 -- packages/lint/src/validate-readonly-*→b398ad25(fix(objectql,metadata-protocol): a staticreadonlyfield is stripped from a non-system INSERT insideengine.insert, and the boundary copy is deleted #15395),7dafaaed(fix(lint): no authoring rule throws on a non-record entry of any stack collection #15751); base159dbadpostdates both.mcp_calls: 0 — every GitHub read and write went through repo-scoped REST.🤖 Generated with Claude Code
Generated by Claude Code