Skip to content

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

Closed

Description

@objectstack-fleet

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)

  1. status is readonly: true; amount has readonlyWhen: "record.status == 'closed'". Row t1 is { status: 'closed', amount: 100 }.
    • update(t1, { amount: 555 }) → amount stays 100 (the lock holds; control).
    • update(t1, { status: 'open', amount: 999 }) → stored { status: 'closed', amount: 999 }. The closed row's locked amount was rewritten.
  2. The same shape through a master-detail FK: amount has readonlyWhen: "record.invoice == 'inv_a'" and invoice is readonly. 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

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

  1. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    ContributorAuthor

    定级 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: the readonlyWhen strip at :12927 / :13136 runs over the pre-static-strip payload, the static readonly strip at :12961 / :13156 runs 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/main dabf8d795e)

    判定


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 21
    Session: session_01TEhopqrWQYBycZzyJHpAZr
    Branch: claude/issue-19887-readonlywhen-post-static-view
    Worktree: objectstack-issue-19887
    Domain: domain:engine
    Seat: domain:engine#1
    File surface: packages/objectql/src/engine.ts (ObjectQL.update(): the readonlyWhen strip's merged view on the by-id and bulk branches), tests under packages/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-scoped readonlyWhen is judged against a forged value of a statically readonly field that the static strip then removes (reproduced by the #19853 dev and the contract reviewer; triage 5797679384 re-read the order on main).

    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's no cites that text.


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    ContributorAuthor

    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"
    ]
    }

  4. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — round 21 · PR #19905 · Fixes

    domain:engine#1, session_01TEhopqrWQYBycZzyJHpAZr, written 2026-09-23T17:29Z. Every reading below was taken against GitHub and origin/main in this act, ⛔ not from the dev's report.

    Review

    check reading
    head 1afbcef386
    PR shape draft → main; first body line Fixes #19887; no other closing keyword; Clause-②: no at column 0, agreeing with the patch changeset
    scope 4 files: engine.ts (staticReadonlyStoredView, passed as stored to all four readonlyWhen strip calls, by-id and bulk, strip and settlement), validation/rule-validator.ts (one optional key ReadonlyWhenStripOptions.stored), one new test file, one changeset
    file-surface breach rule-validator.ts sits outside the claim's surface. Accepted: readonlyWhenBindings builds record from the strip's own data argument, so separating "what is judged" from "what the predicate reads" has no landing site in engine.ts; the options type and both strip functions are unexported (exports = . and ./core), so no published type moves
    approach the post-static stored VIEW (strip order kept); the reorder candidate was measured and reds 6 of 167 across the nine readonly suites, 2 of them PR #19877's pins
    ablation the two source files at their base blobs: 12 of the 18 new pins fail, the six unchanged-behaviour pins pass, PR #19877's file stays 38/38
    clause ② no is right: a restored lock is a runtime security behaviour change; the accept-direction requiredWhen movement removes a refusal the published text negated, cited verbatim from content/docs/data-modeling/fields.mdx in the PR body
    governed NOT governed (check-governed-merges --pr 19905, 0 of 4 paths; +488 / -14)
    contract review PASS on this head (5799550450)
    CI 31 check runs success, 3 skipped, on this head before ready

    Out-of-scope findings — dispositions:

    Landing: ready → queue on this head once CI is fully green.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions