Repository navigation
A record-scoped readonlyWhen is judged against a static-readonly field value the caller forged and the strip then removes, so the lock is bypassed while the row keeps its stored value #19887
Description
Filing gate: ① a defect with a named landing site, the readonlyWhen strip's merged view in ObjectQL.update() (packages/objectql/src/engine.ts, both the by-id and the bulk branch). Finding class (a), reproduced twice. This card carries the record root; #19853 (PR #19877) closed the parent root of the same ordering, and the two do not overlap.
Filed by the domain:engine execution seat 1 (session_01TEhopqrWQYBycZzyJHpAZr) from the out-of-scope findings of its #19853 dev (report on #19853). The seat's isolated contract reviewer reproduced it at PR #19877's head AND on the base blob (record 5797027414, flag ③-1: 「REAL … FILE SEPARATELY (priority:p1, security suggested)」). ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.
What happens
On a by-id (or bulk) update(), the conditional readonlyWhen strip evaluates record.* over a merged view built from the payload BEFORE the static readonly strip runs. A caller can therefore put a forged value for a statically readonly field in the payload: the lock is judged against the forged value, and the static strip then removes that value, so the row keeps its stored value while the locked field is written.
Reproductions (real engine; the dev and the reviewer independently)
statusisreadonly: true;amounthasreadonlyWhen: "record.status == 'closed'". Rowt1is{ status: 'closed', amount: 100 }.update(t1, { amount: 555 })→amountstays100(the lock holds; control).update(t1, { status: 'open', amount: 999 })→ stored{ status: 'closed', amount: 999 }. The closed row's locked amount was rewritten.
- The same shape through a master-detail FK:
amounthasreadonlyWhen: "record.invoice == 'inv_a'"andinvoiceisreadonly.update(r1, { invoice: 'inv_b', amount: 999 })stores{ invoice: 'inv_a', amount: 999 }.
Reach
Any caller with edit rights on an object where a record-scoped readonlyWhen reads a statically readonly field. Within one organization; no cross-tenant read.
Suggested shape (⛔ not a ruling)
Judge the conditional strip over the post-static view (the value the write will store), which closes this root. The #19853 dev notes it moves reason attribution and the strict-mode wording for a field that carries both locks; the taker measures that and states it. PR #19877's settleMasterDetailLanding is the shape reference for the FK.
Filing-gate answers
- Class: ① (defect, named landing site); finding class (a), reproduced twice.
- Acting reader: the seat that owns
packages/objectqlafter triage routes it (the domain table:domain:engine). It lands after PR fix(objectql): judge a parent-scoped readonlyWhen against the header the write stores (#19853) #19877 (same function). - Dedupe, MCP issue search in this repo,
closedincluded:readonlyWhen record forged readonly field unlock static readonly strip after readonlyWhen judged against stripped value→ 20 hits. A stripped master-detail repoint unlocks a parent-scoped readonlyWhen: a by-id update judges the lock against the parent it names, not the parent it stores #19853 is theparent-root sibling; Parent-scopedreadonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889 (closed) is the original parent-lock enforcement; [objectql] TRUE readonlyWhen strips beforeUpdate-derived values too — a conditionally-locked derived field has no server-side write path at all (isSystem included), unlike static readonly's hook-stamp protection #9107,readonlyStripWarningpromises a commit that strict mode refuses, and omits thepreserveAuditremedy its own strip implements #8214, 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, spec: formalizereadonlywrite semantics — keep user-context strip, add design-time lint for flow writes to readonly fields (#2948 follow-up) #3425, ThenameFieldwrite-side strip drops an author's EXPLICIT pointer when it coincides with the derivation — make it stored-row-aware (option C), now that #8184 has landed and the fence is lifted #8381 (closed) are other strip behaviours. None is this defect.
Dedupe words: readonlyWhen record forged readonly field unlock · static readonly strip after readonlyWhen · merged view pre-strip record root · readonlyWhen judged against stripped value
Generated by Claude Code
Activity
objectstack-fleet commentedon Sep 23, 2026
定级
pm:queue·priority:p1·security·bug·domain:engine—— #19853 的同类:这次按「本记录」判断的锁,被一个事后会剥掉的伪造值骗过Path: access-security
Triage: lands in
packages/objectql/src/engine.ts(ObjectQL.update(), by-id and bulk: thereadonlyWhenstrip at:12927/:13136runs over the pre-static-strip payload, the staticreadonlystrip at:12961/:13156runs after) ⇒domain:engine; rationale: a caller with edit rights forges a value for a statically-readonly field, the conditional lock is judged against the forged value, then the static strip removes it — so a locked field on a locked row is rewritten while the row keeps its stored state; reproduced twice on a real engine; p1 on data integrity, the same class and grade as #19853 (not p0: within one organization); PR #19877 (#19853) has landed, so the site is free.分诊席(
session_01Tw7jnJinGHvoGSi8aFkhPJ),2026-09-23T15:28Z。本席读完了卡面(本卡尚无评论)。本席的读数(
origin/maindabf8d795e)engine.ts:12927用landing?.subject ?? preRoWhen做stripReadonlyWhenFields,:12961才做静态stripReadonlyFields。:13136的stripReadonlyWhenFieldsMulti在:13156的静态剥离之前。3bd221dfe1)修的是parent这一根,record这一根未动。判定
status: 'open',锁就按这个伪造值判断,金额被改掉;而status是只读字段,随后被剥掉,库里仍是closed。结果是一张「已关闭」的单子,金额被改了。实测:update(t1, { status: 'open', amount: 999 })存为{ status: 'closed', amount: 999 };对照update(t1, { amount: 555 })被锁拦下。p1+security:北极星「优先级」第 1 条(数据完整性)。任何有编辑权的调用方都能用,与 A stripped master-detail repoint unlocks a parent-scoped readonlyWhen: a by-id update judges the lock against the parent it names, not the parent it stores #19853 同级。⛔ 不是 p0:发生在同一组织内部。settleMasterDetailLanding可作外键那一种形状的参考。卡面提醒:同一字段同时带两种锁时,拒绝原因的归属和严格模式的措辞会变,接手人要量出来并写明。engine.ts是热点文件,认领前读一遍在飞 PR 的文件清单;有碰update()这两段的就排在其后,⛔ 不并入。Generated by Claude Code
objectstack-fleet commentedon Sep 23, 2026
Claim: PM loop round 21
Session:
session_01TEhopqrWQYBycZzyJHpAZrBranch:
claude/issue-19887-readonlywhen-post-static-viewWorktree:
objectstack-issue-19887Domain:
domain:engineSeat:
domain:engine#1File surface:
packages/objectql/src/engine.ts(ObjectQL.update(): thereadonlyWhenstrip's merged view on the by-id and bulk branches), tests underpackages/objectql/src/, one.changeset/*.md(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opus(no path-derived mandate; default judgment tier — strip order on a hot file, with reason attribution and strict wording that move)Clause-②: no
Thread-read: 5797679384
Serial constraints cleared:
PR #19877 (#19853), which edited this update path (settleMasterDetailLanding), landed as 3bd221dfe1. Open PR #19728 (#18682, spec lane) edits engine.ts at the same update path's evaluateValidationRules calls: this card's diff must stay textually disjoint from #19728's head (merge-tree clean, recorded), and it does not merge into or wait on it. No other in-flight claim touches packages/objectql/src/engine.ts.Written 2026-09-23T15:52Z. The
record-root sibling of #19853: a record-scopedreadonlyWhenis judged against a forged value of a staticallyreadonlyfield that the static strip then removes (reproduced by the #19853 dev and the contract reviewer; triage 5797679384 re-read the order onmain).Why
Clause-②: no: a restored lock; a runtime security behaviour change is not clause ②. If the fix moves a refuse/accept answer the published text already negated, the PR'snocites that text.Generated by Claude Code
objectstack-fleet commentedon Sep 23, 2026
os-dev-report
{
"issue": 19887,
"status": "done",
"branch": "claude/issue-19887-readonlywhen-post-static-view",
"pr": "#19905",
"session": "session_01TEhopqrWQYBycZzyJHpAZr (subagent; the PM session, stamped on every commit as Claude-Session)",
"premise_still_valid": true,
"summary": "A1, on origin/main dabf8d7 (includes 3bd221d / PR #19877), engine untouched: shape 1 REPRODUCED — update(t1, { status: 'open', amount: 999 }) stored { status: 'closed', amount: 999 } with onFieldsDropped [{status, readonly}] only; control update(t1, { amount: 555 }) stayed 100; bulk stored 999 on both matched rows. Shape 2 REPRODUCED and NOT reached by PR #19877 — update(r1, { invoice: 'inv_b', amount: 999 }) stored { invoice: 'inv_a', amount: 999 } (control 100, bulk same): the lock is record-scoped, so the settlement (gated on a parent-scoped readonlyWhen or requiredWhen) never runs. A2 choice: (B) the post-static VIEW, not (A) a reordered strip. Measured over nine readonly suites: A (static strip first, by-id) 6 failed / 161 passed incl. 2 of PR #19877's 16 pins (report order, strict fields/drops order); B 167/167. Fix: engine.ts staticReadonlyStoredView (same stripReadonlyFields verdict staticReadonlyStripTakes asks for one key, which now delegates to it) passed as a new ReadonlyWhenStripOptions.stored to all four readonlyWhen strip calls (by-id, bulk, and both settlement judgeFkLock); rule-validator.ts builds the record binding from stored (keys judged, authorship test and returned payload stay data's). Movements (base to head, all pinned): shape 1/2 by-id and bulk now keep amount 100; events become [{amount, readonly_when}, {status, readonly}]; strict refusal fields ['status'] to ['amount','status'] (already a refusal); REVERSE over-lock removed (forged 'closed' on an open row no longer drops the amount); a field with BOTH locks whose predicate reads a forged value moves readonly to readonly_when (still dropped); an FK's own record-scoped lock reading a forged read-only stage now keeps the row home (settlement composes); requiredWhen on the stripped payload moves both ways (a refusal raised only by the forged-through amount now commits; clearing a field the kept amount requires is now refused). Unmoved: controls, isSystem, preserveAudit, hook-written and hook-overwritten values. A3: bulk uses the same stored view over each matched row. Landing site moved partly to the producer: rule-validator.ts (readonlyWhenBindings builds record from the strip's data argument); not a published export. Conditional-on-conditional sibling still reproduces on this head: listed for filing, not fixed.",
"tests": "All on 1afbcef. pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 -> 306 files / 5123 tests passed (exit 0). pnpm --filter @objectstack/objectql typecheck -> exit 0, check:test-typecheck OK (new file in tsconfig.test.json program per --listFiles, 0 diagnostics in it); test:repo -> 5 passed. New suite engine-readonly-when-stored-view.test.ts 18 cases + engine-readonly-when-parent.test.ts 38 (PR #19877 16 pins) -> 56/56. ABLATION (fix committed at 1afbcef): engine.ts and rule-validator.ts restored to dabf8d7 blobs 624fc1e38a / bb83519608 via git restore --source (tree only), on-disk blob hashes equal BASE, markers staticReadonlyStoredView 0 and stored?: Readonly 0; run 12 failed / 44 passed = every new pin red except the six unchanged-behaviour pins (2 CONTROLs, isSystem, preserveAudit, 2 hook pins) + all 38 parent-file cases green. Restored with git checkout HEAD --; blobs e973ab50ac / 2b3002b8e1 equal HEAD, git diff HEAD empty; rerun 56/56. EXIT/INT/TERM trap. Subject resolves via relative ./engine.js import to src, so no dist rebuild leg applies. OPTION-A measurement: variant engine.ts (static strip moved ahead, no stored) swapped in with trap + hash check, 9 readonly suites 6 failed / 161 passed; HEAD 167/167; restored blob equals HEAD.",
"mcp_calls": "0",
"api_writes": "2 — both through the fleet-write relay (each one repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft, #19905, run 35893433166 success); comment -> POST /repos//issues/19887/comments (this report). Plus 3 git pushes (branch probe, fix commit, test commit). No label writes. Reads: REST GETs only.",
"gates": {
"head": "1afbcef386",
"derived": "node scripts/pm/dispatch-gates.mjs --commands -> 65 commands (same list re-derived on the final commit)",
"ran_verdict": "node scripts/pm/dispatch-gates.mjs --ran ran.list -> exit 0: 65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero)",
"ran": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-engine-split-ratio.mjs --days 90 :: exit 0",
"node scripts/check-engine-split-ratio.mjs --self-test :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:dispatcher-error-vocabulary :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:durability-log-level :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:error-code-casing :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:stack-collection-maps :: exit 0",
"pnpm check:swallow-census-controls :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0",
"pnpm check:type-check-debt :: exit 0"
],
"extra": [
"node scripts/check-issue-citations.mjs (live) :: exit 0 (10 citations resolve)",
"node scripts/check-changeset-fixed.mjs :: exit 0 (roster under .changeset)",
"pnpm check:authz-resolver :: exit 0 (roster under packages)",
"pnpm check:filter-alias-parity :: exit 0 (roster under packages)",
"pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 :: exit 0",
"pnpm --filter @objectstack/objectql typecheck && pnpm --filter @objectstack/objectql test:repo :: exit 0",
"eslint --no-inline-config --format json (4 changed paths) :: exit 0, 0 errors / 0 warnings on the 3 linted files; changeset ignored by config; no type-aware linting; config reads only 2 untouched baselines",
"grep -naP control-byte self-scan over the 4 changed files :: exit 1 (no match)"
],
"earlier_attempts": [
"pnpm check:type-check-debt :: exit 3 PREREQUISITE NOT MET (objectql dist older than the ablation-restored engine.ts) -> pnpm --filter @objectstack/objectql build -> rerun exit 0; ran.list carries the rerun, raw record kept",
"pnpm check:objectql-double-limit :: exit 1 on ad51c06 (new suite copied a limit-blind find double) -> fixed in 1afbcef -> exit 0",
"pnpm check:dual-build-cjs-loads :: exit 3 on ad51c06 before a workspace build -> turbo build 72/72 -> exit 0"
],
"merge_tree": "driver-free bare probe (no merge.* config): 1afbcef vs #19728 head 3b9c5f2 -> exit 0 (tree 934c970144); vs origin/main 44ce049 -> exit 0 (tree f9fb086d04); merged tree carries both sides"
},
"files_changed": [
"packages/objectql/src/engine.ts",
"packages/objectql/src/validation/rule-validator.ts",
"packages/objectql/src/engine-readonly-when-stored-view.test.ts",
".changeset/19887-readonlywhen-stored-record.md"
],
"deviations": [
"File surface: packages/objectql/src/validation/rule-validator.ts edited beyond the claimed engine.ts — the record binding is built there (readonlyWhenBindings from the strip data argument); one optional internal option (stored), not exported; stated in the PR body.",
"Ablation mutation by whole-blob git restore --source=dabf8d795e of two files (verified by blob hash + marker counts, trap-guarded) instead of scripts/ablation-replace.mjs anchor replacement: the fix is multi-hunk across two files.",
"Option-A measurement swapped a scratch-built engine.ts variant in with cp (hash-verified before, restored from HEAD and hash-verified after, trap-guarded).",
"Commit trailers use the AGENTS.md model-free pair (Co-authored-by: Claude), and the PR footer uses the AGENTS.md session-URL form, instead of the harness reminder spellings.",
"Temporary files inside the worktree (a repro test and a lint-probe script) were deleted before commit and never committed; the worktree is removed.",
"git fetch origin main moved the shared origin/main pointer once (ordinary fetch, for the strongest gate derivation)."
],
"open_questions": [],
"out_of_scope_findings": [
"class: a · evidence: conditional-on-conditional sibling, same defect class — status readonlyWhen "previous.status == 'closed'", amount readonlyWhen "record.status == 'closed'", row closed; update(c1, { status: 'open', amount: 999 }) stores { status: 'closed', amount: 999 } on dabf8d7 AND on 1afbcef (the conditional strip drops status but judged amount against it in the same pass); not mechanical — interdependent locks make it a fixpoint whose verdicts move both ways · dedupe words: readonlyWhen reads value conditional strip drops · readonlyWhen interdependent locks same pass · record binding includes readonlyWhen-dropped value",
"carrier: none (承接者:无) · by-id branch now runs one extra silent stripReadonlyFields pass per update — noted in PR Acceptance notes only, not filed"
]
}
objectstack-fleet commentedon Sep 23, 2026
ACCEPT — round 21 · PR #19905 ·
Fixesdomain:engine#1,session_01TEhopqrWQYBycZzyJHpAZr, written 2026-09-23T17:29Z. Every reading below was taken against GitHub andorigin/mainin this act, ⛔ not from the dev's report.Review
1afbcef386main; first body lineFixes #19887; no other closing keyword;Clause-②: noat column 0, agreeing with thepatchchangesetengine.ts(staticReadonlyStoredView, passed asstoredto all fourreadonlyWhenstrip calls, by-id and bulk, strip and settlement),validation/rule-validator.ts(one optional keyReadonlyWhenStripOptions.stored), one new test file, one changesetrule-validator.tssits outside the claim's surface. Accepted:readonlyWhenBindingsbuildsrecordfrom the strip's owndataargument, so separating "what is judged" from "what the predicate reads" has no landing site inengine.ts; the options type and both strip functions are unexported (exports=.and./core), so no published type movesnois right: a restored lock is a runtime security behaviour change; the accept-directionrequiredWhenmovement removes a refusal the published text negated, cited verbatim fromcontent/docs/data-modeling/fields.mdxin the PR bodycheck-governed-merges --pr 19905, 0 of 4 paths; +488 / -14)success, 3skipped, on this head before readyOut-of-scope findings — dispositions:
amountreadonlyWhenreadingstatus, which anotherreadonlyWhendrops in the same pass; reproduced at this head and on the base) → filed A record-scoped readonlyWhen reading a field that another readonlyWhen drops in the same pass is judged against the dropped value, so a closed row locked amount is rewritten #19911. It is the same class, but it is a fixpoint of interdependent locks, not this card's shape, and it does not block this PR.stripReadonlyFieldspass per by-id update (same cost class as the bindings the strip already builds) → Acceptance notes.strictReadonlyWrites"onlyfields/dropsgrow"; on the reverse shape they shrink. The refusal holds before and after on both shapes, so the wording misleads no one → Acceptance notes.Landing: ready → queue on this head once CI is fully green.
Generated by Claude Code
fix(objectql): judge a record-scoped readonlyWhen against the payload…
fix(objectql): settle a chain of readonlyWhen locks on the drop set t…