Skip to content

feat(formula,objectql): read one hop through a lookup in a validation predicate - #19728

Merged
objectstack-fleet[bot] merged 49 commits into
mainfrom
claude/issue-18682-predicate-relationship-traversal
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 49 commits into
mainfrom
claude/issue-18682-predicate-relationship-traversal

Conversation

@os-justin

@os-justin os-justin commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #18682

Clause-②: yes

⚠️ Rewritten short by the dispatching seat (2026-09-23T13:10Z). Its history, with each correction, is in this PR's comments. Three earlier review records were served below the contract-review tier and do not count (notice 5790480639); the next at-tier record judges the whole PR.

What changes

A script / cross_field validation rule can read one hop through a lookup: record.account.type on an opportunity resolves the related account's field. Before this change the expression faulted, and because a broken validation fails closed, every write on the object was refused.

  • The engine finds the lookup fields a rule traverses and the related fields it names, loads those related rows before evaluation, and binds them on a copy of the record for that rule only. On an update, the related row is the one named by the foreign key the write stores, read after read-only fields are stripped. The foreign key the driver receives is unchanged.
  • The related read runs under system authority, limited to the fields the expression names (intersected with the related object's declared fields) plus id. When the caller has an active organization, the read carries it: under isolated, a reference to another organization's row reads 0 rows (measured on driver-sql and driver-sqlite-wasm). Two bounds hold every caller that is not system (a user, a public-form submitter, or a caller with no principal); they never apply to a system caller. One consequence for system callers is stated rather than left implicit: a system context that carries a userId but no active organization now gets the system read under a walled posture, where it read nothing before round 17. Under a walled posture (group or isolated), such a caller with no active organization gets no related read, and every stored reference stays unresolved. A related object that no organization wall scopes (no tenant column, as sys_user behind a user field; tenancy.enabled: false; or external) is read only for the rows the caller's own read of that object returns; a reference to any other row refuses the write as not readable, whatever that row holds, and its columns are never read. A public-form submitter's grant admits a read of the form's own object only, so its own read of any other such object is refused; a caller with no principal reads through the security middleware's fall-open. The organization scope, the org-less no-read (user, public-form and principal-less callers) and the sys_user own-read bound (user and public-form callers) are pinned in packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts, the user case against the shipped member_default wall; the principal-less own read and the tenancy.enabled: false / external arms are covered by reading, not by a pin.
  • An unresolved relation (no id, no row found, or a failed read) leaves the field unbound, and the write is refused. The refusal states that the related record was not found, with the same text whether the id names a row in another organization or no row at all. On a related object no organization wall scopes, a row outside the caller's own read, stored or not, is refused as not readable (could not read 'sys_user'), with one text; so is a related read that is itself refused.
  • Two shapes are refused when the rule is authored: reading a lookup both through the relationship and as a plain value, and reading more than one hop deep.

The write preview (ObjectQL.validate())

The preview resolves related rows only for a caller that @objectstack/plugin-security's canWriteObject admits; the checks it runs are listed in its docblock, and include the organization wall (ADR-0123 D2). can-write-object-admission.test.ts pins the method against the registered middleware on its equivalence block's cases, one D12 delegate UPDATE as a direction case, and the D12 arm receiving copies. write-preview-field-gate-parity.test.ts pins the preview against the write path for the field-level gate. ⛔ The preview does not promise that the write would succeed.

Cascade delete

The foreign-key clear a cascade delete issues does not resolve relations, which is main's behaviour. Known limit: a traversing rule on a child that clears its reference to the deleted parent refuses that delete, and the refusal's prescription points at the child object rather than the related one.

Not in this PR

Acceptance

docs/qa/platform-checklist/areas/records-forms.json gains records-forms.predicate-relationship-traversal. On the showcase app, showcase_invoice refuses an invoice against a churned account on the server.

Changeset

minor on @objectstack/formula, @objectstack/lint, @objectstack/objectql and @objectstack/plugin-security.

维护者速读

  • 校验规则现在可以读关联记录上一层的字段(例如在商机上判断所属客户的类型)。以前这样写会出错,导致该对象的所有保存被拒。
  • 关联记录以系统身份读取,只读表达式里点名的字段。调用者有当前组织时,读取带上该组织(isolated 下读不到别的组织的记录);在有组织墙的部署里,没有当前组织的非系统调用者(包括公开表单的匿名提交者)完全不做关联读取。两条都有测试钉住。
  • 关联对象不分组织时(例如 user 字段指向的 sys_user),只读调用者自己读得到的那一行;读不到(比如别的组织的用户)就按「不可读」响亮拒绝,不管那一行存的是什么,也不读它的字段。这两条约束对所有非系统调用者都成立:登录用户、公开表单的匿名提交者、没有身份的内部调用;系统调用不受这两条约束(带 userId 但没有当前组织的系统调用,在有组织墙的部署里现在会做系统读取,之前不读)。
  • 级联删除清空外键时不读关联记录,与 main 一致;已知局限:这时带关联读取的规则会拒绝那次删除,提示语指向的对象不对。
  • 更新时按「去掉只读字段后真正要写入的外键」去读关联记录,堵住了先读后剥只读字段的绕过。

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1

Static analysis only — no I/O, no schema, no query function. Answers which
reference fields an authored predicate reads through (one hop), which it uses
as a plain value, and which it reads deeper than one hop, so a call site that
owns a query engine can preload exactly those fields BEFORE evaluation and
stdlib's purity invariant is untouched.

Also reports the one shape that cannot be served as written: a field both
traversed and used as a value. Hydrating it serves the traversal and silently
turns the value comparison false, and a validation predicate expresses the
FAILURE condition, so a silent false is a rule that stops firing. Measured on
this front end, that shape cannot work today — the traversal faults on every
row that reaches it — so refusing it removes nothing an author has working.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
`checkPredicate` evaluated with `{ record, previous }` only, so a lookup field
carried its id and `record.account.type` faulted with `No such key: type` —
and a faulting validation predicate rejects the write, so the rule could not
be authored at all.

The engine resolves the related rows (it owns the driver) and hands them over
as `EvaluateRulesOptions.related`, the same division of labour `parent`
follows. Two deliberate differences from `parent`: the rows are read under the
ACTING USER so the referenced object's RLS and FLS apply, and an unresolved
row is left absent on purpose — the traversal then faults and the write is
rejected, which is the loud failure an unreadable related field owes.

Hydration is per RULE and onto a COPY. Per rule, because hydrating a field
replaces its stored id with the related record and a sibling rule comparing
the bare id must keep seeing the id. Onto a copy, because the record handed to
a rule is the real write payload.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
One batched resolver serves the insert, single-update and bulk-update seams:
collect the reference fields the object's predicate rules traverse, read the
related rows ONCE per field under the CALLER's context so the referenced
object's CRUD gate, RLS and FLS apply, and hand them to the evaluator. Free
when no rule traverses — no query and no closure work.

`needsPriorRecord` now counts a traversing rule. The hop is taken from the
foreign KEY and a PATCH that does not touch that key does not carry it, so
without the prior row there is no id to resolve and the rule would fault and
reject a write it should have accepted. Counting it keeps the bulk path's
no-prior branch unreachable for such an object, the same argument #4977 makes
for `parent`.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ritten

The authoring-time half. Two shapes are refused with a prescription rather
than served half-right: a reference field read BOTH through the relationship
and as a plain value (hydrating it silently turns the value comparison false),
and a read deeper than the one hop predicates resolve.

Neither removes anything an author has working — measured on this front end,
both fault at evaluation today, and a faulting validation predicate rejects
the write. The refusal replaces a per-row runtime fault with one loud message
naming the repair.

Gated on `fieldTypes`, which is what tells a REFERENCE field from an
object-valued one: `record.address.city` traverses today and keeps traversing.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ust the picker

The invoice `account` picker already scopes churned accounts out of the
dropdown with `lookupFilters`, which is exactly why the rule earns its place:
a picker filter is a UI affordance, not a server guarantee. A write that never
touches the picker — REST, an import, a flow, an agent — reached the same
column with no scoping at all.

Adds the ADR-0136 D2.4 acceptance item under `records-forms` covering the
three ruled outcomes: the write passes when the parent field matches, is
refused with the AUTHORED message when it does not, and faults loudly when the
acting user cannot read the parent. Plus the changeset and both locales'
bundle entries for the new rule message.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ow read

The new batched read went through `as any`, which grew the
query-options-erasure ratchet on engine.ts from 8 to 9. The options bag is
declared — `EngineQueryOptions` — so it is named rather than erased. The
ratchet's unswept count drops 68 to 67.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 22, 2026
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/formula, @objectstack/lint, @objectstack/objectql, @objectstack/plugin-security, touching 44 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/objectql/src/core.ts, packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via rule_violation (literal, a string literal in checkPredicate))
  • content/docs/automation/flows.mdx (via validateExpression (symbol, a top-level function))
  • content/docs/automation/webhooks.mdx (via acc_1 (literal, a string literal in a comment in RelationshipTraversalAnalysis))
  • content/docs/data-modeling/formulas.mdx (via validateExpression (symbol, a top-level function))
  • content/docs/data-modeling/seed-data.mdx (via cross_field (literal, a string literal in collectPredicateRelationships))
  • content/docs/data-modeling/validation.mdx (via cross_field (literal, a string literal in collectPredicateRelationships))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via evaluateRule (symbol, a top-level function))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/sharing-rules.mdx (via /sharing/rules/:idOrName/evaluate (route, bridged from symbol evaluateRule — its route source's handler names it))
  • content/docs/permissions/system-context.mdx (via canWriteObject (symbol, a method of class SecurityPlugin), resolvePredicateRelated (symbol, a method of class ObjectQL))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/error-handling.mdx (via rule_violation (literal, a string literal in checkPredicate))
  • content/docs/protocol/objectql/schema.mdx (via cross_field (literal, a string literal in collectPredicateRelationships))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via evaluateValidationRules (symbol, a top-level function), validateStackExpressions (symbol, a top-level function))
  • content/docs/releases/v17/17-0.mdx (via cross_field (literal, a string literal in collectPredicateRelationships))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/objectql/src/core.ts, packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 69 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 615c0856efd26f42579bf7ff27297c05bdfd11fb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 21c6bb3905239d2933ec4f31827cd370abfab67b — the merge of head 3e8b3c3a22a6a0b67e77668d9220d6cf7cce0c31 into base 615c0856efd26f42579bf7ff27297c05bdfd11fb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 21c6bb3905239d2933ec4f31827cd370abfab67b && git checkout 21c6bb3905239d2933ec4f31827cd370abfab67b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 615c0856efd26f42579bf7ff27297c05bdfd11fb 3e8b3c3a22a6a0b67e77668d9220d6cf7cce0c31 && git checkout -B drift-repro 615c0856efd26f42579bf7ff27297c05bdfd11fb && git merge --no-ff 3e8b3c3a22a6a0b67e77668d9220d6cf7cce0c31

node scripts/docs-audit/affected-docs.mjs --json 615c0856efd26f42579bf7ff27297c05bdfd11fb

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 615c0856efd26f42579bf7ff27297c05bdfd11fb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0065a406b822361bc81bc0c49a7fc5a6d9436f85

Read at 2026-09-22T13:48Z. Every reading below was re-derived from the diff fb7b74691f7d192430f6b3b457a858dc442ad0e0..0065a406b822361bc81bc0c49a7fc5a6d9436f85 (merge-base of the head and origin/main, 13 files, +1198/-10) and from origin/main, in a detached worktree at the head with a fresh install and build. The dev report, the PR body and the ruling 5776007340 were used only as the list of things to check. Where I could run code I ran it (the PR's own 23 + 15 tests pass; my own 8 engine-seam measurements and 7 cel-js probes are quoted by label below); where I could not, it says NOT MEASURED.

① Derived judgments

Q1-A (hydrate in place + refuse the mixed shape at authoring, prescription record.LOOKUP.id) — implemented at the two ruled points, but the refusal arm is on the wrong surface.

  • Hydration: hydrateRelated (rule-validator.ts) overlays the related row onto a shallow COPY, per RULE, only for the reference fields that rule's own source traverses; checkPredicate evaluates the copy. A rule that does not traverse is handed the record byte-identical to before. Verified by reading and by the PR's sibling-rule test.
  • Refusal: validateExpression (formula/validate.ts) now emits an error for bare-and-traversed and multi-hop on a REFERENCE-typed field, gated on fieldTypes; the message names record.FIELD.id. Verified: probe P10 shows that error is the ONLY error lint raises on the mixed shape, so lint accepted it before this diff and refuses it after.
  • ⛔ The refusal exists ONLY in validateExpression. findTraversalConflicts has exactly one caller on the head (validate.ts); no runtime package imports @objectstack/lint, and nothing under packages/objectql, packages/metadata*, packages/rest or packages/runtime calls validateExpression. The comment the diff adds to formula/src/index.ts says "@objectstack/lint (and the engine's publish path) refuse the conflicting shapes" — there is no engine publish path that does. Measured consequence (M7, engine + hand driver, rule record.account.status == 'churned' && record.account == 'acc_churned', a row pointing at the churned account): BEFORE this diff the write is REJECTED as unevaluable; AFTER it the write is ACCEPTED — the bare compare is a map-vs-string false (probe P3 confirms cel-js answers false without faulting), so the rule silently stops firing. That is option B's harm, the one the ruling rejected on measurement, delivered on every path that authors a rule without running lint (Studio / sys_metadata, MCP-authored metadata, a stack built without lint).
  • ⛔ The refusal arm also reaches seams the ruling carved OUT. Lint calls check(…, objectName, 'record') with fieldTypes for: field-level requiredWhen / readonlyWhen / conditionalRequired / visibleWhen (validate-expressions.ts:1570), per-option visibleWhen (:1595), action visible / disabled (:1743, :1745), sharing-rule conditions (:1795 — RLS, which the card marks ⛔ out of v1), hook conditions (:1805, :1829, :1838), flow node and edge conditions (:1355, :1480, and service-automation/engine.ts:9329 with fieldTypes), and formula-field values (:1710, role value; probe P11d shows the multi-hop refusal firing there with text about "reject the write"). On none of those seams does anything hydrate, so the prescription "write record.FIELD.id" is false where it is shown: it produces an expression that faults at evaluation, and on a fail-open seam that means the rule stops enforcing altogether. Q2-A said v1 is checkPredicate only; this diff moves the accepted set of nine other seams and hands their authors a repair that does not work there.

Q2-A (checkPredicate only; visibleWhen / readonlyWhen / requiredWhen OUT, carved to #19727) — hydration honours it; the refusal does not (above). The diff touches RuleContext, EvaluateRulesOptions, evaluateRule's script / cross_field arm, needsPriorRecord, and adds collectPredicateRelationships / hydrateRelated; evaluateOptionVisibility, the requiredWhen block and the readonlyWhen strip are untouched, and collectPredicateRelationships collects script / cross_field (including inside conditional) and nothing else. No UI seam is quietly covered.

The ruling's ground ("a mixed expression already throws today, so refusing it removes nothing that works") — verified with one nuance, and one consequence the ruling did not foresee. Probe P2: record.account == 'acc_1' && record.account.type == 'partner' with account = 'acc_1' faults (No such key: type). Probe P1: the same expression with account = 'acc_2' evaluates to false without faulting — CEL short-circuits, so a mixed expression DOES evaluate today on every row where the bare arm decides, and faults only on rows that reach the traversal. No mixed expression can therefore evaluate correctly across all rows, and the dev's corpus scan (1041 sources, positive controls) found none authored — so the lint refusal removes nothing that works CORRECTLY, and the ground holds for the lint layer. It does not describe the runtime after this diff: there the throw becomes a silent accept (M7 above).

Accept/reject delta, enumerated.

Accepted before, refused after:

  • lint, any fieldTypes-bearing site, reference-typed field: record.FK.FIELD together with a bare record.FK (compare, != null, has(record.FK), ternary test, method receiver such as record.FK.startsWith(…) — probes P9, P13, P13b, P14). ⚠️ Every plain null-guard spelling is in this set; the only null-safe spelling lint still accepts is has(record.FK.FIELD) (see ④ for what that costs).
  • lint, same sites: record.FK.FIELD.FIELD2 (multi-hop) on a reference-typed field.
  • runtime checkPredicate: nothing newly refused.
  • engine validate() dry-run (engine.ts:10692): a traversing rule is now REJECTED in preview where the real write is ACCEPTED — see ③.

Refused before, accepted after:

  • runtime checkPredicate: record.FK.FIELD on a reference-typed field, when the acting user can read the related row and the row carries the key (M1, M2, M5 measured: accept when the parent does not trip the rule, refuse with the AUTHORED message when it does, on insert, on a repoint, and on a patch that does not carry the FK — the FK is read off the prior row).
  • runtime checkPredicate: a MIXED expression that bypassed lint is now accepted with its bare arm silently false (M7) — the defect above, not a widening.

Unchanged: non-reference object-valued fields (record.address.city), previous.FK.FIELD (not analysed, faults as before), multi-value reference macros (record.accounts.exists(…), size(record.accounts), in — probes P7, P8, P8b: bare only, no traversal, no conflict), conditional.when (evaluated unhydrated by checkConditional, faults as before — note the changeset's headline "a validation rule can read one hop" is wider than what when gets).

Hard constraint (per-evaluation copy) — holds, at two layers, and the ruling's premise is inexact. evaluateValidationRules builds merged as { ...previous, ...data } (rule-validator.ts:2453) — a spread copy — so the object checkPredicate receives was never the write payload itself; hydrateRelated copies again per rule. Measured at the driver boundary (M1: create receives account: 'acc_ok', a string; M5: update receives { id, amount }, no expanded object). The PR's test "does NOT mutate the record it was handed" pins data only and would pass even under in-place hydration of merged; the test "leaves a sibling rule that compares the BARE foreign key untouched" is the pin that actually proves per-rule isolation. Adequate.

Permission semantics — read under the acting user: holds. Unreadable → loud: holds for a plain member access, with three real holes.

  • Measured (M1): the related read is find('account', { where: { id: { $in: ['acc_ok'] } }, fields: ['id', 'status'], context }) with context = { userId: 'u1', positions: ['rep'] } and no isSystem — unlike resolveMasterDetailParent, which reads { isSystem: true }. The step-4 FLS mask in security-plugin.ts DELETES a non-readable key from a find result, so the hydrated map lacks it and record.FK.FIELD faults. Measured (M6, middleware deleting status after the read): the write is REJECTED as unevaluable, never accepted.
  • Hole 1 — the loud fault carries the wrong prescription. M3/M4/M6 all return: "The predicate reads 'status', which this object does not declare — fix the rule's condition, or declare the field." The field is declared — on the RELATED object; an author following this declares a bogus status on the referencing object. The card's ruled outcome 3 is "faults loudly"; a fault whose prescription points at the wrong object is not the prescription discipline this repo holds refusals to.
  • Hole 2 — absence is overloaded. resolvePredicateRelated's docblock: "No id, row gone, refused by the security layer, read threw: all four leave the field unbound." Add a fifth the docblock does not name: a readable column the driver did not echo. driver-memory's projectFields skips undefined values (its own docblock: "a value of undefined means the key is not emitted"), so under the projection ['id','status'] a stored row without status comes back without the key. Measured (M4, a related row lacking status): the write is REJECTED as unevaluable. This is the parent 表头绑定也是稀疏的 —— #4953 修完 record 之后,同一个作者陷阱在 parent 根上原样留着 #6457 trap ("whether a declared lock enforced depended on which columns a driver happened to return") re-opened one root over, on a fail-CLOSED seam — a valid write refused for a readable, legitimately-empty parent field. The engine already has the TOTAL-ising helper for parent and did not apply it here; applying it naively would turn an FLS-deleted key into null and defeat the permission rule, so the fix has to let the ENGINE decide readability (catch the security refusal; security.getReadableFields(target, context) exists) rather than delegate it to CEL's key-absence.
  • Hole 3 — has() and optional selection defeat "never silently true". Probe P4: has(record.account.status) returns false on a null FK, on a bare id string AND on a hydrated map that lacks the key; probe P12: record.account.?status.orValue('') evaluates on all three. Since every plain null-guard is refused by the new lint arm (P13/P13b/P14), has(record.FK.FIELD) && record.FK.FIELD == … is the spelling an author of an optional lookup will reach for — and with it an FLS-stripped field makes the rule silently not fire. The guarantee holds only for expressions that touch the field with a plain member access.
  • Collateral, measured by the PR's own CI: required context Dogfood Regression Gate is RED on this head. packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts:138 — a member whose permission set grants showcase_invoice CRUD and showcase_contact read and NOTHING on showcase_account — owns an invoice (account status prospect) and PATCHes { name }; expected below 300, got 400. That is the diff's no_invoice_for_churned_account rule: it runs on every invoice write, its related read is refused for this persona, the rule faults, and an owner's rename is rejected. The checklist item's knownGaps asserts "no seeded persona fails to read" showcase_account; CI measured one. The PR body's dogfood claim was authored (checklist item), not run — no run record exists and no showcase test names the rule. Reproduced locally at this head over HTTP with the same permission set: the engine logs predicate relationship lookup failed with PERMISSION_DENIED — "operation 'find' on object 'showcase_account' is not permitted for positions [everyone]", permission sets member_default + showcase_d34_member — then Validation rule 'no_invoice_for_churned_account' predicate failed to evaluate (runtime: No such key: status), and the PATCH answers 400 VALIDATION_FAILED with constraint.reason: 'unevaluable', missingKey: 'status' and the prescription "The predicate reads 'status', which this object does not declare — fix the rule's condition, or declare the field." So member_default does not grant showcase_account read either; the checklist's "public_read_write, no persona fails to read it" is false under the security plugin's CRUD gate for this composition.

Changeset honesty. .changeset/18682-predicate-relationship-traversal.md: @objectstack/formula: minor, @objectstack/objectql: minor, Clause-②: yes (widening), no ADR-0087 marker. Packages named are exactly the two whose source changes and publishes; @objectstack/spec is imported (REFERENCE_VALUE_TYPES, referenceTargetOf) and untouched, correctly omitted; examples/app-showcase is private. @objectstack/lint's published behaviour changes through its formula dependency (the new refusal at ten sites) without a line of lint changing — no changeset is owed under the convention, but the refusal's reach (①) is what makes the (widening)-only arm inexact today.

Model identifier (AGENTS.md:446). PR title, PR body, all 6 commit messages (trailer pair Claude-Session: + Co-authored-by: Claude), the changeset, the checklist JSON, both locales, every code comment in the diff: grep for model names and ids returns no match. Clean.

Also red, and caused by the diff: required context Lint & Repo Gates fails at check-adr-anchors --self-test: ADR-0136 is cited by docs/qa/platform-checklist/areas/records-forms.json and packages/objectql/src/validation/rule-relationship-traversal.test.ts and names no record under docs/adr/ (origin/main has 0135 and 0137; nothing else on origin/main cites 0136). The number was copied from the card body ("Acceptance (ADR-0136 D2.4)"), which is dangling there too — the card needs the same correction. ADR-0137 (predicate fault semantics are contract, D2: a faulting field-rule predicate refuses the submit and names the field and the rule) is the record that actually governs the fault this diff adds a new cause to, and the diff does not cite it.

② Semver level

minor on @objectstack/formula and @objectstack/objectql is the right level for the ruled scope (Clause-② yes takes at least minor), and the ruling's basis for (widening) alone holds at the lint layer for checkPredicate conditions (no correctly-working expression is refused). It becomes exactly true only once the refusal is scoped to the validation-rule sites (① F-scope) — until then the same diff narrows nine seams the ruling did not put in scope. Not BREAKING as ruled; no ADR-0087 disposition owed.

③ Boundary flags

Blocking (the FAIL rests on these; each names its fix):

  1. Dogfood Regression Gate red at this head, caused by the showcase rule's collateral on a persona that cannot read showcase_account (④). Needs a ruling: either the showcase's own permission model must guarantee account read to every invoice writer (and the test's local set is not the showcase's), or the semantics "unreadable parent rejects every write on the child, FK touched or not" is what the maintainer wants and the test moves — the PM decides; the reviewer only records that the required context is red on the diff's own change and the PR body's dogfood claim was not run.
  2. Lint & Repo Gates red at this head: dangling ADR-0136 citation in two diff files. Cite the record that exists or drop the number; correct the card body in the same stroke.
  3. The engine validate() seam (engine.ts:10692) is not wired: M3 measured preview valid: false (unevaluable) against an ACCEPTED write on the same row. The seam's own contract is "the same posture as the real write" and validate-only.test.ts pins that a dry run ASKS the write's verdict; the import dry run rides it. Resolve related there (insert mode at minimum; update-mode preview without a prior is a pre-existing limit to name, not widen).
  4. No runtime refusal of the mixed shape (M7), and a code comment that claims one exists. Refuse in the engine too: in hydrateRelated / checkPredicate, a bare-and-traversed conflict on a reference field returns unevaluableRuleError with the same prescription instead of hydrating — that keeps today's fail-closed verdict for the shape, removes nothing that works, and closes the silent flip on every lint-bypassing path. Correct the formula/src/index.ts comment to name lint alone until then.
  5. The refusal arm reaches ten lint sites outside Q2-A's seam with a prescription that is false there. Gate the traversal-conflict pass on an explicit caller opt-in in the schema hint (set by lint at validate-expressions.ts:1521 / :1523 only) rather than on the mere presence of fieldTypes, and keep value-role sources out of it entirely.
  6. The unevaluable fault's prescription names the wrong object (④ hole 1). A traversal fault must say which RELATED object and field could not be read under the acting user.
  7. Absence collapses "cannot read", "row gone", "no id" and "column not echoed" (④ hole 2, M4), and has() / .? tolerate absence (④ hole 3, P4 / P12). Make the readability decision in the engine before evaluation — a refused or FLS-stripped traversed field yields the unevaluable refusal directly, a readable-but-empty field is materialised to null over the target's declared fields (parent 表头绑定也是稀疏的 —— #4953 修完 record 之后,同一个作者陷阱在 parent 根上原样留着 #6457's helper generalised) — so the permission verdict never depends on which CEL operator the author picked.
  8. No engine-level pin: acting-user context, projection, update-seam prior-FK read and driver-boundary payload are asserted only by this review's scratch measurements; the PR's tests hand related in by hand. Add one engine test in the shape of engine-required-when-parent.test.ts (capture the find context with registerMiddleware, assert no isSystem, assert the driver's create payload carries the id).

Non-blocking:

  • collectPredicateRelationships reads condition.source without checking dialect; a non-CEL source that happens to parse as CEL is analysed and hydrated. Check the dialect.
  • conditional.when is not hydrated and still faults; the changeset headline says "a validation rule". Say script / cross_field conditions, or hydrate when too.
  • Partial field masking (spec/security: field masking is all-or-nothing — no partial masking (phone last-4, ID middle-8), and maskingRule was pruned as dead in 2026-06 #8993) hands a masked VALUE to the predicate, which then evaluates silently against the mask. Name it in the docblock or treat masked-for-caller fields as unreadable here.
  • The diff adds a new fault cause under ADR-0137 D2 without citing the record (PD [WIP] Add Chinese version of the documentation #13 corollary).
  • The unevaluableRuleError text is reached with missingKey: 'status' on a traversal; the records-forms.json acceptance clause "rejected with the unevaluable-rule envelope" is satisfied literally by a misleading envelope — tighten the clause once 6 lands.

Implemented-by: claude/issue-18682-predicate-relationship-traversal
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 (at-tier contract-review subagent of the dispatching seat)

VERDICT: FAIL


Generated by Claude Code

… decide readability first

Contract-review rework, findings 2-7.

ADR: the acceptance cited ADR-0136, which names no record. ADR-0137 (predicate
fault semantics are contract) is what governs the fault this adds a cause to.

Fail closed in the ENGINE, not only in lint (ADR-0137 D1, ADR-0124). No runtime
package imports lint, so the mixed shape — a reference field read both through
the relationship and as a value — was rejected before this capability and would
have been accepted after it, with the bare arm silently false, on every path
that authors metadata without lint. `checkPredicate` now refuses it itself. The
comment claiming an engine publish path already refused is deleted; there is
none.

Name the RELATED object in the refusal (ADR-0137 D2). The generic prescription
said the field "this object does not declare" — on a traversal the field IS
declared, on another object, and an author following it added a bogus column to
the wrong file.

Decide readability BEFORE evaluation. A related column the caller may read but
which is empty now materialises to null and evaluates; one the caller may not
read refuses. A driver returns both as the same missing key, so the engine asks
`getReadableFields` through a new seam the security plugin fills. This also
stops the verdict depending on which operator was written: `has()`, `.?` and
`[?]` read a missing key as an ordinary false, and all three are now seen by
the analysis and refused the same way.

Scope the authoring refusal to where hydration happens. Gating on `fieldTypes`
reached nine seams — field requiredWhen/readonlyWhen, option visibleWhen,
action visible/disabled, sharing (RLS), hooks, flow node and edge conditions —
where nothing hydrates and the prescription is false. Now an explicit opt-in,
set by lint at the validation-rule condition alone.

Also: the dry-run validate() seam resolves related rows, so preview and write
agree; and the relationship collector checks the dialect.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
The rule-validator tests hand `related` in by hand, so none of the engine
behaviour was pinned. Driven end-to-end through the real engine and a real
driver: the related read runs under the acting user and never as system, the
projection is id plus only the fields the rules name, a batch costs one read,
the foreign key is read off the prior row when a patch omits it, the driver's
write payload carries the key and not the expanded record, a readable-but-empty
column evaluates as null, an unreadable field refuses under every operator
spelling, the dry run agrees with the write, and a non-traversing object pays
no read at all.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…lidation rules

Ruled: a validation rule's output is a pass/fail the SYSTEM enforces, not data
handed to the caller — categorically unlike an access-control rule, which is
why RLS predicates are out of this capability entirely. Reading as the acting
user made the rule unauthorable for exactly the persona it exists to constrain:
a member with CRUD on the child and no read on the parent faulted on every
write, which the dogfood gate measured.

What bounds the elevation is the PROJECTION, not the caller: only the columns
the predicate names, intersected with the related object's declared fields. A
column the related object does not declare never enters the query and is
refused as the authoring fault it is — and stays distinguishable from a column
that exists and is empty, which materialises to null and evaluates.

The refusal names the field and the rule, never the value. A caller can infer a
value by observing which writes refuse; that channel was accepted knowingly and
is kept no wider than "this rule refused this write".

Confined to `checkPredicate`'s seam. The readable-fields seam the acting-user
model needed is removed again, with it.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
… not by position

`showcase_invoice` now carries a real business rule — no invoice against a
churned account — so the matrix's "admin control payload is VALID" assertion
depended on which account the seed happened to return first. The control is
chosen to satisfy the rule instead of by position; the rule is untouched.

Also states the checklist's known gap as what CI actually measured: the
dogfood persona holds invoice CRUD and no grant on showcase_account, which is
now the point of the item rather than a gap, because the related read is
system-authority.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…bound in the test double

Two gate findings from this diff. The new unevaluable log line copied a tracker
id into prose an operator reads; ids belong in the lesson, not the sentence.
And the new engine test's `find` double ignored the caller's `limit`, which is
how a real double-limit defect rides through unnoticed.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
The acting-user model needed a readable-fields resolver wired here; the ruled
system-authority read does not. Removing the wiring left one blank line behind,
which kept a package this card no longer touches inside its diff.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

席位改写了本 PR 正文的三处 —— 谁改的、改了什么、为什么,写在这里

domain:spec seat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1,座位贴 #19357),2026-09-22T15:26Z。施工轮按契约只在开 PR 时写一次正文,之后的更正点名交席位办;它点了三处,本席核过后照办。

为什么必须改,而不是留一条评论了事

本 PR 是契约面 PR。正文里那三处在返工后已经为假,而正文是复核者、队列记录与将来读 CHANGELOG 的人都会读的东西。⇒ 留着假陈述、另发一条更正,等于让最权威的那份文本继续说错话。

# 原文说的 现在的事实
1 关联行「read under the acting user」,被引对象的 CRUD / RLS / FLS 全部生效 系统权限读,范围受投影约束(表达式点名字段 ∩ 被引对象已声明字段 + id)。维护者裁决,全文在卡 #18682 的 5778121976
2 ## Scope 里「the seam that is fail-closed, and therefore the only one where an unreadable related field can produce the loud refusal」 改为「系统权限读被限制在这一条缝」—— 原措辞的因果在裁决之后不再成立
3 ## Verification 整张表与两条 NOT MEASURED 整节重写

⭐ 第 3 处为什么单独说:它曾经断言了一件没有发生过的事

上一轮的正文把 dogfood 用例写成已验证,而那个门根本没跑;同一时刻该 head 上有三条必需检查是红的。施工轮这一轮主动认了这件事,⛔ 没有辩解。

⇒ 新的 ## Verification 做了两件旧版没做的:① 区分谁测的 —— 本席对 GitHub 亲自核到的(Dogfood Regression Gate 及其三个分片 completed / success、该 head 0 红)与施工轮本地跑的,分开列;② 保留「NOT MEASURED 与 pass / fail 分开报」这条规矩,并写明本轮该列为空、以及此前在该列的两项各自是怎么变成真读数的。

⛔ 本席没有动正文的任何其它部分:技术方案叙述、两种被拒形状、Acceptance、Acceptance notes、以及施工轮自己的署名页脚,一字未改。

状态

needs:contract-review 挂在卡 #18682 与本 PR 两边。新一轮在档复核已派(head 995266cea04facfc44da01bab85140fbb1632a40)—— 上一份 FAIL 记录 5777701356 只管 0065a406b,已随 head 前移作废,⛔ 不沿用。PASS 后本席先从两边摘标、引记录,然后才 ready + 入队。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 995266cea04facfc44da01bab85140fbb1632a40

Read at 2026-09-22T15:41Z. This record replaces 5777701356, which governed 0065a406b and is void with that head. Nothing below inherits a verdict from it: every reading was re-derived from the diff fb7b74691f7d192430f6b3b457a858dc442ad0e0..995266cea04facfc44da01bab85140fbb1632a40 (the merge-base of the head and origin/main; 12 commits, 16 files, +2020/-14) and from origin/main, in two detached worktrees with a fresh install and build — one at the head, one at the merge-base for the before/after transition. The PR body, the implementing round's report 5779163549 and the ruling 5778121976 were used only as the list of things to check. Probe labels (A-series: analyser, 48 spellings; E-series: engine, real driver double, 12 cases) are quoted where a reading came from code I ran. ADR-0137 (D1/D2/D3), ADR-0124 and ADR-0078 were read before judging.

① Derived judgments

The maintainer's ruling — 「校验规则在读关联记录时改用系统权限,但只读规则表达式自己点名的字段」 — bound by bound, from the code.

  1. Validation rules only — HOLDS. resolvePredicateRelated (engine.ts:6924) is private and has exactly four callers, all validation seams: the dry run (:10741), insert (:11473), single update (:12805), bulk update (:13091). Its result flows only into EvaluateRulesOptions.related, which evaluateValidationRules hands to evaluateRule, whose script / cross_field arm alone calls checkPredicate. conditional.when (checkConditional), field requiredWhen / readonlyWhen, option visibleWhen, the sharing plugin's evaluateRule and every RLS path are untouched; plugin-security is byte-identical to origin/main. collectPredicateRelationships reads objectSchema.validations and nothing else. The elevation is unreachable from an RLS or UI predicate.

  2. Only the named fields — HOLDS. The read set is computed in the engine before the query is built: named from the static analysis, checked against the related object's registry field map, and any undeclared name aborts the read entirely — no query is issued (E4c: rule naming nope, zero reads of crm_account). The projection actually issued is id plus the union of what every traversing rule on the object names (E5: two rules naming status and name+status produced ONE read, fields: ['id','status','name'], where: {id: {$in: [...]}}, context {userId:'u1', positions:['rep'], isSystem:true}). The elevated read stays tenant-scoped: buildDriverOptions threads tenantId independently of isSystem, so isSystem adds only the audit-warn mute.

  3. The refusal does not echo the related value — HOLDS, including logs. The authored refusal carries the rule's own message; the four traversal refusals name the reference field, the related object, the named column(s) and the rule. Ten CEL fault shapes over a related row holding churned / TOPSECRET (E3: int(), double(), timestamp(), +, string index, matches with a bad pattern, startsWith(1), comparison overloads) produced fault text naming types, keys or the author's own pattern — never an operand value. The engine's warn lines on the fault path carry the message and stack, no value (E10). The 'unresolved' vs 'no-reference' pair lets a caller distinguish an id that exists from one that does not, but assertReferencesResolve already answers existence unscoped by design, so that is not new width.

  4. Negative pin — EXISTS and re-measured. never smuggles an UNDECLARED field into the system read set in engine-predicate-relationship.test.ts; E4c confirms the refusal 'crm_account' declares no 'nope' with no related read issued.

  5. The accepted channel is not reported as a finding. ⚠️ Something that makes it WIDER is — flag 2 below.

Engine-side fail-closed for the mixed shape (prior finding 4) — CLOSED, transition re-measured on both sides. Rule record.account.status == 'churned' && record.account == 'acc_churned', row pointing at the churned account, through the real engine and a driver double:

  • merge-base fb7b74691: REJECTED — runtime: No such key: status, with the wrong-object prescription (E1-base);
  • head 995266cea: REJECTED — reads a reference field both through the relationship and as a value, with the record.account.id prescription; the rule's own message is never reached (E1).
    REJECTED → REJECTED. The refusal lives in resolveTraversalScope (rule-validator.ts), reached from checkPredicate before evaluation, judged with referenceTargetOf — the same arbiter the engine's $expand gate asks. Lint is not on the path: no runtime package imports it (grep for findTraversalConflicts / traversalHydration outside formula and lint: zero hits). The false formula/src/index.ts claim is gone; the replacement text names lint and the engine separately and cites ADR-0124.

The refusal arm does not leak past the validation seam (prior finding 5) — HOLDS, both directions. traversalHydration is set at exactly one site, validate-expressions.ts:1533 (rule condition); the sibling when check at :1540 does not set it, nor does any field / option / action / sharing / hook / flow / formula site, nor service-automation's schemaHint, nor mcp's hint. validateExpression additionally requires role === 'predicate' and fieldTypes (validate.ts:891). The PR pins both directions (checks NOTHING when the caller has not opted in, checks nothing in the value role).

Fault prescription (prior finding 6) — HOLDS. Four reasons, each naming the related object: 'crm_account' declares no 'nope' ... declare 'nope' on 'crm_account' — ⛔ not on the object carrying this rule (E4c), no related record (E6, E11), the related record was not found (E11), could not read 'crm_account'. The wrong-object text is off the traversal path. Under ADR-0137 D2 the refusal names the rule; the envelope's field is _record for every authored rule, which the PR's acceptance note correctly records as a pre-existing spec gap.

Absence vs null (prior finding 7) — genuinely distinguishable. A declared column the driver did not echo is materialised to null and evaluates: rule record.account.status == null FIRES with the authored message on that row (E4a) and is ACCEPTED on a row carrying status (E4b). An undeclared column refuses with declares no and no read (E4c). The verdict is decided in the engine before CEL sees the row, so has() and .? cannot turn a fault into false — the PR's three-spelling pin passes and the analyser sees every spelling (next).

⭐ Optional-access re-measurement — VERIFIED, no hole found. The raw AST was inspected: ., .?, [] (key {op:'value'}), [?], rcall for method calls, call for has(). 48 adversarial spellings (A-series), each classified as traversal / bare / multi-hop / conflict:

  • traversal detected for ., .?, ['x'], [?'x'] on the SECOND hop and on the FIRST (record['account'].type, record.?account.?type, record[?'account'].type), for escaped and raw string keys ('type', r'type'), inside has(), size(), in, a list literal, a macro body ([1].exists(x, record.account.type == 'p')), a comprehension variable that shadows the root, after a method (record.account.type.startsWith('p'), .lowerAscii().size()), and through parentheses;
  • a non-literal key ([record.k], [dyn('type')], ['ty' + 'pe'], [b'type']) is BARE, so the field is not hydrated, the id string cannot be indexed, the predicate faults and the write is refused — fail-closed;
  • multi-hop is refused in every spelling (.?owner.?email, ['owner']['email'], [?'owner'].email, three hops);
  • mixed shapes are caught whichever way the bare use is spelled (.?type + bare, index + has(record.account), bare via method receiver, via in, via a ternary arm, via dyn(), via a dynamic index);
  • a ternary RECEIVER (c ? record.account : record.account).type is not a traversal and is bare — unhydrated, faults, refused. Fail-closed, and an author is told nothing useful; noted, not a hole.
    The implementing round's own account of the hole it found is accurate, and the fix is complete for the surface cel-js exposes.

engine.validate() dry run (prior finding 3) — insert-mode agreement holds (PR pin + E9); the stated update-mode limit is accurate, not convenient: validate() passes no previous in update mode for ANY rule, so a traversing rule refusing on a patch that omits the FK behaves exactly as a previous-reading rule already does there, and a patch that carries the FK resolves (E9b). What wiring it did to the channel is flag 2.

Body vs code. The rewritten body's claims about the seam, the read authority, the projection, the accepted cost and Verification hold. Three passages contradict the code: the repair column of the "Two shapes refused" table (record.account.id — flag 1); "the read set is ... plus id" (the projection carries id, the accept-set refuses a NAMED id — flag 1); and "an unresolved row is left absent ... refused by the security layer" (the mechanism is now a discriminated binding decided before evaluation, and a security-layer refusal cannot occur under isSystem). The header "Two deliberate differences from the parent precedent" is stale for its first bullet, which is now the SAME as parent.

CI, read at the head against GitHub, not from the report: 42 check runs, every one of the seven required contexts completed / success, 0 failures; Dogfood Regression Gate and shards 1/3, 2/3, 3/3 green. Locally at the head: the PR's three test files pass (29 + 36), and the two dogfood files this diff touches pass 51/51 — the persona the previous head faulted now writes and the churned-account rule still refuses.

Model identifier (AGENTS.md:446): title, body, 12 commit messages (trailer pair Claude-Session: + Co-authored-by: Claude), changeset, checklist, both locale entries and every code comment in the diff — no match.

② Semver level

minor on @objectstack/formula and @objectstack/objectql, Clause-②: yes (widening), no ADR-0087 disposition — correct for those two: at the engine the accept-set moves only from refused (fault) to accepted (plain one-hop traversal), and the mixed shape stays refused with a better message; at lint the ruled ground (a mixed expression cannot evaluate correctly on any row today) holds and the arm is now confined to the one seam that hydrates. One omission, non-blocking: @objectstack/lint's source changes (validate-expressions.ts, +19/-1) and its published behaviour at validation-rule condition sites narrows by two shapes, yet the changeset names it nowhere. It versions anyway through the fixed group, but its CHANGELOG will carry no sentence for the new refusal an upgrading author greps for. Add '@objectstack/lint': minor to the same changeset; the Clause-② arm is unchanged.

③ Boundary flags

Blocking — the FAIL rests on these; each names its fix:

  1. The engine's own prescription is a dead end. The ruled repair for the mixed shape (Q1-A: point the author at record.LOOKUP.id) is emitted by findTraversalConflicts, repeated in the changeset table and the PR body — and the engine refuses it. Measured on both registration paths (E2 raw registerObject, E12 ObjectSchema.create): record.account.status == 'churned' && record.account.id == 'acc_churned' is REJECTED with 'crm_account' declares no 'id', followed by a second false prescription, declare 'id' on 'crm_account'. The registry's field map carries the injected organization_id / audit / owner_id / owning_business_unit_id columns and never id, and resolvePredicateRelated judges declaredness against that map. ADR-0078 §6: a false prescription is worse than a missing rule; here two are chained, and the first is the one the ruling specified. Fix: admit the primary-key spelling to the declared set (it is already unconditionally in the projection, so the read set widens by nothing), and pin the ruled repair end to end through the engine.

  2. The dry-run seam widens the accepted channel beyond writers. engine.validate() runs no middleware for the target object (E9c: a principal with no positions receives the rule's verdict; the only middleware operation observed is the isSystem find on crm_account), and its one network ingress — POST .../data/:object/import with dryRun: true — is gated by enforceAuth (authentication) and enforceApiAccess (the object's enable exposure flags), never by the caller's CRUD on the object. On the real write path the security plugin's CRUD gate refuses before any rule runs, so the channel the ruling accepted — a caller inferring a value from which WRITES are refused — reaches only principals permitted to attempt the write. Wiring the elevated read into validate() (the previous round's finding 3) hands the same verdict, computed from a system-authority read, to any authenticated caller with no permission on the child object and no write attempted. That is wider than accepted, and it is new on this head: before it the preview evaluated only the caller's own payload. The validate() docblock's "no driver is touched" (engine.ts:10683) is also false now. Fix: resolve related in validate() only for a caller who passes the object's create / update gate (or leave the traversal unresolved there for a non-system caller, which refuses as the prior head did), correct the docblock, and pin that a caller without write permission gets no verdict from a traversing rule.

  3. The superseded acting-user semantics survive in the diff's own prose — in a published contract docblock and in the example app. EvaluateRulesOptions.related (rule-validator.ts:396, an exported interface that ships in the .d.ts) says the rows are "read under the ACTING USER so the referenced object's RLS and FLS apply. A field the user may not read therefore arrives ABSENT"; resolvePredicateRelated's header (engine.ts:6901, "Read as the ACTING USER, deliberately unlike parent") contradicts its own body ninety lines down; the insert site says "Read under the CALLER's context, not system" (:11472); the preview site says the "permission answer is the caller's own" (:10734); RelatedUnavailableReason (rule-validator.ts:537) lists "the acting user may not read it"; the unresolved refusal text offers "outside this caller's visibility" as a cause that cannot occur under isSystem; a detached docblock for hydrateRelated (a function that no longer exists) sits above TraversalScope (rule-validator.ts:2907) and is {@link}ed twice (:295, :404); both test headers state acting-user while the tests assert isSystem (engine-predicate-relationship.test.ts:12, rule-relationship-traversal.test.ts:133); the checklist item's source cites hydrateRelated and its history records "faults loudly on an unreadable parent" as a ruled outcome the same item now inverts; and the showcase rule (invoice.object.ts:176) tells every reader of the reference app that "a rep who cannot read the account ... the rule faults and the write is rejected" — the dogfood on this very head measures the opposite. The PM rewrote the PR body because three passages had become false; the same three falsehoods stand in the code. On a security-relevant elevation, in-code authority that states the reversal beside the decision is the trap PD [WIP] Add Chinese version of the documentation #13 names, and a published docblock that overstates enforcement is what ADR-0124 D4 calls a defect. Fix: bring every location above to the ruling's wording (the ⭐ SYSTEM authority block at engine.ts:6968 and the RelatedFieldBinding docblock already have it), delete the orphaned docblock and its links, and correct the checklist source / history.

Non-blocking:

  • The no-reference sentence ("this record stores no reference there") is also reached by a multi-valued reference (E7) and by an expanded object supplied in the FK slot (E6); both refuse — correctly fail-closed — but the sentence does not describe what happened. Reword per cause.
  • Add @objectstack/lint to the changeset (see ②).
  • Body: correct the three passages named under "Body vs code".
  • The (c ? record.a : record.a).type receiver shape is refused with a bare No such key and no prescription; acceptable, but the analyser could name it as a shape it will not serve.

Implemented-by: claude/issue-18682-predicate-relationship-traversal
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 (at-tier contract-review subagent of the dispatching seat)

VERDICT: FAIL


Generated by Claude Code

… retire the acting-user copy

Three review findings.

The repair the conflict refusal prescribes did not exist. It tells an author to
compare `record.<fk>.id`, and the engine refused that too — the primary key is
declared by the platform, not the author, so it is absent from every object's
field map — then prescribed declaring `id` on the related object, which is
equally impossible. The PK spelling now counts as declared for traversal
resolution; the read set widens by nothing, since `id` was already
unconditionally in the projection. Pinned end to end: take the message the
refusal emits, author the rule it asks for, require it accepted, and require it
to fire for real so `.id` is proven to resolve.

The dry run handed the accepted inference channel to non-writers. On the write
path the CRUD gate runs in middleware, so only someone who could write could
observe a rule's verdict — which is the bound the channel was accepted under.
`validate()` runs no middleware for its target and its import ingress checks no
caller CRUD, so the elevated read reached any authenticated caller. It is now
behind the caller's own create/update gate, answered by the security plugin
with the same evaluator the CRUD gate uses. A caller who could not perform the
write gets no elevated read at all and the predicate refuses. The docblock no
longer claims nothing is executed.

The superseded acting-user semantics survived in the code after the visible copy
was corrected, including surfaces that ship: the exported
`EvaluateRulesOptions.related` docblock, the resolver header and both call
sites, the reason union, both test headers, the checklist source and history,
an orphaned docblock with two dangling links, and — worst — the showcase
example telling reference-app readers the opposite of what the dogfood on this
head measures. All swept.

Also: lint owes a changeset line, the no-reference sentence now names all three
stored shapes that reach it, and the computed-receiver traversal is documented
as deliberately unprescribed.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…-rule elevation

The related-record read for a traversing validation rule is a new elevation
site, so the census page's six declared counts moved 110 to 111. Mechanical,
regenerated with `gen:system-context-census`.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…edicate-relationship-traversal

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
…red by the caller's own read

A validation rule reading one hop through a reference field reads the
related row under system authority, scoped by the caller's organization.
A related object with no tenant column (sys_user), `tenancy.enabled: false`
or `external` is not scoped by any organization, so a rule could be
evaluated on a user the caller cannot read, and its pass/fail revealed
that user's field.

For a user caller, such a related object's rows are now the ones the
caller's OWN read returns, through every enforcement layer (CRUD, row-level
security, sharing). Any other stored id binds as 'unreadable' and the rule
refuses the write with the not-readable prescription; it is never
evaluated on that row and its columns are never read. Tenant-scoped
related objects and system callers are unchanged.

Pinned on the real SecurityPlugin + SqlDriver with the shipped
member_default sys_user wall, on insert, validate() and update.

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor

Contract review

Served-tier: 149/149 CONTRACT_REVIEW_TIER
Head-sha: 1e487ef1108a7eb6fd52cf173ddf80f364e307f7

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 149 transcript turns served at the tier the constant names. Round 16 (ruling A) at this head. The seat adopts the FAIL and orders round 17 on the blocking item ①(b); the Test Core (5/6) red is answered on the next head. Adopted by the seat 2026-09-24T16:20Z. The record below is the reviewer's, unedited except the two header lines.

Read: card #18682 body plus all 42 comments (rulings 5778121976, 5794029084, takeover 5814444837, letter A 5815452038; newest os-dev-report 5817378756, whose JSON equals scratchpad report.json exactly); PR body, 22 files, diff, 15 issue comments (review threads: none), previous PASS 5795813514; check-runs at the head, 53f960f7a, 1d6866b26, 2c1011b01; actions runs 36012376955 and 36009378803; AGENTS.md, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs at origin/main. Git reads on refs/review/pr-19728 (== head), 53f960f7a, 3b9c5f2fca, 2c1011b01 (merge-base with origin/main ccccdcc35e), 6eaa0f4a8. NOT RUN: any suite, gate family or ablation. Job log: actions/jobs/107676450261/logs answered CONNECT 403, NOT MEASURED.

① Derived judgments

Round shape. git diff --stat 53f960f7a refs/review/pr-19728: 4 files, +113/−13 (engine.ts +22/−6, rule-validator.ts +2/−2, predicate-related-read-tenant-scope.test.ts +83/−5, changeset +6). Merge 53f960f7a (parents 3b9c5f2fca, 2c1011b01): the diff-of-diffs of git diff 6eaa0f4a8 3b9c5f2fca against git diff 2c1011b01 53f960f7a (index and hunk headers stripped) differs in exactly one CONTEXT line main changed (resolveMasterDetailParents(…, opCtx.context)); both read 22 files +4663/−69. (f) Nothing from round 15 is undone by the merge; the existing pin ['could not read', "'crm_account'"] is untouched; the round diff adds no isSystem: true literal (engine.ts:7409 is pre-existing context); the six pre-existing pins (test file 218–299) keep every assertion, only helpers were generalised.

(a) Gate set — RIGHT. engine.ts:7399 at head: caller?.userId && (targetSchema?.external != null || resolveTenantFieldName(targetSchema) === null). resolveTenantFieldName (packages/objectql/src/tenancy/system-write-organization.ts:196–210) is null iff tenancy.enabled === false, no fields, or neither the declared tenantField nor organization_id is a field; it is documented as, and I verified it to be, the twin of SqlDriver.computeTenantField (packages/drivers/driver-sql/src/sql-driver.ts, same three arms). buildDriverOptions (engine.ts:4505–4508) withholds tenantId for isTenancyDisabled and federated objects, and the driver scopes only when computeTenantField is non-null — so the gate fires exactly for the objects the system read leaves unscoped by organization; nothing the ruling covers is left out on that axis. sys_user: managedBy: 'better-auth' (packages/platform-objects/src/identity/sys-user.object.ts:51), no injected organization_id (registry.ts:308–312), census reach: out, tenantField: null (scripts/platform-object-tenancy-census.json:634–640), so the gate fires. The ruling's parenthetical spelled sys_user as tenancy.enabled: false; the gate covers the real mechanism plus that spelling plus external. A tenant-scoped object whose RLS is narrower than the wall is NOT gated and evaluates on the org-scoped system read: RIGHT against the rulings as written — letter A's last sentence ("The organization-scope rule for tenant-scoped objects is unchanged (already pinned)") and the maintainer's 5778121976 (system read, named columns only, inference cost accepted) govern that class; only the platform-global class was put to the maintainer. Swept in beyond the named case: external objects (no wall by construction) — a harmless narrowing under the governing clause.

(b) Own read — RIGHT for a caller carrying userId, ⛔ WRONG for one reachable caller class.
Right: engine.ts:7400–7402 this.find(target, { where id $in ids, fields ['id'], context: caller }) with caller = context (7314); every call site hands the caller's raw options context (insert 11334–11339 and update 12340–12345 context: options?.context; validate 11235 options?.context; bulk update 13632). find() dispatches through executeWithMiddleware with mergeReadContext(query.context, options.context) (10717–10721); plugin-security's only total bypass is opCtx.context?.isSystem (security-plugin.ts:1880). No system flag reaches the own read. The system read of the named columns is limited to $in (ownRead ?? ids) (7407) and is skipped entirely when ownRead.size === 0 (7404).
Wrong: both the org-less gate (7315 readsNothing = !!caller?.userId && …) and the own-read gate (7399) key on caller?.userId. A non-system caller with no userId gets the bare system read: no row bound (gate off) and no organization bound (7409 spreads the caller context; 4505–4506 send no tenantId because it has none). Such a caller reaches the insert executor from the wire on main today: the public-form route builds { publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true } with no userId (packages/rest/src/rest-server.ts:10750–10752); the middleware's public-form arm admits insert on that object and ends in return next() past every downstream gate (security-plugin.ts:1890–1929); the form's allowedFields is whatever its sections declare, a lookup or user field included (rest-server.ts:10678). A principal-less context with no positions, no permission sets and no userId falls open outright (security-plugin.ts:2032–2038). Consequence at the head: a form target with a traversing rule — the PR's own showcase example record.account.status == 'churned' (examples/app-showcase/src/data/objects/invoice.object.ts:187) — lets an unauthenticated submitter choose the FK and read one bit of the related row per submission, 422-versus-201, across every organization for a tenant-scoped object and for any sys_user row. That caller's own read of the related object returns nothing (the grant arm allows find only on the form's own object, 1893–1896; guest_portal otherwise resolves closed), so letter A's "⛔ It never evaluates on a row the user could not read" is not met, and it is the round-14 leak class (org-less caller, cross-organization) with the userId spelling as the hole. Not introduced by this round — the userId gate dates from round 14 and round 15's PASS considered only "a system context that carries a userId" — but this record judges the whole PR against the ruling's words, and the maintainer rejected landing an unmeasured cross-organization inference. Source-read only; NOT MEASURED at runtime (⛔ no suites here); every link is a line above. The property owed: a caller that is not isSystem is bound by its own read (or reads nothing) whether or not it carries userId; system callers unchanged.

(c) Refusal identity — RIGHT for userId callers. On a gated object every id outside ownRead binds 'unreadable' (7452), stored or not; a thrown own read binds blocked then 'unreadable' (7426–7430, 7443); traversalRefusal (rule-validator.ts:3343–3348) emits one text — could not read 'OBJECT' … "and that row could not be read" — never the value. Rule evaluation precedes assertReferencesResolve on insert (11973 before 11974) and update (13415 before 13418), so the pre-existing existence probe (on main at 2c1011b01, 11 hits) cannot split "exists elsewhere" from "nowhere" ahead of the rule. validate() reuses the same resolver (11235), so insert, preview and update refuse alike.

(d) Pins — weight-bearing by reading. predicate-related-read-tenant-scope.test.ts:309–345: pin (a) asserts clear.seen equals banned.seen over refusal, committed, preview and update, with u_y.banned = (kind === 'secret') (164–168), NOT_READABLE on refusal, update and preview, committed 0, and userColumnsRead never carrying banned; CONTROL (b) has peer u_x2 hit RULE_MESSAGE on all three doors with banned projected, and self commit. Harness: real SysUser, shipped member_default sys_user grant and RLS read from defaultPermissionSets (89–101), real SecurityPlugin + SqlDriver, posture isolated; banned is a declared field (sys-user.object.ts:647). The ablation readings G/H/I/K/A/B are the report's claims; each is consistent with the code by reading (G/H drop the gate so u_y is read unscoped; I yields the "not found" text; K makes the peer unreadable). None covers the (b) class above.

(e) Prose. Changeset lines 61–65, RelatedUnavailableReason docblock (rule-validator.ts:546), engine docblock 7277–7278 ("also bounds the ROWS") and the runtime text are true for a userId caller. body-edits.md: bullet-2 replacement sentences 1–3 true; sentences 4–5 ("read only for the rows the user caller's own read … whatever that row holds, and its columns are never read") true for a caller carrying userId, false for the public-form caller in ①(b); "All three are pinned … the sys_user case against the shipped member_default wall" true (roster 218/246/277/309). Bullet-3 append and the 维护者速读 bullet: true with the same caveat.

② Semver level

.changeset/18682-predicate-relationship-traversal.md at head: minor on @objectstack/formula, @objectstack/lint, @objectstack/objectql, @objectstack/plugin-security; Clause-②: yes (widening); no major, no BREAKING banner, no ADR-0087 marker. Correct: against the merge-base the capability is new (a widening), and this round's narrowing lives inside the unreleased PR, so neither the launch-window BREAKING banner (check-changeset-no-major.mjs:47–74) nor the HTML-comment marker adr-0087: registered … / adr-0087: not-required (category) why (check-adr-0087-registration.mjs:60–63) is owed. Consistent with AGENTS.md §3 (yes takes at least minor).

③ Boundary flags

Blocking:

  • ①(b): a non-system caller without userId (public-form context rest-server.ts:10750–10752; fall-open arm security-plugin.ts:2032–2038) bypasses both the org-less gate (engine.ts:7315) and the own-read gate (engine.ts:7399) and evaluates a traversing rule on rows it could not read, unbounded by organization or row.

CI, stated plainly: Test Core (5/6) FAILURE at the head; annotation "packages/client pnpm run test exited (1)"; failing test NOT MEASURED (log CONNECT 403). Green at the merge-only 53f960f7a (run 36009378803) and red at 1e487ef110 (run 36012376955), both with recorded base 2c1011b01, so the tree delta between green and red is exactly this round's four files. Can the diff reach packages/client? By source, no: client declares no validation rule (git grep over packages/client for validations or a traversing condition: 0), no platform-objects object carries a traversing rule (0 hits), and every changed runtime line sits behind collectPredicateRelationships(schema).size == 0 then unbound (7311–7312) and then caller?.userId && …; the rule-validator change is text inside the 'unreadable' arm only. Not ruled a flake; the red must be answered by a re-run or the log before landing. TypeScript Type Check failure at 1d6866b26 is the aggregator over cancelled sub-jobs of a superseded push; success at the head.

Non-blocking:

  • Dev-declared deviations (5817378756): frozen-lockfile install skipped then redone on zod 4.6.1 with a forced rebuild; merge message amended before first push; deepen fetch advanced shared origin/*; no second merge of main (origin/main now ccccdcc35e, merge-base still 2c1011b01; PR diff 22 files +4763/−69 = 4832, at most 5000, headroom 168); dangling ids on gated objects now 'unreadable' not 'unresolved' (deliberate, one text). All within the claim's 22 files; governed surfaces: none.
  • RelatedFieldBinding docblock (rule-validator.ts:561–567, shipped through the exported RelatedRecordBinding) still names only the projection as the bound, and row still says "readable declared fields" — omission, not false.
  • Changeset "for a user caller … refuses the write as not readable": an org-less user under a walled posture is refused as "not found" (readsNothing precedes the gate); the body's bullet says so, the changeset stays silent (carried from round 15).
  • Ablations not re-run here (⛔); readings are the report's.
  • Out of scope, noted by the dev and not filed: sys_scim_user / sys_scim_group blanket read; referenceExists platform-global existence probe (pre-existing on main).
  • The driver-sqlite-wasm half of "measured on driver-sql and driver-sqlite-wasm" remains a dev reading (carried from round 15).
  • Model identifiers: the three round commits carry the model-free trailer pair; round diff 0.

Implemented-by: claude/issue-18682-predicate-relationship-traversal
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: FAIL
Blocking item: ①(b) — a reachable non-system caller class (public-form and principal-less contexts, no userId) gets the bare system read for a traversing rule, unbounded by organization or row, so letter A's "never evaluates on a row the user could not read" is not met at 1e487ef1108a7eb6fd52cf173ddf80f364e307f7.


Generated by Claude Code

…r that is not system

Both bounds in resolvePredicateRelated keyed on `caller.userId`, so a caller
that is neither system nor a user (the public-form submitter, a
principal-less context) got the bare system read, bounded by neither
organization nor row. They now key on "not isSystem": under a walled
posture such a caller with no organization reads nothing, and on a related
object no organization wall scopes it is bound by its own read. System
callers are unchanged.

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
…ess "not found" case

The changeset said the own-read row bound applies to "a user caller"; it
applies to every caller that is not system. It now also states that under a
walled posture an org-less caller gets no related read and is refused as not
found. The exported RelatedFieldBinding docblock names the row bound beside
the projection, and `row` no longer claims an FLS-filtered ("readable") set.

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
…rsing rule's related read

`resolvePredicateRelated` reads `ExecutionContext.isSystem` to decide which
callers its two bounds hold, so it is an elevation read site: row 29b anchors
it, and the declared counts move 111 -> 112 (regenerated by
check-system-context-census --fix).

Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor

Contract review

Served-tier: 76/76 CONTRACT_REVIEW_TIER
Head-sha: 3e8b3c3a22a6a0b67e77668d9220d6cf7cce0c31

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session; every one of its 76 transcript turns served at the tier the constant names. Round 17 at this head; it closes the round-16 FAIL 5817932927 ①(b). The seat wrote the PR body from the round-17 report with both non-blocking corrections under ③ applied (the system-caller sentence now states the one system-side move; the pin sentence is narrowed to what is pinned). Adopted by the seat 2026-09-24T17:16Z. The record below is the reviewer's, unedited except the two header lines.

Read: card #18682 body plus all 43 comments (maintainer ruling 5778121976, order 5792704279, seat ruling 5794029084, landing state 5795844077, takeover 5814444837, letter A 5815452038, round-16 report 5817378756, round-17 report 5818517157, whose JSON equals scratchpad/pr-19728/report-r17.json in substance); PR body, pulls/19728/files (22), the diff, all 16 issue comments (review threads: 0, reviews: 0), the round-15 PASS 5795813514 and the round-16 FAIL 5817932927; check-runs at the head, 5b11234f94, 11ddc2dc0a; body-edits-r17.md; AGENTS.md, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, .github/workflows/lint.yml at origin/main (now e8f163fc3a). Git reads on refs/review/pr-19728 (== head), 1e487ef110, 5b11234f94, 11ddc2dc0a, 3b9c5f2fca, 2c1011b01 (merge-base with origin/main, unchanged). NOT RUN: any suite, gate family or ablation. Branch-protection endpoint answered 403 to this token, so "required" below is by the earlier records' naming.

① Derived judgments

Round shape. git diff --numstat 1e487ef110 refs/review/pr-19728: 5 files, +71/−21 (engine.ts +9/−6, predicate-related-read-tenant-scope.test.ts +43/−2, changeset +6/−3, rule-validator.ts +5/−3, system-context.mdx +8/−7). Per commit: 11ddc2dc0a engine.ts + test file only; 5b11234f94 changeset + rule-validator docblock only; 3e8b3c3a22 the mdx only. All five inside the PR's 22; no new file.

(a) Round-16 ①(b) — CLOSED. engine.ts:7317 at head const bound = !caller?.isSystem; feeds 7318–7319 (readsNothing = bound && !carriesOrganization(caller?.tenantId) && postureEnforcesWall(...)) and 7402 (if (bound && (targetSchema?.external != null || resolveTenantFieldName(targetSchema) === null))). At 1e487ef110 both keyed on caller?.userId (7315, 7399). Caller classes, each traced:

  • Public-form submitter: rest-server.ts:10750–10753 at origin/main builds { publicFormGrant, permissions: ['guest_portal'], anonymous: true }, no isSystem, no tenantId. Walled posture: readsNothing, byId empty with no ownRead (7375), binding 'unresolved' (7455), text "the related record was not found" (rule-validator.ts:3340). No wall: own read at 7403–7405 under the caller's context; the grant arm admits find only when opCtx.object === grantObject (security-plugin.ts:1892–1897 at the PR ref) and otherwise throws PermissionDeniedError (1931–1932); the catch at 7429–7433 sets blocked, 7446 binds 'unreadable', one text "could not read 'OBJECT'" (3346–3348). Update/bulk: the grant arm refuses before the engine — identical for both values.
  • Principal-less context (no userId, positions, permissions, isSystem): the middleware's fall-open (security-plugin.ts:2031–2038, !opCtx.context?.userId) makes its own read return whatever the driver holds; under a walled posture readsNothing precedes it. Consistent with letter A by the letter ("answered by what the acting user could read themselves").
  • Author code with no context: caller undefined, bound true — same two arms.
  • Spurious or non-boolean isSystem: every wire context is assembled with isSystem: false (core assemble-execution-context.ts:316; 'isSystem' at 121 is a server-decided entry field, never copied from a request); the schema is z.boolean().default(false) (spec execution-context.zod.ts:318); git grep over packages/examples/apps non-test sources finds no producer other than the literals true/false, pass-throughs and === true normalisations. runAs: 'user' hooks whose trigger has no user never reach the seam (engine.ts:16539 UnscopedHookApi, hook-run-as.ts:97–130). Action bodies ({ ...base, isSystem: true }, action-execution.ts:1485), hook/flow runAs: 'system', seeds and service self-writes are declared elevations, in scope of the maintainer's system-authority ruling. No caller class the census missed reaches a traversing rule with the bare read and no bound.

(b) Regressions — none against the rulings; one undeclared behaviour move. FK clear: buildReferentialFieldClear returns unbound at 7310, before both bounds; the pin file delete-reference-cleanup-system-identity.test.ts is byte-identical to 3b9c5f2fca. Tenant-scoped and round-16 pins: the test-file diff's −2 are the two posture type annotations (lines 121, 188); every assertion at 218–345 unchanged. No new isSystem: true literal in engine.ts (28 at both heads); the system read stays 7410–7412 with the caller's context spread, so a tenantId still scopes it (buildDriverOptions 4506–4509, 4524–4526). ⚠ Moved: a SYSTEM context carrying userId and no tenantId under a walled posture read nothing at 1e487ef110 (readsNothing keyed on userId) and now gets the bare system read (bound false). Reachable only through declared elevations over an org-less user (action body, hook runAs: 'system'); the r14 order's "do not change system callers" was spelled for callers with no userId, and the r15 PASS recorded the r14 behaviour as a functional deviation. Consistent with ruling 5778121976; not a widening for any untrusted principal. It is not declared: the report, commit 11ddc2dc0a and the body edit say "system callers are unchanged". Non-blocking.

(c) Census row 29b — TRUE and complete. The one new ExecutionContext.isSystem read is 7317; the row names both effects (no own-read row bound; a related read with no organization under a walled posture) and its parenthetical (tenantId still scopes) holds by buildDriverOptions. Anchor engine.ts#resolvePredicateRelated exists. Counts: 110 at the merge-base, 111 after the PR's own row 6b, 112 now; the gate runs inside Lint & Repo Gates (lint.yml:348–349), success at the head and failure at 5b11234f94, as the dev states. origin/main still reads 110 on that page and did not touch it since the merge-base.

(d) Pins — weight-bearing by reading. New describe at test file 352–384. Pin (a) 355–366: both callers under isolated on qa_note (tenancy disabled, so the org wall admits the write) against qa_line (tenant-scoped, so the own-read gate is off): ablation L (readsNothing back on userId) gives the bare read of line_y, so secret refuses with RULE_MESSAGE and public commits — open.seen differs, red. Pin (b) 368–377 under single (readsNothing false, own-read arm exercised): ablation M gives the system read of u_y, banned diverges and userColumnsRead gains 'banned' — red. CONTROL 379–383: ablation N (bound = true) makes the system caller read nothing, open.seen.committed 0 against the asserted 1 — red. Pin (a) is held to refusal by VALIDATION_FAILED, committed 0, preview invalid and readsOfLine [], not by equality alone. Ablation readings are the dev's; consistent with the code.

(e) Prose. Changeset 61–68: true (any non-system caller; "not found" under a walled posture). Engine docblock 7277–7278 and comments 7313–7315, 7397–7400: true. rule-validator.ts:546 (unreadable names both causes), 579–583 (row bound beside the projection), 590–591 (row no longer says "readable"): true — the round-16 non-blocking notes are closed. Body edits, sentence by sentence: bullet-2 S1 (system authority, named fields plus id) TRUE; S2 (org carried; 0 rows under isolated) TRUE for driver-sql, the driver-sqlite-wasm half carried unverified as the dev declares; S3 ("Two bounds hold every caller that is not system ... leave system callers unchanged") TRUE as "the bounds do not apply to a system caller", with the (b) move undeclared; S4 (walled + no org: no read, references unresolved) TRUE at 7318–7319, 7375, 7455; S5 (unwalled related object: only the own read's rows, others not readable, columns never read) TRUE at 7402–7410, 7455; S6 (public-form grant admits the form's own object only; principal-less reads through the fall-open) TRUE at security-plugin.ts:1892–1897, 1931–1932, 2031–2038; S7 ("All of these are pinned ...") OVERCLAIMS: pinned are the org scope (218–243), the org-less no-read for a user (246–307), for the public-form and principal-less callers (355–366), the sys_user own-read bound for a user (309–348) and the public-form refusal (368–377); NOT pinned are the principal-less fall-open own read (a deleted probe only) and the tenancy.enabled: false / external arms of the gate as RELATED objects (every pin's related object is qa_line or sys_user); "against the shipped member_default wall" is true for the user case (89–101), while the public-form case is decided by the grant arm. Bullet-3 append TRUE (7455 for any id outside ownRead, stored or not; 7429–7446 for a refused read; one text at 3346–3348). 维护者速读 bullet-2 replacement TRUE. New bullet TRUE with S3's caveat on 「系统调用不变」; 「没有身份的内部调用 ... 只读调用者自己读得到的那一行」 is true by the letter (under single the fall-open returns every row).

(f) Budget. git diff --numstat 2c1011b01 refs/review/pr-19728: 22 files, +4813/−69 = 4882, at most 5000 (headroom 118); the file list equals the PR API's 22 exactly; merge-base still 2c1011b01.

CI at the head, stated plainly: 35 check-runs, 35 names, 33 success, 2 skipped (Console Pin Gate; Packed-tarball smoke (opt-in)), 0 failure, 0 cancelled. Lint & Repo Gates, TypeScript Type Check, Test Core (all six shards), Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard: success. Matches the dev's 33/2/7. Intermediate heads: 11ddc2dc0a superseded (19 cancelled, aggregator failure); 5b11234f94 Lint & Repo Gates failure and the TypeScript aggregator over cancelled shards. The round-16 Test Core (5/6) red did not recur; its failing test name stays NOT MEASURED by anyone.

② Semver level

.changeset/18682-predicate-relationship-traversal.md at head: minor on @objectstack/formula, @objectstack/lint, @objectstack/objectql, @objectstack/plugin-security; Clause-②: yes (widening); no major, no BREAKING banner, no ADR-0087 marker. Correct: against the merge-base the capability and canWriteObject are additive, and this round's narrowing lives inside the unreleased PR. AGENTS.md:1072–1073 (yes takes at least minor; (narrowing) is BREAKING) and 1083–1085 (marker only for a breaking changeset, forms adr-0087: registered ... / adr-0087: not-required (...) per check-adr-0087-registration.mjs:60–63); check-changeset-no-major.mjs:5–9 forbids major in the launch window. packages/spec untouched, so no spec changeset is owed; the other touched packages are private or docs. Check Changeset: success.

③ Boundary flags

Blocking: none.

Non-blocking:

  • Undeclared behaviour move (①(b)): a system context with userId and no tenantId under a walled posture now gets the bare system read instead of no read. Within the maintainer's system-authority ruling; the report and the body-edit sentence "leave system callers unchanged" should say "the bounds never apply to a system caller".
  • Body-edit S7 "All of these are pinned" overclaims as itemised in ①(e); suggested: "The organization scope, the org-less no-read (user, public-form and principal-less callers) and the sys_user own-read bound (user and public-form callers) are pinned in ...". PR body is not shipped prose.
  • Dev-declared deviations (5818517157): pushed before the gate run (5b11234f94 red on the census, fixed at the head); the mdx moved because the census gate forces an anchor; pins on qa_note rather than qa_inspection (an org-less non-system insert into a walled object is refused before any rule, ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED — out-of-scope finding, not filed); full suites at 5b11234f94 (the later commit touches only the mdx — verified); no merge of main this round. All accepted as stated.
  • Landing note: origin/main moved to e8f163fc3a; engine.ts, rule-validator.ts and security-plugin.ts also changed on main since 2c1011b01; git merge-tree --write-tree origin/main refs/review/pr-19728 is clean; the head's CI ran on the merge ref as of 16:36Z.
  • Pre-existing, disclosed by the dev, not filed: sys_scim_user / sys_scim_group blanket read; referenceExists platform-global existence probe; the public-form grant admits find on the form's own object with no row filter, so a self-referencing traversing rule on an unwalled form object is answered by that grant (by reading, not measured).
  • Within-organization reads of a tenant-scoped related object still evaluate on the org-scoped system read even where the caller's RLS is narrower — letter A's last sentence keeps that rule; the accepted channel of 5778121976.
  • system-context.mdx intro "the other hundred-and-four" was already stale at main (110 sites, same phrase); the PR moved the number and left the phrase.
  • Debts carried: the FK-clear prescription follow-up (round-11 Q1 option D) and the optional-lookup authoring trap, still unfiled.
  • Ablations not re-run here; readings are the dev's. The driver-sqlite-wasm half of "measured on" remains a dev reading.
  • Model identifiers: none in the round diff, the changeset or the body edits; the three commits carry the model-free trailer pair AGENTS.md:451–455 requires.

Implemented-by: claude/issue-18682-predicate-relationship-traversal
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 17:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 1f05ea4 Sep 24, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-18682-predicate-relationship-traversal branch September 24, 2026 17:38
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ization (objectstack-ai#19836)

Fixes objectstack-ai#19808
Clause-②: no

## What this changes

`ObjectQL.referenceExists`, the probe behind `assertReferencesResolve`
(the write-path lookup existence check), read under a bare `{ isSystem:
true }`. That context has no `tenantId`, so `buildDriverOptions` sent
the driver no tenant and the check looked in every organization.

The probe now runs under `ObjectQL.referenceCheckContext(context)`. This
is the `sudo()`-shaped `{ ...callerContext, isSystem: true }` that the
pre-delete reference check already uses, so both reference checks now
build their elevated context the same way:

- `isSystem` still skips RBAC/RLS/FLS. That is why the original check
was elevated: a user may link to a record they are not allowed to read.
- The caller's `tenantId` now reaches the driver. Under the `group`
posture, `accessible_org_ids` reaches it too and is widened into
`tenantIds`.
- A record in another organization now gets the same
`reference_not_found` refusal as a record that exists nowhere.
- `buildDriverOptions` still sends no tenant for `tenancy.enabled:
false` objects and for federated objects. References to those objects
keep working from any organization.

`assertReferencesResolve` now passes its `context` (already its 4th
parameter) to the probe. All three call sites (insert, update by id,
bulk update) already passed `opCtx.context`, so no call site changed.
The dangling-reference audit calls the probe without a context. It gets
`referenceCheckContext(undefined)`, which is `{ isSystem: true }`, the
same as before. The audit's behaviour does not change.

Code changes are confined to `assertReferencesResolve` and
`referenceExists` (one argument, one context expression, rewritten
docblocks). The docblock section that explained why the probe ignored
tenancy now says why it skips RLS but keeps the tenant filter.

## The card's first step: measured before the fix

Question from triage: after the cross-organization reference is stored,
can the org-X caller read any field of the org-Y row through any read
path?

Measured on `origin/main` c118524. The setup was a real
`SecurityPlugin` (posture `isolated`, `org-scoping` on) over a real
`ObjectQL` over `SqlDriver` (better-sqlite3 `:memory:`). The test was a
scratch file under `plugin-security`, which aliases
`@objectstack/objectql` and `@objectstack/driver-sql` to source. The
file was deleted after the run and never committed.

| read path, org-X member, stored contact.account = org-Y account |
result |
|:--|:--|
| `find` with `expand: { account: {} }` | `account` stays the bare id
`acc_y`, no fields |
| `find` with `expand: { account: { fields: [name, secret, status] } }`
| bare id `acc_y`, no fields |
| `findOne` with `expand` | bare id `acc_y`, no fields |
| direct `find` of the org-Y account | `[]` |
| roll-up `summary` on the org-Y parent (count of contacts) | org-Y
`contact_count` stays 0. The org-X insert threw `SummaryRecomputeError`
after the row was written, because the recompute's update is
tenant-scoped and cannot find the parent |
| `count` of the org-Y account | 0 |
| master-detail header with a `requiredWhen: parent.status == 'locked'`
detail field, note omitted | header = org-Y locked row: `note:
required`. Header = org-Y open row: **write committed** |

So no read path returned an org-Y field value. The last row is
different: a parent-scoped predicate over an org-Y header can be
observed. `resolveMasterDetailParents` / `resolveMasterDetailParent`
read the header under a bare `{ isSystem: true }` too, and this oracle
**survives this fix**. After the fix: locked header → `note: required`,
open header → `reference_not_found`. The two answers still differ,
because `evaluateValidationRules` runs before `assertReferencesResolve`.
This is reported to the PM as a separate finding. It is not changed
here: it is outside this card's two methods, and open PR objectstack-ai#19728 also
edits `engine.ts`.

## After the fix: same harness

| write, org-X member | before | after |
|:--|:--|:--|
| lookup to a row only in org Y | committed (plus
`SummaryRecomputeError` on the roll-up) | `VALIDATION_FAILED` /
`reference_not_found` |
| lookup to an id that exists nowhere | `VALIDATION_FAILED` /
`reference_not_found` | same |
| lookup to an org-X row | commits | commits |
| lookup to a `tenancy: { enabled: false }` row | commits | commits |
| lookup to an org-X row of an object the member may NOT read
(per-object `allowRead: false`, direct read = 403 `PERMISSION_DENIED`) |
commits | commits (RLS still skipped under the real SecurityPlugin) |
| lookup to an org-Y row of that unreadable object | commits |
`reference_not_found` |
| lookup to a NULL-organization row of an object exempted only by the
deployment's `platformGlobalObjects` | commits | commits |
| lookup to an org-Y-stamped row of that deployment-exempted object |
commits | `reference_not_found` |

The last row is a behaviour change, and it is the hazard the dispatch
asked me to measure. The driver still filters a
`platformGlobalObjects`-exempted object by organization (objectstack-ai#15831, open,
`pm:blocked`). So the probe now agrees with what the caller's own `find`
of that object already returns (measured: only the NULL-organization
row). This PR does not work around it. The changeset tells deployments
about it.

## Tests

New file `packages/objectql/src/engine-reference-tenant-scope.test.ts`,
15 cases. objectql cannot import `driver-sql`, so the test driver copies
`SqlDriver.applyTenantScope`: it filters on `DriverOptions.tenantId`,
keeps `OR organization_id IS NULL`, and honours the `tenantIds` union.
It also records every call's options.

- Refusal, on all three doors (insert, update by id, bulk update):
`ValidationError`. `resolveThrownHttpError` reads `{ status: 400, code:
'VALIDATION_FAILED' }` and the fields are `[{ field: 'account', code:
'reference_not_found' }]`. Nothing is written or repointed.
- Oracle shut, on all three doors: "only in another organization" and
"exists nowhere" give the same envelope. The messages are also identical
once the caller's own id is removed.
- Controls: a same-org reference commits on all three doors. A
`tenancy.enabled: false` target commits, and its probe's driver options
carry no `tenantId`; the row is stamped with another org on purpose, so
it passes only because the engine withholds the tenant. An `isSystem`
write stays unchecked and runs no probe.
- Wiring checks: the probe's operation context is `{ isSystem: true,
tenantId, userId }` and the driver sees `tenantId`. Under the `group`
posture the membership union reaches the probe. The audit's unscoped
probe is pinned, so any change to its behaviour has to be a deliberate
decision.

Ablation, run on committed HEAD 92662fe through
`scripts/ablation-replace.mjs`, with an EXIT/INT/TERM trap that restores
and checks the blob hash. The test imports `./engine.js` from source, so
no dist build was involved.

- Leg A: the probe context was changed back to the pre-fix bare `{
isSystem: true }`, with an injected marker. On disk: anchor 1 → 0,
marker 0 → 1. Result: **7 failed / 8 passed**. Failed: refusal ×3,
oracle ×3, probe wiring.
- Leg B: a hand-picked `{ isSystem: true, tenantId }` with no spread.
Result: **2 failed**: probe wiring (`userId` missing) and `group`
posture (a legitimate reference refused).
- Restore after each leg: blob equals HEAD (`4ac24d149603`) and `git
diff HEAD` is empty. Control run afterwards: 15/15.

## Local verification (final head 19624a5)

- `pnpm --filter @objectstack/objectql test`: 304 files / 5072 tests
passed. The trailing `-- --maxWorkers=2` in my command was dropped by
vitest; the whole package ran, which is what I intended.
- `pnpm --filter @objectstack/objectql typecheck`: exit 0.
`check:test-typecheck` OK with the ledger unchanged (40 files / 234
errors / 65 signatures). `tsc --listFilesOnly -p tsconfig.test.json`
includes the new test file.
- Downstream suites that combine a real engine, tenants and lookups
(objectql `dist` rebuilt, or aliased to source): plugin-security 6 files
/ 88 tests, plugin-sharing 4 / 284, plugin-audit 4 / 83,
service-automation 1 / 6, service-settings 1 / 18. All passed.
- `node scripts/pm/dispatch-gates.mjs --commands` listed 64 commands.
All were run on 19624a5, with each exit code captured before any
pipe. 62 exited 0. `--ran` verdict: `64 derived famil(ies) accounted for
— 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`.
- NOT MEASURED: `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT
MET). It needs every package's `dist`, and a full workspace build does
not fit the 10-minute foreground limit. Narrower check instead:
`require()` of objectql's `dist/index.js` and `dist/core.js` both load
(exit 0).
- NOT MEASURED: `check:type-check-debt` (exit 3, PREREQUISITE NOT MET).
It needs 14 unbuilt workspace packages for the same reason. objectql's
own test layer is covered by `check:test-typecheck` above.
- `check:query-options-erasure` failed on the first pass: test surface
236 → 238, from two `as any` option bags in the new test. Fixed in
19624a5 by typing them. It is now at the ceiling (236).
- `check-engine-split-ratio --days 90` first refused on the shallow
clone. It passed after `git fetch --shallow-since=2026-06-18 origin
main`.

## Acceptance notes

- The `inspectDanglingReferences` docblock still says the audit "can
never be more or less strict than the rule it reports on". That is no
longer true for cross-organization references: the write check refuses
them, and the audit's unscoped probe does not report them. The
`referenceExists` docblock states this gap. The audit's docblock and its
behaviour are left alone, because both are outside this card's region
and the audit question is open with the PM.
- The docblock of `referenceCheckContext` still describes only the
pre-delete check. The write-path probe now uses it too. Left as is
(outside the region).
- Before the fix, a cross-organization child write under a roll-up
parent was written and then threw `SummaryRecomputeError`: HTTP 500
after commit. Non-system writes are now refused before the write.
`isSystem` writes that name another organization's parent were not
measured. Issue not filed; no current owner.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…g-reference audit to the organization (objectstack-ai#19854)

Fixes objectstack-ai#19837
Clause-②: no

## What this changes

**Leg 1: the master-detail parent binding.** `resolveMasterDetailParent`
(update by id) and `resolveMasterDetailParents` (insert and bulk update)
read the header that a detail object's `parent.*` predicates
(`requiredWhen`, `readonlyWhen`) are judged against. Both read it under
a bare `{ isSystem: true }`. That context has no `tenantId`, so the
driver found the header in any organization. Both now read under
`ObjectQL.referenceCheckContext(context)`, the `sudo()`-shaped `{
...context, isSystem: true }` that both reference checks already use
(claim-seat ruling):

- RLS and FLS are still bypassed, because a master-detail lock is a
property of the header's state.
- The caller's `tenantId` now reaches the driver, and so does the
`group` posture's membership union.
- A header outside the caller's tenant scope (another organization;
under `group`, one outside the caller's membership set) binds as absent.
That is the same binding a header that exists nowhere gets.

Both methods take a new `context` parameter. The five call sites pass
`opCtx.context`.

**Leg 2: the dangling-reference audit, option B (claim-seat ruling,
objectstack-ai#19808's ACCEPT).** `inspectDanglingReferences` used to probe through
`referenceExists` with no context, so the check ran across every
organization. Now each scanned row's reference is probed under that
row's own organization:

- The audit reads the column the object is tenant-scoped by
(`resolveTenantFieldName`), next to the references.
- It hands that value to the port's `probe` as a new optional third
argument.
- The engine's port turns the value into the probe's `tenantId`.
- A NULL-organization row, a `tenancy.enabled: false` object, and a
federated object's phantom (injected) organization column all probe
unscoped, as before.
- The run's probe memo is keyed per organization.
- **Except under a union posture.** Under `group`
(`postureUsesUnionScope(this.resolveEnginePosture())`, read once per
run) the probe stays unscoped, as before this PR. That is the seat's
decision from the patch round (see "The `group` posture" below). The
per-row organization applies in every other posture.

## A1: the oracle on insert, re-measured on `origin/main` after objectstack-ai#19836

This uses the harness shape objectstack-ai#19808's dev used: a real `SecurityPlugin`
(posture `isolated`, `org-scoping` on) over a real `ObjectQL` over
`SqlDriver` (better-sqlite3 `:memory:`). The detail field `note` has
`requiredWhen: "parent.status == 'locked'"`. The caller is bound to org
X and omits `note`. The harness was a scratch file under
`plugin-security/src`. It was never committed and was removed after
every run. "Pre" means `engine.ts` at `afc3b64928`, and "post" means
this branch at `5930c9898a`.

| write names… | pre | post |
|:--|:--|:--|
| org-Y header, `locked` | `VALIDATION_FAILED` · `note` `required` |
`VALIDATION_FAILED` · `header` `reference_not_found` |
| org-Y header, `open` | `VALIDATION_FAILED` · `header`
`reference_not_found` | same |
| a header id that exists nowhere | `VALIDATION_FAILED` · `header`
`reference_not_found` | same |
| control: org-X header, `locked` | `note` `required` | same |
| control: org-X header, `open` | commits | commits |
| control: NULL-organization header, `locked` | `note` `required` | same
|
| control: `tenancy: { enabled: false }` master, `locked` | `note`
`required` | same |

## A2: the update doors (NOT MEASURED on the card)

The caller is bound to org X and updates their own org-X detail rows.
"Stored" means that the row already holds an org-Y header, as a row
written before objectstack-ai#19808 or by an `isSystem` write would. "Bulk" uses
`where: { id: { $in: [...] } }, multi: true`. A scalar `where.id` routes
to the by-id branch even under `multi: true`.

| door, predicate | pre: org-Y `locked` | pre: org-Y `open` | post: both
|
|:--|:--|:--|:--|
| by id, repoint + write `memo` (`readonlyWhen`) |
`reference_not_found`; `onFieldsDropped` reports `memo` |
`reference_not_found`; no drop | `reference_not_found`; `memo` dropped
(`readonly_when`) |
| same, `strictReadonlyWrites` | `ERR_READONLY_FIELD_REJECTED` |
`reference_not_found` | `ERR_READONLY_FIELD_REJECTED` |
| by id, stored header, write `memo` | commits, `memo` kept, drop
reported | commits, `memo` **written** | commits, `memo` kept, drop
reported |
| same, `strictReadonlyWrites` | `ERR_READONLY_FIELD_REJECTED` | commits
| `ERR_READONLY_FIELD_REJECTED` |
| bulk, stored header, write `memo` | `memo` kept | `memo` **written** |
`memo` kept |
| by id, repoint, `note` stays null (`requiredWhen`) | `note` `required`
| `reference_not_found` | `reference_not_found` |
| by id, stored header, clear `note` | `note` `required` | commits |
commits |
| bulk, stored header, clear `note` | `note` `required` | commits |
commits |

Before the fix, every pair differed on at least one channel a caller can
see. After it, every pair answers the same.

## A3: does "absent" close the oracle on every door?

Yes, measured on every door. An org-Y header now takes the same path as
a header that does not exist. The two predicate kinds already handle an
unbound `parent`:

- `requiredWhen` is **fail-open** (objectstack-ai#4977 ruling: logged, skipped). On
insert and on a repoint, the header id is in the payload, so
`assertReferencesResolve` (objectstack-ai#19808) refuses it with
`reference_not_found`. On a stored header, the id is not in the payload,
so nothing is refused and the write commits. Either way, locked and open
give the same answer.
- `readonlyWhen` is **fail-closed** (objectstack-ai#4889: LOCKED). The field is
dropped, or refused under strict mode, whatever state the header is in.

The only differences left are server-side `warn` lines ("requiredWhen …
not bound … skipped", "readonlyWhen parent lookup…"). They do not reach
the caller, and they read the same for both states.

What this costs, stated in the changeset: a row that already stores a
header from another organization now edits as if its header were
missing. Its `parent`-scoped `readonlyWhen` fields stay locked, and its
`parent`-scoped `requiredWhen` rules are not enforced. Outside `group`,
leg 2 now reports exactly these rows.

## A4: the audit (leg 2)

Rows were inserted under `isSystem`, because the write path now refuses
a non-system cross-organization reference. `inspectDanglingReferences({
objects: ['m_contact'] })` was run over 6 rows in the same harness
(posture `isolated`; under `group` the audit probes unscoped, see
below):

| stored row | pre | post |
|:--|:--|:--|
| org-X row → org-Y account | not reported | **reported** in `dangling`
|
| org-X row → an id that exists nowhere | reported | reported |
| org-X row → org-X account | not reported | not reported |
| org-X row → a `tenancy.enabled: false` row | not reported | not
reported |
| org-Y row → org-Y account | not reported | not reported |
| NULL-organization row → org-Y account | not reported | not reported |

## A5: class sweep of `{ isSystem: true }` reads in `engine.ts`

`check-system-context-census` counts READS of `isSystem` (110 sites,
unchanged by this diff), not constructions, so I enumerated every
non-comment `isSystem: true` construction in `engine.ts` (12 sites) and
checked whether its target id comes from the caller:

| site (symbol) | shape | same defect? |
|:--|:--|:--|
| `resolveMasterDetailParent` / `resolveMasterDetailParents` | bare,
header id from the caller's payload or row | **yes, fixed here** |
| `referenceExists` via `referenceCheckContext`, and
`cascadeDeleteRelations` | spread (`sudo()`-shaped) | no, tenant kept |
| `recomputeSummaries` | `{ ...(execCtx ?? {}), isSystem: true }` | no,
tenant kept |
| `ObjectRepository.execute`, `ScopedContext.sudo()` | spread of the
caller's or scoped context | no, tenant kept |
| `buildSession` (`...(execCtx.isSystem ? { isSystem: true } : {})`) |
copies the caller's own flag onto the hook session | no, not an
elevation |
| `probeInstallOrganizations` | `sys_organization`, `limit: 2`, no id |
no, no caller id |
| `buildHookApi` fallback | used only when there is no caller context |
no, no caller |
| `readMigrationFlagVerified`, `recordObservedDeviation` (read + write),
`retractCreationAttestation` (read + write) | `sys_migration` flag row
by migration id | no, engine-owned id |

No other site in `engine.ts` has this shape, so nothing else is fixed
here and no class (a) item is filed from the sweep. For completeness I
also looked at the bare constructions elsewhere in
`packages/objectql/src`: the audit's own row read, `scan-value-shapes`,
the lifecycle sweep and `summary-backfill`. All of them are boot or
sweep reads with no caller id.

## Tests

`packages/objectql/src/engine-reference-tenant-scope.test.ts` (the
objectstack-ai#19808 file, with the same tenant-scoping driver double):

- **Leg 1**, new `describe`, 9 cases:
- Insert: org-Y `locked`, org-Y `open` and a header that exists nowhere
give the same envelope: `resolveThrownHttpError` reads `{ status: 400,
code: 'VALIDATION_FAILED' }` and the fields are `[{ field: 'header',
code: 'reference_not_found' }]`. The messages also match once the id is
removed.
  - Update by id, repoint: same envelope for both headers.
- `readonlyWhen` on a stored org-Y header, by id and bulk: the stored
value, the `onFieldsDropped` report and the strict envelope `{ status:
500, code: 'ERR_READONLY_FIELD_REJECTED' }` are all equal for both
headers.
- `requiredWhen` on a stored org-Y header, by id and bulk: both commit.
- Lit controls: an org-X `locked` header still gives `note` `required`,
and an org-X `open` one commits. An org-X `locked` header still locks
`readonlyWhen`, and an `open` one lets the write through. A
NULL-organization header binds. A tenancy-disabled master stamped with
another organization binds, and its read carries no `tenantId`.
- `group`: a member of org X and org Y inserting against the org-Y
`locked` header gets `note` `required` (the header binds inside the
membership set), and the header read reaches the driver with `tenantIds`
= both organizations. The lit control, a member of org X alone, gets
`header` `reference_not_found`.
- Wiring: the header reads are `find` (insert) and `findOne` (update).
Their operation context is `{ isSystem: true, tenantId, userId }` and
the driver sees `tenantId`.
- **Leg 2**: objectstack-ai#19808's pin, "the audit keeps its unscoped probe", was
written so that it moves only by decision, and the decision is now made.
It is replaced by three cases:
- A stored cross-organization reference is reported, and the probe's
driver `tenantId` is the row's organization.
- Lit controls: same-organization, NULL-organization, tenancy-disabled
and own-organization references are not reported, and in the same run an
id that exists nowhere is reported.
- `group`: a cross-organization reference to an EXISTING row is not
reported, because the probe is unscoped (no `rts_account` probe carries
a `tenantId`), while an id that exists nowhere still is. A lit control
runs the same stored rows under `isolated`, where both are reported. The
"audit REPORTS" case now names `isolated` explicitly instead of
inheriting the posture from the environment.


`packages/objectql/src/integrity/dangling-reference-audit.row-organization.test.ts`
(new, 5 cases) covers the audit-module half:

- the row's organization reaches the port, and `null` or `''` probes
unscoped;
- the memo is keyed per organization;
- a `tenancy.enabled: false` object probes unscoped;
- a declared `tenancy.tenantField` is read, and projected when nothing
else asked for it;
- a federated phantom organization column is not projected (objectstack-ai#8414's
projection is unchanged).

**Ablation.** Run on committed HEAD `5930c9898a` through
`scripts/ablation-replace.mjs` (WRAP mode), inside a script with its own
`EXIT`/`INT`/`TERM` trap that restores from `HEAD` and checks the blob
hash. The tests import `./engine.js` from source, so no `dist` build is
involved. The markers are comments, so they cannot change behaviour.

- **1a**: `resolveMasterDetailParent` back to the bare `{ isSystem: true
}`. The anchor went 1 → 0 on disk and the marker 0 → 1. Result: **4
failed / 27 passed** (update-by-id repoint, stored `readonlyWhen`,
stored `requiredWhen`, wiring).
- **1b**: `resolveMasterDetailParents` back to the bare elevation. The
anchor went 1 → 0 and the marker 0 → 1. Result: **3 failed / 28 passed**
(insert, bulk stored `requiredWhen`, wiring).
- **2** (re-run on `26421788b4` after the patch round): the engine's
probe port back to no per-row context in every posture. The anchor went
1 → 0 and the marker 0 → 1. Result: **2 failed / 29 passed**: the
`isolated` "audit REPORTS" case, and the `group` case's `isolated` lit
control (`:424`, expected `['ct_grp','ct_gone']`, received
`['ct_gone']`). The `group` half stays green under this mutation, as it
should, because the mutation is the decided `group` behaviour. The blob
was restored to `a69246368bc5`. Legs 1a and 1b were not re-run, because
the leg-1 code is unchanged since `5930c9898a`.
- **1c** (the `group` pin, run on `8b5c2cee52`):
`resolveMasterDetailParents` back to the bare elevation. The anchor went
1 → 0 and the marker 0 → 1. The new pin went red at its `tenantIds`
assertion (`:656`, expected both organizations, received `[]`). Its
`note` `required` envelope stays green under the mutation, because an
unscoped read also finds the org-Y header, so the `tenantIds` assertion
is the one that discriminates. The blob was restored to `956383b5ce5a`.
- After each leg the blob equals HEAD (`dce546fe7f5d`) and `git diff
HEAD` is empty. The control run afterwards passed 31/31. Every leg went
red, as predicted.

## Local verification (head `5930c9898a`)

**Re-run on `8b5c2cee52` after the second patch round:** the two touched
test files passed (32 tests), the objectql suite passed (305 files /
5089 tests), `typecheck` exited 0 with the ledger unchanged,
`check-system-context-census` exited 0, `check:query-options-erasure`
exited 0, and `node scripts/check-issue-citations.mjs` exited 0 (14
citations, all resolve), and `node scripts/check-changeset-no-major.mjs
--base origin/main` exited 0. The full 64-command sweep and the `--ran`
verdict below are from `5930c9898a`; they were not re-run.

- `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2`: **305 files / 5088 tests passed**.
- `pnpm --filter @objectstack/objectql typecheck`: exit 0.
`check:test-typecheck` is OK with the ledger unchanged (40 files / 234
errors / 65 signatures). `tsc --listFilesOnly -p tsconfig.test.json`
lists both test files.
- Downstream: the plugin-security master-detail / `controlled_by_parent`
suites (8 files / 154 tests) passed against source objectql (aliased).
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived **64** commands from the tree at
`5930c9898a` (merge base `afc3b6492`). All were run, with each exit code
captured before any pipe; 62 exited 0. The `--ran` verdict: `64 derived
famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a
recorded exit 3)`.
- Named by the dispatch: `check-system-context-census` exit 0 (`110
elevation read sites in 20 packages across 45 files`),
`check-platform-object-tenancy-census` exit 0 (`84 platform-namespace
objects`), `check:org-identifier` exit 0, and
`check:query-options-erasure` exit 0 (test surface 236, at the ceiling;
non-test 67 sites, none new).
- NOT MEASURED: `check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT
MET: it needs every package's `dist`). As a narrower check, `require()`
of the rebuilt `packages/objectql/dist/index.js` and `dist/core.js`
loads (exit 0).
- NOT MEASURED: `check:type-check-debt` (exit 3, PREREQUISITE NOT MET,
for the same reason). objectql's own layers are covered by `typecheck`
above.
- Lint, narrowed and proven: `eslint --no-inline-config --format json`
over the 4 touched `.ts` files counted 4 files linted, 0 errors and 0
warnings, and none of the files is ignored. `eslint.config.mjs` sets no
`parserOptions.project` and registers no typed rule, so the lint is not
type-aware and this diff cannot change the verdict on any file it does
not touch. The repo-wide `pnpm lint` is CI's.
- Dogfood (`showcase-readonly-when-parent`,
`federated-sweep-projections`): NOT MEASURED locally, because they need
the full showcase build. The `Dogfood Regression Gate` in CI runs them.

## Deviations from the declared file surface

- **Five call sites** (insert ×1, update by id ×2, bulk ×2) gained `,
opCtx.context`. The two methods had no context at all, so the ruled cure
could not be written inside them alone. Each edit is one argument on a
line that already called the method.
- **`referenceExists`'s docblock**: one comment paragraph was rewritten.
It said that the audit calls the probe with no context, which leg 2
makes false. No code changed there.
- **`postureUsesUnionScope`** is imported into `engine.ts` from
`@objectstack/spec/security` (patch round), placed at the end of that
import list so it does not collide with objectstack-ai#19728.
- **`integrity/dangling-reference-audit.ts`**: the port interface, one
helper and the scan loop. This is "the audit's probe port" that the
claim names.
- **Neighbours**: `git merge-tree --write-tree` was run in a throwaway
bare repo that shares the object store and has no `os-regen` driver
registered. At `8b5c2cee52` it is **clean** against objectstack-ai#19728's head
`3b9c5f2fca` (exit 0) and against `origin/main` `2548ba57de` (exit 0).
The first patch-round push collided textually with objectstack-ai#19728 on the
`@objectstack/spec/security` import, so `26421788b4` puts
`postureUsesUnionScope` at the end of that list. objectstack-ai#19840 has merged.
- The scratch measurement harness lived under
`packages/plugins/plugin-security/src` (outside the surface) for the
length of each run. It was never committed, and a trap removed it.

## The `group` posture (decided)

Under `group`, the write rule's reach is the **writer's** whole
membership set (`accessible_org_ids` becomes `tenantIds`), and the
stored row does not record it. A probe scoped to the row's own
organization would therefore be stricter than the rule. It would report
every cross-organization reference a member of both organizations
legitimately wrote, on every lifecycle sweep of healthy data. The seat
decided: under a union posture the probe stays unscoped, as before this
PR, and every other posture keeps option B. The first contract review
(FAIL, comment 5794714685) found the changeset's audit sentences
unqualified by posture; the second patch round (`8b5c2cee52`) qualifies
them.

The blind spot this leaves is stated in the `inspectDanglingReferences`
docblock and the changeset. Under `group`, a stored reference into an
organization that no writer of that row could reach (a write made before
objectstack-ai#19808's guard existed, an `isSystem` write, or a membership since
revoked) resolves and is not reported. Only a reference that resolves
nowhere is.

## Acceptance notes

- `referenceCheckContext`'s docblock still describes only the pre-delete
check, even though it now serves four sites. This is left alone because
it is outside the region.
- The audit keys the union exception on `resolveEnginePosture()` (the
injected provider, then `OS_TENANCY_POSTURE`). `buildDriverOptions`
widens to the union only when an injected provider returns `'group'`. So
in a lean embedding with an env-only `group` posture, the audit probes
unscoped while the write rule stays equality-scoped: less strict, never
stricter. This is not changed here, and nothing is filed, because the
reach is not measured.
- The engine envelope for `ERR_READONLY_FIELD_REJECTED` resolves to
status 500 through `resolveThrownHttpError`, because the error declares
no status. The REST mapping was not measured, and nothing is filed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…the write stores (objectstack-ai#19853) (objectstack-ai#19877)

Fixes objectstack-ai#19853

Clause-②: no

## What a caller could do before, and what happens now

**Before.** On a master-detail child whose parent field is `readonly:
true`, a caller with edit rights could change a field locked by a
`parent`-scoped `readonlyWhen` by naming a different, unlocked parent in
the same update. The engine resolved `parent` from the FK the payload
named, judged the lock against that header, and only afterwards stripped
the read-only FK — so the row stayed under the locked parent with the
locked field rewritten. The same happened when the FK carried its own
`readonlyWhen` lock that kept the row where it was, and on bulk (`multi:
true`) updates for every matched row.

**Now.** The engine settles whether the update really moves the row
BEFORE it judges any `parent`-scoped lock, and judges every lock (and
`requiredWhen`, which shares the binding) against the parent the row is
stored under afterwards. A legitimate move — FK writable, or an
`isSystem` / `preserveAudit` write the static strip exempts — is still
judged against the parent it moves to.

## A1 — reproduction on `origin/main` 2bbb462, engine untouched

Real `ObjectQL` engine, in-memory driver (the suite's own), the card's
shape: `inv_line.amount` has `readonlyWhen: "parent.status == 'paid'"`,
`inv_line.invoice` is `master_detail` with `readonly: true`, `l1`
(amount 100) under paid `inv_a`, `inv_b` open.

- `update(l1, { amount: 999, invoice: 'inv_b' })` committed and stored
`{ invoice: 'inv_a', amount: 999 }` — reproduced.
- Control `update(l1, { amount: 555 })` — locked, amount stayed 100.
- Same run, the other arms: FK's own record-scoped lock → stored
`amount: 999` under `inv_a`; FK's own parent-scoped lock → same; bulk
stripped repoint → same per row; reverse direction (naming the PAID
invoice for a line under an open one) → the amount was dropped
(over-lock); `requiredWhen: "parent.status == 'paid'"` → a note-only
edit of an open line naming the paid invoice was refused `po_ref is
required`, and clearing `po_ref` on a paid line by naming the open one
was accepted.

## A2 — every strip that can move the FK on this path, from the code

Order on both update branches, after the before-phase hooks: primary-key
strip (`id` only) → `readonlyWhen` strip → static `readonly` strip
(skipped for `isSystem`) → `assertNoStrictDrops` →
`evaluateValidationRules` → reference check → driver.

- **`readonlyWhen` strip** — can take the FK when the FK carries its own
lock. Ran AFTER `parent` was resolved from the named FK.
- **Static `readonly` strip** — takes a non-system caller's FK when it
is `readonly: true` (unless `preserveAudit` keeps it, or a hook wrote
it). Ran after the `readonlyWhen` strip.
- Not strips: field-level security refuses a forbidden FK outright
(plugin-security step 2.5 throws `PermissionDeniedError`); the objectstack-ai#16344
hide pass withholds static-readonly values from hooks and restores them
before any engine-owned read (net zero); `beforeUpdate` hooks,
`normalizeMultiValueFields` and `validateRecord` all run before `parent`
is resolved, so their effect is already in the payload the resolution
reads; the primary-key strip touches `id` only.

## The fix — `packages/objectql/src/engine.ts`

`settleMasterDetailLanding` (one private method, used by the by-id and
the bulk branch) runs before the `readonlyWhen` strip:

1. the FK's own `readonlyWhen` lock is judged ALONE (the strip's
`supplied` subset holds only the FK) against the header the FK names —
objectstack-ai#4889's verdict for the FK itself, byte-identical to before; the strip
that judges the other fields is then handed a `supplied` without the FK,
so that verdict is never re-asked against a different header;
2. the static strip's verdict on the FK is asked of the same
`stripReadonlyFields`, silently, with the same `supplied`,
`hookWrittenKeys` and `preserveAudit`; its `isSystem` gate is read once
per branch into a const that both the settlement and the real strip
consume (keeps the system-context census at 110 sites, and the two
cannot disagree);
3. `parent` is resolved from the view the write stores — the payload, or
the payload without the FK — so `masterIdOf` falls through to the prior
row's FK when the repoint does not land.

The `requiredWhen` non-regression pre-check's "does this write repoint"
is asked of the same stored view. The header read keeps `opCtx.context`
(the objectstack-ai#19837 tenant scope) exactly as it was.

## A3 — what moves

- `parent` for every `parent`-scoped `readonlyWhen` and `requiredWhen`
is the header the row is stored under (the fix).
- `onFieldsDropped` for the card's write: `[{amount, readonly_when},
{invoice, readonly}]` (was `[{invoice, readonly}]`); a
`strictReadonlyWrites` refusal names `['amount', 'invoice']` with both
drops (was `['invoice']`).
- Over-lock removed: naming a locked parent beside a stripped FK no
longer drops a field of a row that stays under an open parent.
- `requiredWhen` moves with the shared binding, both directions (see the
declaration note below).
- Header reads: a stripped repoint reads the header the row keeps
instead of the named one (same count, pinned); the `requiredWhen`
pre-check no longer buys a second read for a repoint that does not land;
a `parent`-scoped lock on the FK ITSELF that refuses the landing costs
two reads (named, then kept) where it cost one.
- Warn-line order only: when the FK's own lock fires, its line now
prints before the other fields' lines. Report order, strict `fields`
order and message text are unchanged.
- Unmoved: the FK's own verdict; strip order; `isSystem` /
`preserveAudit` behaviour; every write whose payload does not name the
FK.

## A4 — the legitimate repoint

With `invoice` writable (`inv_line_free`): paid → open `update(f1, {
invoice: 'inv_b', amount: 999 })` is judged against `inv_b` and commits
both; open → paid `update(f2, { invoice: 'inv_a', amount: 999 })` is
judged against `inv_a` and drops the amount while the move lands. In
both the judged header is the one the row is stored under because the FK
lands: no strip takes it.

## Tests

New `describe` block in
`packages/objectql/src/engine-readonly-when-parent.test.ts` (16 cases):
the card, the control, the report, the strict envelope (`code`
`ERR_READONLY_FIELD_REJECTED`, `name`, `fields`, `drops`; the engine
error carries no HTTP `status` — that mapping is downstream and not
measured here), the reverse over-lock, the one-header read, `isSystem`,
`preserveAudit`, both legitimate repoint directions, the FK's own
record- and parent-scoped locks, both `requiredWhen` directions (refusal
asserted on `code` `VALIDATION_FAILED` + `fields`), and bulk in both
directions.

Measured on `0a538af9d7`:

- `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2` → 305 files / 5105 tests passed (exit 0).
- `pnpm --filter @objectstack/objectql test:repo` → 1 file / 5 tests
passed; `pnpm --filter @objectstack/objectql typecheck` → exit 0,
`check:test-typecheck: OK`.
- Ablation (committed fix; `engine.ts` restored to its `2bbb462335` blob
`ffcda81540` and verified on disk, `settleMasterDetailLanding` count 0):
the parent suite ran 11 failed / 27 passed — every new pin except the
five whose behaviour is unchanged (control, `isSystem`, `preserveAudit`,
both legitimate repoints). Restored with `git checkout HEAD --`, blob
`624fc1e38a` equals `HEAD:packages/objectql/src/engine.ts`, `git diff
HEAD` empty, rerun 38/38 passed. Script carried an EXIT/INT/TERM trap.

## Gates on `0a538af9d7`

- `node scripts/pm/dispatch-gates.mjs --commands` → 64 commands; all run
with exit codes captured before any pipe; `--ran` → `64 derived
famil(ies) accounted for — 64 run, 0 NOT-MEASURED`, exit 0.
- `check:dual-build-cjs-loads` and `check:type-check-debt` first
answered PREREQUISITE NOT MET (no workspace `dist`); after `turbo run
build` over `./packages/*` and `./packages/*/*` (72/72 tasks), both exit
0 (`type-check-debt` needed an objectql rebuild after the ablation
restore touched `engine.ts`'s mtime).
- `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks
on) → exit 0, 13 citations resolve.
- `node scripts/check-system-context-census.mjs` → exit 0 (110 sites).
It was red on the first commit (`d4496752a1`) for a new `isSystem` read
inside the helper; the second commit hoists the one read per branch
instead.
- `pnpm check:query-options-erasure` → exit 0.
- Lint, narrowed and proven: eslint's own config
(`ESLint.calculateConfigForFile` / `isPathIgnored`) puts `engine.ts` and
the test file in the linted population and ignores the changeset;
`eslint --no-inline-config --format json` over the three paths → 3
results, 0 errors, 0 warnings on the two linted files; the config
enables no type-aware linting and no cross-file rule (its only file
reads are two baselines this diff does not touch), so this diff cannot
move a verdict on any untouched file. `pnpm lint` itself is CI's.

## Neighbour PR objectstack-ai#19728

Textually disjoint: this diff does not touch the
`evaluateValidationRules` lines, the import line, the region after
`resolveMasterDetailParents` or `system-context.mdx`. Driver-free bare
probe (`git clone --bare --shared`, no merge driver registered):
`merge-tree --write-tree` of `0a538af9d7` against `3b9c5f2fca` → exit 0;
against `origin/main` `b940f32a56` → exit 0. The merged tree carries
both changes.

## Acceptance notes

- **Sibling on the `record` root, same defect class, not fixed here
(listed for the seat to file).** A record-scoped `readonlyWhen` that
reads a static-`readonly` field is judged against the caller's forged
value of that field, which the static strip then removes. Measured on
`0a538af9d7`: `status` `readonly: true`, `amount` `readonlyWhen:
"record.status == 'closed'"`, row closed; `update(t1, { amount: 555 })`
stays 100, `update(t1, { status: 'open', amount: 999 })` stores `{
status: 'closed', amount: 999 }`. Judging the conditional strip over the
post-static view would close both roots at once, but it moves reason
attribution for fields declaring both locks and the strict message text,
so it is a separate decision.
- objectstack-ai#4889's landing rule still decides the FK's OWN `parent`-scoped lock:
`invoice: { readonlyWhen: "parent.status == 'paid'" }` judges a move off
a paid invoice against the invoice it moves to. Documented behaviour,
unchanged here; noted, not filed.

## Declaration note for the contract review

The claim declares no widening; copied above as given. Measured movement
in `update()`'s refuse/accept answer, only when a payload names an FK
that a strip then takes back out and the object has a `parent`-scoped
`requiredWhen`: a write refused `VALIDATION_FAILED` against the named
header now commits (the row stays under a header that does not require
the field), and a write that cleared a required field under a header
that does require it is now refused. Every other movement is a strip
(the write still commits) or a strict refusal that was already a
refusal. Whether that requirement movement counts as an accept-set
change is the review's call.

**Seat's disposition (after the contract review):** `Clause-②: no`
stands, and `patch` is right. The accept-direction movement removes a
refusal the published text already negated.
`content/docs/data-modeling/fields.mdx` ("Locking a detail from its
master: `parent`") says: "`parent` = the record on the other end of this
object's master_detail field", and "The server binds `parent` on the
update path by reading the master the row points at (a repointing write
is judged against the master it lands on)". A row whose repoint never
lands points at, and lands on, its stored master, so refusing it against
the named header contradicted that text. The refuse-direction movement
is a restored guarantee (a requirement bypass now refused). Neither
moves an authorable key, an export or an error code.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… the write stores (objectstack-ai#19887) (objectstack-ai#19905)

Fixes objectstack-ai#19887

Clause-②: no

## What a caller could do before, and what happens now

**Before.** A caller with edit rights could change a field locked by a
`record`-scoped `readonlyWhen` by putting a forged value for a
statically `readonly` field in the same update. On `UPDATE` the
conditional strip runs before the static one, and it built `record` from
the payload it was handed, which still held the forged value. The lock
was judged against that value, the static strip then removed it, and the
row committed with its stored state kept and its locked field rewritten.
The same happened through a read-only master-detail FK read as
`record.invoice`, on bulk (`multi: true`) updates for every matched row,
and for an FK's own `readonlyWhen` lock (judged inside objectstack-ai#19853's
settlement) that reads a read-only field.

**Now.** Every `readonlyWhen` predicate on the update path reads the
payload the write STORES: a value the static strip will take back out is
replaced by the row's stored value before any predicate is evaluated.
The strips keep their order, so each still reports its own fields under
its own reason. Where the static strip keeps the value (an `isSystem`
caller, a `preserveAudit` write of a preservable field, a value a
`beforeUpdate` hook wrote or overwrote), the value is stored, and the
lock reads it exactly as before.

## A1 — reproduction on `origin/main` `dabf8d795e` (includes
`3bd221dfe1`, PR objectstack-ai#19877), engine untouched

Real `ObjectQL` engine, the suites' in-memory driver shape.

- **Shape 1** — `status` `readonly: true`, `amount` `readonlyWhen:
"record.status == 'closed'"`, `t1` = `{ status: 'closed', amount: 100
}`: `update(t1, { status: 'open', amount: 999 })` stored `{ status:
'closed', amount: 999 }`; `onFieldsDropped` reported only `[{ status,
readonly }]`; control `update(t1, { amount: 555 })` stayed 100. Bulk
(`where: { status: 'closed' }, multi: true`): both matched rows stored
`amount: 999`. **Reproduced.**
- **Shape 2** — `invoice` read-only `master_detail`, `amount`
`readonlyWhen: "record.invoice == 'inv_a'"`, `r1` under `inv_a`:
`update(r1, { invoice: 'inv_b', amount: 999 })` stored `{ invoice:
'inv_a', amount: 999 }`; control stayed 100; bulk the same. **Reproduced
— PR objectstack-ai#19877 does not reach it**: its settlement runs only when the
payload touches a `parent`-scoped lock or the schema has a
`parent`-scoped `requiredWhen`, and this lock is `record`-scoped, so
`landing` is undefined and the strip judged the pre-static payload as
before.

## A2 — the choice: the post-static VIEW, not a reordered strip

Two candidates, both measured on this branch.

- **(A) Reorder** — run the static strip first and judge the conditional
one over what it leaves. Measured as a variant of this head (by-id
branch reordered, no view option) over the nine readonly suites
(`engine-readonly-when-parent`, this PR's suite,
`engine-readonly-strict-writes`, `engine-readonly-strip-signal`,
`engine-strict-readonly-warning-truthful`,
`engine-readonly-strip-caller-values`,
`engine-readonly-when-derived-writes`, `engine-readonly-hook-input`,
`hook-withheld-readonly-fault`): **6 failed / 161 passed**, including
**2 of PR objectstack-ai#19877's 16 pins** (`reports both strips, each under its own
reason` and the `strictReadonlyWrites` refusal: the `readonly` event now
arrives first, so the event order and the strict `fields` / `drops`
order flip). It also reports a field carrying both locks as `readonly`
whenever the static strip takes it, where a TRUE lock used to report
`readonly_when`, and (by reading, not measured) the FK's own lock would
stop being judged when the static strip takes the FK.
- **(B) The stored view (chosen)** — keep the order; hand the
conditional strip the payload the static strip leaves, and build
`record` from that. Same nine suites: **167 / 167 passed**. This is the
"smaller alternative" of the dispatch, analogous to
`staticReadonlyStripTakes`: the view is asked of the SAME
`stripReadonlyFields` verdict, with the same `supplied` snapshot,
`hookWrittenKeys`, `preserveAudit` and `isSystem` gate.

### The fix

- `packages/objectql/src/engine.ts`: `staticReadonlyStoredView(schema,
data, supplied, strip)` — `data` itself when the static strip does not
run, else `stripReadonlyFields(...)` run silently with the same
arguments the real strip gets. `staticReadonlyStripTakes` (objectstack-ai#19853) now
delegates to it, so the one-key question and the whole-view question are
one call. Each branch describes the static strip once (`staticStrip` /
`staticStripMulti`, reusing the one `isSystem` read objectstack-ai#19853 hoisted, so
the system-context census stays at 110 sites) and passes the view as
`stored` to all four `readonlyWhen` strip calls: the by-id and bulk
strips, and the by-id and bulk `judgeFkLock` of the settlement.
- `packages/objectql/src/validation/rule-validator.ts` — outside the
claimed file surface, and the reason it is the landing site:
`readonlyWhenBindings` builds `record` from the strip's `data` argument,
which is also the set of keys judged and the payload returned.
Separating "what is judged" from "what the predicate reads" needs one
optional key, `ReadonlyWhenStripOptions.stored`, read by
`stripReadonlyWhenFields` and `stripReadonlyWhenFieldsMulti`. Neither
function nor the options type is exported from the package (`index.ts`
exports `evaluateValidationRules`, `needsPriorRecord`, `legalNextStates`
from that file), so no published surface moves. Unlike `supplied`,
omitting `stored` is not fail-safe (the view is then `data`); the
docblock says so, and every engine call site passes it.

### Every movement, measured (base `dabf8d795e` → this head
`1afbcef386`)

| write | before | now |
|:---|:---|:---|
| shape 1 `update(t1, { status: 'open', amount: 999 })` | stored `{
closed, 999 }`; events `[{status, readonly}]` | stored `{ closed, 100
}`; events `[{amount, readonly_when}, {status, readonly}]` |
| shape 1 under `strictReadonlyWrites` | refused, `fields: ['status']` |
refused, `fields: ['amount', 'status']`, `drops` both (already a
refusal) |
| shape 2 by-id and bulk | stored `amount: 999` | stored `amount: 100` |
| shape 1 bulk | both rows `amount: 999` | both rows `amount: 100` |
| REVERSE: forged `status: 'closed'` on an open row | amount dropped
(over-lock), stored `{ open, 100 }` | amount lands, stored `{ open, 999
}` |
| field with BOTH locks, own predicate reads itself (`update(b1, {
status: 'open', amount: 999 })`) | events `[{status, readonly}]`, amount
stored 999 | events `[{amount, status: readonly_when}]` (one event),
amount stored 100 — the both-lock field is still dropped; only its
reason moves |
| FK's own record-scoped lock reads a forged read-only `stage`
(settlement composes) | FK landed on `inv_b`, amount 999 | FK stays on
`inv_a`, amount judged against the paid header, 100 (by-id and bulk) |
| `requiredWhen: "record.amount > 500"` on `note`, forged status +
amount 999 | refused `VALIDATION_FAILED` (note required) | commits; the
row keeps amount 100 and note null |
| `requiredWhen` requiring `note` while the amount is below 500, forged
status + amount 999 + `note: null` | accepted, stored `{ closed, 999,
null }` | refused `VALIDATION_FAILED` (note required); nothing lands |

**Unmoved (pinned):** the control writes; `isSystem` (the static strip
does not run, the forged status lands and is read); `preserveAudit` (the
static strip keeps the status); a hook-written status and a hook
overwriting a forged status (both land and are read); every write whose
payload holds no value the static strip takes (the view is then the
payload itself). Warn-line wording is unchanged; lines follow the strip
verdicts. Strip order, `reportDroppedFields` and the strict envelope's
shape are unchanged.

## A3 — bulk

`stripReadonlyWhenFieldsMulti` builds each matched row's `record` from
the same `stored` view over THAT row (the static strip is not per-row,
so one view serves every row). Pinned: shape 1 and shape 2 in bulk, and
the settlement composition in bulk.

## Tests

New `packages/objectql/src/engine-readonly-when-stored-view.test.ts`, 18
cases: both card shapes with their controls, the reverse over-lock, the
report, the strict envelope (`code` `ERR_READONLY_FIELD_REJECTED`,
`name`, `fields`, `drops`; the engine error carries no HTTP `status` —
that mapping is downstream and not measured here), the both-lock
attribution, `isSystem`, `preserveAudit`, the two hook pins, the
settlement composition (by-id with its events, and bulk), bulk shapes 1
and 2, and the two `requiredWhen` directions (refusal asserted on `code`
`VALIDATION_FAILED` + `fields`).

All on `1afbcef386`:

- `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` (the new file is in the `tsconfig.test.json`
program by `--listFiles`, 0 diagnostics in it); `pnpm --filter
@objectstack/objectql test:repo` → 5 passed.
- PR objectstack-ai#19877's 16 pins (`engine-readonly-when-parent.test.ts`, 38 cases
in the file) → all green.
- **Ablation** (fix committed; `engine.ts` and `rule-validator.ts`
restored to their `dabf8d795e` blobs `624fc1e38a` / `bb83519608` with
`git restore --source` (tree only), verified on disk by blob hash and by
marker counts 0 / 0): the two suites ran **12 failed / 44 passed** —
every new pin except the six whose behaviour is unchanged (both
CONTROLs, `isSystem`, `preserveAudit`, both hook pins), and all 38 of
the objectstack-ai#19853 file green. Restored with `git checkout HEAD --`; blobs
`e973ab50ac` / `2b3002b8e1` equal `HEAD`, `git diff HEAD` empty; rerun
56 / 56 passed. The script carried an `EXIT` / `INT` / `TERM` trap.

## Gates on `1afbcef386`

- `node scripts/pm/dispatch-gates.mjs --commands` → 65 commands, every
one run with its exit code captured before any pipe; `--ran` → `65
derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED`, exit 0.
`check:type-check-debt` first answered PREREQUISITE NOT MET (the
ablation restore left `engine.ts` newer than objectql's `dist`); after
`pnpm --filter @objectstack/objectql build` it exits 0, and the record
carries that rerun. The workspace `dist` came from `turbo run build`
over `./packages/*` and `./packages/*/*` (72 / 72 tasks).
- `check:objectql-double-limit` was red on the first commit for this
suite's copied driver double (its `find` ignored `limit`); the second
commit applies the bound after the filter, and the gate is green.
- `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks
on) → exit 0, 10 citations resolve.
- `node scripts/check-system-context-census.mjs` → exit 0 (110 sites).
`pnpm check:query-options-erasure` → exit 0.
- The three roster gates the derivation marks as keeping a roster under
one of this diff's directories: `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:filter-alias-parity` → exit 0 each.
- Lint, narrowed and proven: eslint's own config (`ESLint.isPathIgnored`
/ `calculateConfigForFile`) ignores the changeset and lints the three
TypeScript files with the typescript-eslint parser and no
`parserOptions.project` / `projectService`; `eslint --no-inline-config
--format json` over the four paths → 4 results, 0 errors, 0 warnings on
the three linted files. The config enables no type-aware linting and its
only file reads are two baselines this diff does not touch, so this diff
cannot move a verdict on any untouched file. `pnpm lint` itself is CI's.

## Neighbour PR objectstack-ai#19728

Textually disjoint: this diff does not touch the
`evaluateValidationRules` calls, the import line, or any hunk objectstack-ai#19728
edits in `rule-validator.ts` (its hunks are the related-record bindings;
none touches the `readonlyWhen` strips). Driver-free bare probe (`git
clone --bare`, refs fetched in, no `merge.*` driver registered):
`merge-tree --write-tree` of `1afbcef386` against `3b9c5f2fca` → exit 0;
against `origin/main` `44ce049a8c` → exit 0. The merged tree carries
both changes (marker counts: `staticReadonlyStoredView` 6, `stored?:
Readonly` 1, objectstack-ai#19728's `RelatedRecordBinding` 2 in `engine.ts` and 5 in
`rule-validator.ts`).

## Acceptance notes

- **Sibling in the same class, not fixed here (listed for the seat to
file, class (a)).** The conditional strip still judges one
`readonlyWhen` field against another field's value that the SAME
conditional strip drops. Measured on `dabf8d795e` and on this head:
`status` `readonlyWhen: "previous.status == 'closed'"`, `amount`
`readonlyWhen: "record.status == 'closed'"`, row closed; `update(c1, {
status: 'open', amount: 999 })` stores `{ status: 'closed', amount: 999
}`. Not mechanical: two conditional locks can each read the other's
field, so "judge against what the conditional strip itself keeps" is a
fixpoint whose verdicts can move in both directions, which is a decision
rather than this card's shape. Dedupe words: `readonlyWhen reads value
conditional strip drops` · `readonlyWhen interdependent locks same pass`
· `record binding includes readonlyWhen-dropped value`.
- The by-id branch now runs one extra silent `stripReadonlyFields` pass
per update (a loop over the declared fields, a copy only when a key is
taken). The by-id strip already materialises its bindings on every
update, so the cost profile is unchanged in kind; noted, not measured.
- User docs are untouched and still true after the fix; see the
declaration note.

## Declaration note for the contract review

The claim declares no widening; copied above as given. The fix moves a
refuse/accept answer only through the validation rules that run on the
stripped payload:

- **Accept direction.** A write refused `VALIDATION_FAILED` only because
a forged-through locked value raised a requirement now commits without
that value. This removes a refusal the published text already negated:
`content/docs/data-modeling/fields.mdx` ("Conditional Logic") says "The
server enforces `requiredWhen` on submit and ignores writes to fields
whose `readonlyWhen` predicate is `TRUE`", and ("Who the lock applies
to") "A TRUE `readonlyWhen` predicate locks the field for **every
API-boundary caller**". On the stored row the predicate IS true, so the
write to the locked field was one the server is documented to ignore,
and refusing the update on the strength of it contradicted that text.
- **Refuse direction.** A write that cleared a field the kept value
requires is now refused: a restored guarantee (the stored row would have
violated the requirement).
- `strictReadonlyWrites`: a write whose view differs from its payload
always carries a static strip, so it was already refused; only `fields`
/ `drops` grow.

Every other movement is a strip (the write still commits). No authorable
key, export or error code moves.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e stores, not a value another lock drops (objectstack-ai#19911) (objectstack-ai#19923)

Fixes objectstack-ai#19911

Clause-②: no

## What a caller could do before, and what happens now

**Before.** When one field's `readonlyWhen` read a field that carries
its own `readonlyWhen`, a caller with edit rights could change a locked
field by sending a new value for the other field in the same update. The
conditional strip judged every lock against one `record` view built
before any lock was judged. That view still held every value the strip
then dropped. With `status` locked by `previous.status == 'closed'` and
`amount` by `record.status == 'closed'`, `update(c1, { status: 'open',
amount: 999 })` on a closed row stored `{ status: 'closed', amount: 999
}`. The same happened on bulk (`multi: true`) updates, for `isSystem`
callers (a `readonlyWhen` lock binds them), and for a master-detail FK's
own lock judged inside the PR objectstack-ai#19877 settlement: the line moved to
another invoice although its own lock held on the row it kept. The
mechanism also over-locked in reverse: a dropped value that WOULD lock
another field locked it, though the row never took that value.

**Now.** Each lock is judged with its own incoming value and every OTHER
dropped key reverted to the stored value. No field is written while its
lock is TRUE on the row the update stores. The strip then releases, in
one step, every dropped key that is unlocked on that row, and keeps the
result only when every dropped key is locked and every kept key is
unlocked on the row it stores. When that step does not settle the set
(locks that read each other in a cycle, or a cascade where releasing one
key moves another's verdict, objectstack-ai#19927), the larger fail-safe drop set
stands. This applies on all four conditional call sites (by-id and bulk
strips, and the by-id and bulk FK judgements of the settlement).

## A1: reproduction on `origin/main` `a34c27cbe5`, before any edit

Real `ObjectQL` engine, the PR objectstack-ai#19905 suite's in-memory driver shape.
Object `case_x`: `status` `readonlyWhen: "previous.status == 'closed'"`,
`amount` `readonlyWhen: "record.status == 'closed'"`, rows `c1`, `c2`
closed with `amount: 100`.

- By id: `update(c1, { status: 'open', amount: 999 })` stored `{ status:
'closed', amount: 999 }`, event `[{ status, readonly_when }]`. Control
`update(c1, { amount: 555 })` stayed 100. **Reproduced.**
- Bulk (`where: { note: 'k' }, multi: true`): both rows stored `amount:
999`. Control stayed 100. **Reproduced.**

## A2: the candidates, measured

All five variants (the base and four candidates) were run on the same 20
files: the nine suites PR objectstack-ai#19905's body names (its own
`engine-readonly-when-stored-view` included) and every other objectql
test file that mentions `readonlyWhen` (674 tests). **Every variant
passes 674/674**, so the existing pins do not tell the candidates apart.
They were compared on the card's shape and on a 27-row probe of every
movement class instead. The expected rows were checked against a
brute-force enumeration of every drop set that agrees with the stored
row.

| shape (exact drop sets by enumeration) | base | (i) monotone fixpoint
| **(iv) fixpoint + exact release (chosen)** | (ii) judge against the
stored row | (iii) refuse when locks interact |
|:---|:---|:---|:---|:---|:---|
| the card, by id and bulk (`{status, amount}`) | amount 999
**under-lock** | 100 | 100 | 100 | refused |
| legitimate reopen: `status` unlocked, amount edited (`{}`) | both land
| both land | both land | amount dropped **over-lock** | both land |
| close + edit on an open row (`{amount}`) | amount dropped | dropped |
dropped | amount 999 lands on a closed row **under-lock** | dropped |
| REVERSE: frozen `status`, `closed` + amount (`{status}`) | amount
dropped **over-lock** | dropped **over-lock** | amount lands | lands |
refused |
| lock reading its own field, open row over the cap | dropped | dropped
| dropped | 5000 lands **under-lock** | dropped |
| three-lock chain (`{c,b}`) | `{c,a}` | `{c,b,a}` over-locks `a` |
`{c,b}` | `{c,b}` | refused |
| four-lock chain (`{c,z,y}`) | `{c}` | `{c,x,z,y}` over-locks `x` |
`{c,z,y}` | `{c,z,y}` | refused |
| two-lock cycle (NONE) | `{a}` | `{a,b}` | `{a,b}` fail-safe | `{b}` |
refused |
| FK's own `record` lock reads a stage its own lock keeps | FK lands,
amount 999 | FK stays, 100 | FK stays, 100 | FK stays, 100 | refused |

- **(i) monotone fixpoint.** Never opens a lock, and its first pass is
the old single pass. It **over-locks**: a key dropped in an early pass
whose own lock is FALSE on the final row. Measured on a two-field shape
(REVERSE, where the base over-locks too, so (i) keeps that defect) and
on the three- and four-lock chains (the four-lock `x` is a new
over-lock).
- **(ii) judge every lock against the stored row.** It reverts every
judged key, a key's own value included. It over-locks the legitimate
reopen, and it **opens locks the base holds**: close + edit writes the
amount onto a now-closed row, and a lock that reads its own field lets
5000 past a 1000 cap. It also reads `record` as `previous` for those
fields. Rejected.
- **(iii) refuse.** Moves accept to refuse on every interacting shape.
The documented behaviour is to *ignore* a locked write, not refuse it
(quoted below). Rejected. Measured in its narrowest form, refusing only
when a drop moves another lock's verdict; a syntactic "reads a dropped
field" rule would refuse a superset.
- **(iv) chosen.** (i), then ONE release: every dropped key that is
unlocked on (i)'s row is released at once. The result is kept only if it
is exact (every dropped key locked, every kept key unlocked, on the row
it stores). Otherwise (i)'s answer stands. It never opens a lock: no
field is written while its lock is TRUE on the row the update stores. It
reaches the unique exact drop set on every measured shape except the
three-lock cascade (objectstack-ai#19927): there, one release step does not settle the
set (releasing `x` moves `y`'s verdict), so the fixpoint's larger drop
set `{c, x, y}` stands instead of `{c, y}`, and a field whose own lock
is FALSE on the stored row is still dropped, as on base. The cycle has
no exact set at all and takes the same fail-safe answer. It changes no
documented behaviour: every movement below brings the stored row into
line with the documented rule. It keeps every existing pin, as all
candidates do.

## The fix

- `packages/objectql/src/validation/rule-validator.ts`
- `settleReadonlyWhenDrops`: the fixpoint and the exact release. Each
key is judged with its own incoming value (a lock reading its own field
judges the write) and the other drops reverted. Views are memoised per
drop set.
- `stripReadonlyWhenFields` and `stripReadonlyWhenFieldsMulti` now route
through it, with no new arguments at existing call sites. Bulk means
"locked in at least one matched row" for every evaluation.
- Warnings come from each key's deciding evaluation and are logged once,
in declaration order.
- `ReadonlyWhenStripOptions.only`: the strip judges every key but takes,
and speaks about, only that key, and only when it is taken.
- `readonlyWhenFkJudgementReadsParent`: the settlement's question "does
judging the FK's lock need the header it names?".
- The `parent`-root reader now caches roots per source, so it answers
for `record` too.
- Neither the options type nor the strips are exported from the package
(`index.ts` and `core.ts` export none of them), so no published surface
moves.
- `packages/objectql/src/engine.ts`, `settleMasterDetailLanding` and its
two `judgeFkLock` closures
- The FK's own lock is judged with every caller-supplied lock (`only:
fk`) instead of alone. This is how a value another lock drops is
reverted before the FK's lock reads it.
- The named header is read when the FK's lock reads `parent` (as
before), or reads `record` while another payload key's lock reads
`parent`.
- A landing FK now stays in `supplied`, so the strip that judges the
rest re-judges it on the same landing and settles the same drop set. An
FK that does not land keeps PR objectstack-ai#19877's rule: its verdict is final and
never re-asked against the header the row keeps.
- One new import line, placed away from the rule-validator import line
PR objectstack-ai#19728 edits.

## Every movement (base `a34c27cbe5` to head `d01b912f3d`; patch round 1
changed no code, so `611a2fc561` answers the same)

| write | before | now |
|:---|:---|:---|
| the card, by id and bulk | `amount: 999`; event `[status]` | `amount:
100`; one event `[status, amount]` `readonly_when` |
| the card under `strictReadonlyWrites` | refused, `fields: ['status']`
| refused, `fields: ['status', 'amount']`, `drops` one `readonly_when`
event (a refusal before and now) |
| the card, `isSystem` / `preserveAudit` / a hook echoing the caller's
`status` | `amount: 999` | `amount: 100` |
| REVERSE by id and bulk (frozen `status`) | amount dropped; event
`[status, amount]` | `amount: 999` lands; event `[status]` |
| REVERSE under `strictReadonlyWrites` | refused, `['status', 'amount']`
| refused, `['status']` |
| own-field lock beside a dropped reopen | `amount: 500` on a closed row
| `amount: 100` |
| three-lock chain | stored `{c: L, b: x, a: 1}` | stored `{c: L, b: y,
a: 2}` |
| two-lock cycle | `{a}` dropped | `{a, b}` dropped (no exact set
exists) |
| `requiredWhen` requiring `note` while the amount is above 500, on the
card | refused `VALIDATION_FAILED` | commits, amount 100 |
| `requiredWhen` requiring `note` while the amount is below 500, the
card clearing `note` | commits `{closed, 999, null}` | refused
`VALIDATION_FAILED`, nothing lands |
| settlement, FK lock reads a stage its own lock keeps, by id and bulk |
line moves to `inv_b`, amount 999 | stays under `inv_a`, amount 100;
event `[stage, invoice, amount]` |
| settlement, stays to moves: the FK's own `record` lock reads a value
that is itself locked under the header the update names (`invoice`
locked by `record.amount == 'big'`, `amount` by `parent.status ==
'paid'`; line under open `inv_b`, the update names paid `inv_a` with
`amount: 'big'`), by id and bulk | line stays under `inv_b` and stores
`amount: 'big'`; event `[invoice]`; header reads `[inv_b]` | line moves
to `inv_a` and keeps `amount: 'small'`; event `[amount]`; header reads
`[inv_a]`, then the repoint's reference check on `inv_a`. Both rows
agree with their locks |
| the same write under `strictReadonlyWrites` | refused, `fields:
['invoice']` | refused, `fields: ['amount']`, nothing lands |
| three-lock cascade (`c` locked by `previous.c == 'L'`, `x` by
`record.c == 'open'`, `y` by `record.x == 'xv'`; row `c: 'L'`; the
update sets all three), by id and bulk | drops `{c, x, y}` | drops `{c,
x, y}`, unchanged; the exact set is `{c, y}` (objectstack-ai#19927) |
| same FK lock, no `parent`-scoped lock (plain strip) | FK moves | FK
stays |
| PR objectstack-ai#19877's `inv_line_moored` shape (stage not in the payload) |
header reads `[inv_a]` | `[inv_b, inv_a]`: one extra read, same row,
same event |

**Unmoved (pinned):** both controls; a hook-written `status` and a hook
overwriting the caller's `status` (both land and are read); a
hook-written `amount`; the legitimate reopen by id, bulk and strict
(nothing dropped); close + edit; an own-field lock over and under its
cap; an FK lock reading only `previous` (one header read); a landing
FK's fail-open fault warning, said once. PR objectstack-ai#19905's 18 pins and PR
objectstack-ai#19877's 38 (its whole file) are green.

**Warn lines:** a newly dropped key gains its drop line and a released
key loses it. A key re-judged in a later pass speaks from its deciding
evaluation, once. A landing FK's fault warnings now print with the rest,
in declaration order, instead of first. When the FK stands on its own
lock but the static strip takes it, its conditional fault warning is no
longer printed.

## A3: all four call sites

By-id strip, bulk strip, by-id `judgeFkLock` and bulk `judgeFkLock` all
run the same settlement; no site is exempt. The FK's verdict is
consistent when it lands: the re-judge is the same computation over the
same header. When the FK's lock reads neither `record` nor `parent`, its
verdict does not depend on the view. PR objectstack-ai#19905's `stored` view is the
base every view is built from, so a value the static strip takes is
never read either.

## Tests

New
`packages/objectql/src/engine-readonly-when-interdependent-locks.test.ts`,
35 cases (32 in round 1, plus three stays-to-moves pins by id, bulk and
under `strictReadonlyWrites` in patch round 1): the card by id and bulk
with both controls, the report and warn lines, the strict envelope
(`code` `ERR_READONLY_FIELD_REJECTED`, `name`, `fields`, `drops`; the
engine error carries no HTTP `status`, which is mapped downstream and
not measured here), `isSystem`, `preserveAudit`, four hook shapes, the
legitimate reopen (by id; bulk plus strict), close + edit, REVERSE by id
/ bulk / strict, the own-field lock (two cases), the chain, the cycle,
both `requiredWhen` directions (refusal asserted on `code`
`VALIDATION_FAILED` + `fields`), the settlement by id and bulk with its
header reads, the plain-strip FK, the moored read count, the
`previous`-only FK, the landing FK's single warning, and two unit cases
for `only`.

On `d01b912f3d` (patch round 1):

- `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2`: 307 files / 5158 tests passed, exit 0.
- `pnpm --filter @objectstack/objectql typecheck`: exit 0,
`check:test-typecheck: OK`. The new file is in the `tsconfig.test.json`
program (`--listFiles`: 1) with 0 diagnostics. `pnpm --filter
@objectstack/objectql test:repo`: 5 passed.
- **Ablation** (fix committed; `engine.ts` and `rule-validator.ts`
restored to their `a34c27cbe5` blobs `e973ab50ac` / `2b3002b8e1` with
`git restore --source`, tree only; on-disk hashes verified and markers
`settleReadonlyWhenDrops` / `readonlyWhenFkJudgementReadsParent` counted
0 / 0). The three suites ran **23 failed / 68 passed**: every new pin
failed except the 12 whose behaviour is unchanged, the three
stays-to-moves pins included, and PR objectstack-ai#19905's 18 and PR objectstack-ai#19877's 38
stayed green. The subject is imported from source (`./engine.js`), so no
`dist` sits on the path. Restored with `git checkout HEAD --`: blobs
`c9b1cfc19e` / `f3934b80f6` equal `HEAD`, `git diff HEAD` empty, rerun
91/91 passed. The script carried an `EXIT` / `INT` / `TERM` trap.

## Gates on `611a2fc561`

Patch round 1 (`d01b912f3d`) changed docblocks, the changeset and three
pins over the same four paths. It re-ran the objectql tests and
typecheck, `node scripts/check-issue-citations.mjs` (23 citations
resolve, objectstack-ai#19927 included), `node scripts/check-changeset-no-major.mjs
--base origin/main`, `pnpm check:nul-bytes` and the narrowed eslint run:
exit 0 each. The full derivation below ran on `611a2fc561`.

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 65 commands, each run with its exit code
captured before any pipe. `--ran`: `65 derived famil(ies) accounted for
— 65 run, 0 NOT-MEASURED (a DERIVED zero …)`, exit 0.
`check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:type-check-debt` first answered PREREQUISITE NOT MET (exit 3, no
workspace `dist`). After `pnpm exec turbo run build
--filter='./packages/*' --filter='./packages/*/*' --concurrency=2`
(72/72 tasks) all three exit 0, and the record carries the reruns.
- `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks
on): exit 0, 22 citations resolve.
- `node scripts/check-system-context-census.mjs`: exit 0 (no new
`isSystem` read). `pnpm check:query-options-erasure`: exit 0.
- The three roster gates the derivation marks as keeping a roster under
this diff's directories: `node scripts/check-changeset-fixed.mjs`, `pnpm
check:authz-resolver`, `pnpm check:filter-alias-parity`, exit 0 each.
- Lint, narrowed and proven. eslint's own config (`ESLint.isPathIgnored`
/ `calculateConfigForFile`) ignores the changeset and lints the three
TypeScript files with `typescript-eslint/parser` and no
`parserOptions.project` / `projectService`. `eslint --no-inline-config
--format json` over them gives 3 results, 0 errors, 0 warnings. The
config enables no type-aware linting, and its only file reads are two
baselines this diff does not touch, so this diff cannot move a verdict
on any untouched file. `pnpm lint` itself is CI's.

## Neighbour PR objectstack-ai#19728

This PR stays textually disjoint from it and does not wait on it.
Driver-free bare probe (`git clone --bare --shared`, no `merge.*` driver
registered): `merge-tree --write-tree` of `d01b912f3d` (and before it
`611a2fc561`) against its head `3b9c5f2fca` exits 0, and against
`origin/main` `beac798026` exits 0. The merged tree carries both changes
(`readonlyWhenFkJudgementReadsParent` 3 in `engine.ts`,
`settleReadonlyWhenDrops` 6 in `rule-validator.ts`, objectstack-ai#19728's
`RelatedRecordBinding` 2 and 5).

## Acceptance notes

- **Cycle residue.** When locks read each other in a cycle, no drop set
can make every dropped key locked AND every kept key unlocked on the
stored row, and no rule can promise both halves. This one keeps the
fail-safe half (a lock that cannot be settled is not waived), as the
bulk strip's "locked in at least one row" rule already does.
- **Cost.** A write where nothing locks runs one pass, as before. Each
drop adds one pass over the standing keys and one re-check of the
dropped keys; a full check pass runs only when something is over-locked.
The settlement reads one extra header in the moored shape. Noted, not
measured.
- **In-tree usage.** The tree has no case of one `readonlyWhen` reading
another `readonlyWhen` field. Examples and platform objects were
scanned: the showcase `invoice` locks read `status` / `parent.status`,
and neither carries a lock. The card's shape is the natural "a closed
case stays closed" plus "a closed case's amount is frozen".
- User docs are untouched and still true after the fix; see the
declaration note.

## Declaration note for the contract review

The claim declares no widening; copied above as given. The fix moves a
refuse/accept answer only through the validation rules that run on the
stripped payload, and moves strip verdicts toward the documented rule:

- **Accept direction.** A write refused `VALIDATION_FAILED` only because
an amount the lock should have held raised a requirement now commits
without it. The REVERSE shape's amount, which the base silently dropped,
now lands. Both remove a verdict the published text already negated.
`content/docs/data-modeling/fields.mdx` ("Conditional Logic") says
`readonlyWhen` is a "CEL predicate; field is read-only when `TRUE`", and
"The server enforces `requiredWhen` on submit and ignores writes to
fields whose `readonlyWhen` predicate is `TRUE`". ("Who the lock applies
to") says "A TRUE `readonlyWhen` predicate locks the field for **every
API-boundary caller**". `content/docs/data-modeling/formulas.mdx` binds
`record` to "the row being evaluated". On the row the update stores, the
REVERSE amount's predicate is FALSE and the card's is TRUE.
- **Refuse direction.** A write that cleared a field the kept amount
requires is now refused: a restored guarantee.
- `strictReadonlyWrites`: every shape that moves carries a conditional
drop, so it was a refusal before and still is; only `fields` / `drops`
move, and they grow on the card and shrink on REVERSE.

Every other movement is a strip (the write still commits). No authorable
key, export or error code moves.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…he stored row agrees with (objectstack-ai#19927) (objectstack-ai#19928)

Fixes objectstack-ai#19927

Clause-②: no

## What a caller saw before, and what happens now

**Before.** When `readonlyWhen` locks read each other in a chain, an
update could ignore an edit whose own lock was FALSE on the row the
update stored. The card's cascade: `c` locked by `previous.c == 'L'`,
`x` by `record.c == 'open'`, `y` by `record.x == 'xv'`; row `{ c: 'L',
x: 'old', y: 'old' }`; `update({ c: 'open', x: 'xv', y: 'yv' })`. The
row keeps `c: 'L'`, so `x`'s lock is FALSE there, yet all three fields
were dropped, and a `strictReadonlyWrites` refusal named `x`. The
settlement that PR objectstack-ai#19923 added released every over-locked key once and,
when that one step did not settle the set, kept the fixpoint's larger
drop set.

**Now.** The release repeats. The stored row for the card's update is `{
c: 'L', x: 'xv', y: 'old' }`, one `readonly_when` event `[c, y]`, by id,
on bulk (`multi: true`) updates and for `isSystem` callers; under
`strictReadonlyWrites` the refusal names `[c, y]`. No caller field is
written while its `readonlyWhen` is TRUE on the row the update stores (0
opened in 3016 engine runs and 5662 strip runs, below). A cycle with no
exact drop set keeps the fail-safe drop.

## The change

- `packages/objectql/src/validation/rule-validator.ts`,
`settleReadonlyWhenDrops`, step ② only. Step ① (the monotone fixpoint)
is untouched. Step ② used to release every over-locked key once and keep
the result only if it was exact. Now the first set releases them all at
once, each later set is every judged key that locks when judged against
the set before it, and the first set that gives back itself is the
answer. After n + 1 sets (n = judged keys) with none giving back itself,
①'s set stands, as before. The docblock is rewritten to say this and
what it guarantees.
- `packages/objectql/src/engine.ts` is not touched. All four conditional
call sites (by-id strip, bulk strip, and the by-id and bulk
`judgeFkLock` of objectstack-ai#19853's settlement) already route through this one
function.
- New
`packages/objectql/src/engine-readonly-when-exact-drop-set.test.ts` (19
cases). The objectstack-ai#19911 suite's header comment is corrected (comment only).
One `patch` changeset, and the pending
`.changeset/19911-readonlywhen-interdependent-locks.md` corrected in
place (one file beyond the claim's surface, authorized by the seat in
the patch round).

## A1: the card reproduced on `origin/main` `ae0c90c133`, before any
edit

Real `ObjectQL` engine, the objectstack-ai#19911 suite's in-memory driver, two rows `{
c: 'L', x: 'old', y: 'old' }`:

| write | stored | report |
|:---|:---|:---|
| by id | `r1` `{ L, old, old }` | event `[c, x, y]` |
| bulk (`where: { tag: 't' }, multi: true`) | `r1`, `r2` `{ L, old, old
}` | event `[c, x, y]` |
| by id, `strictReadonlyWrites` | untouched | refused
`ERR_READONLY_FIELD_REJECTED`, `fields: [c, x, y]` |
| bulk, `strictReadonlyWrites` | untouched | refused, `fields: [c, x,
y]` |

Brute-force enumeration of the 8 drop sets: the only exact one is `{c,
y}`. **Reproduced.**

## A2: the search, its bound, and its answers against a brute-force
oracle

**Why this search.** Every step judges every key against one set, so no
order enters the answer: field declaration order and payload key order
cannot move it (measured below). A one-key-at-a-time release needs an
order: either a read graph built from the CEL source, which the strip
does not have, or declaration order, which would make the answer
order-dependent on a cycle. It was not built.

**Termination and cost.** ① makes at most n(n + 1) / 2 key judgements; ②
at most n + n² (the first release judges ①'s dropped keys once; each of
at most n further sets judges every key once). Worst case 3n(n + 1) / 2
key judgements; a bulk write evaluates each over its matched rows.
Measured below as CEL evaluations: the highest ratio to that bound was
1.0 (9 of 9 at n = 2, the two-lock cycle with no exact set); at n = 7
the highest was 70 of 84 by id and 144 of 252 on a 3-row bulk write. On
every run where ① or its first release settled the set, the evaluation
count equals the base's exactly (5535 of 5535 runs); on the 127 runs
where it did not, head evaluates at least as many.

**Exactness without a cycle.** A key's verdict depends only on the
judged keys its `record` reads name. After t sets, every key at depth
below t in that read graph holds its final verdict, so the n-th set is
exact and the (n + 1)-th gives it back. Measured: every acyclic run
reached its exact set (below).

**Probe.** The real `stripReadonlyWhenFields` /
`stripReadonlyWhenFieldsMulti` of the base tree (`ae0c90c133`) and of
this head, over 9 hand-built shapes plus 3000 seeded random ones: 2 to 7
text fields, predicates of one or two atoms joined by `&&` / `||`,
sometimes negated, over `record.*` (self-reads included), `previous.*`,
`parent.status` (bound, or unbound for 10% of rows) and `true` /
`false`; 1 to 3 prior rows; by id and bulk. 178 shapes judged fewer than
two keys and were skipped: **5662 runs**. The oracle: a key's lock on a
drop set D is the one-key strip (only that key carries its
`readonlyWhen`) over the payload with D minus that key removed, i.e. the
same evaluator, bindings and fault rules with no settlement. Every one
of the 2^n drop sets was enumerated. A shape is cyclic when the
`record.*` reads among its judged keys form a cycle.

| class (runs) | exact sets (enumerated) | head reaches one | base
reaches one |
|:---|:---|:---|:---|
| acyclic (4720) | exactly 1 in every run | 4720 | 4673 |
| cyclic, one exact set (846) | 1 | 846 | 823 |
| cyclic, two exact sets (64) | 2 | 39 (the same answer as base in all
64) | 39 |
| cyclic, no exact set (32) | 0 | n/a: fail-safe drop, equal to base in
32 of 32 | n/a |

- **Locks opened:** head 0, base 0.
- **Order:** 3 permutations of field declaration order and payload key
order per run, 16986 permutation runs, 0 changed the head's answer.
- **Two exact sets (64 runs):** head's answer equals base's in all 64:
one of the exact sets in 39 (① or its first release had already reached
it), the fail-safe drop in 25. Pinned: `a` locked by `record.b ==
'new_b'`, `b` by `record.a == 'new_a'` has `{a}` and `{b}`; ② alternates
between `{}` and `{a, b}` and ①'s `{a, b}` stands. `a` locked by
`record.b == 'old'`, `b` by `record.a == 'old'` has `{}` and `{a, b}`;
①'s first pass locks nothing, so `{}` is the answer and both edits land.
- **Movements against base: 70 runs.** In every one, head's answer is
exact and each key base dropped but head keeps lands with its lock FALSE
on the row head stores. In 4 of them (hand-built, by id and bulk) head
also drops a key base had written: that key's lock is TRUE on the row
head stores (the knock-on class below).

## A3: the same through the real engine, base and head

The objectstack-ai#19911 contract review's probe style: 8 hand-built shapes plus 1500
seeded random ones with 2 to 5 fields, `record` / `previous` / `parent`
roots, self-reading locks, a static `readonly` field (35% of shapes)
whose value the caller forges in half of them, a `master_detail` FK with
its own lock (45%), `isSystem` (25%). Each ran by id and bulk over 1 to
3 rows with random values, plain and under `strictReadonlyWrites`, at
base and at head: **3016 plain and 3016 strict runs per tree**, plus 2
permutations per plain run at head. An independent oracle re-evaluated
every caller-supplied field's predicate on the row the driver holds
after the write (the FK's own lock against the header it names, objectstack-ai#4889).

- **Locks opened:** head 0, base 0.
- **Order:** 6032 permutation runs at head, 0 differ in stored rows or
reported drops.
- **Strict:** the refusal went refuse to accept or accept to refuse in 0
of 3016 pairs; its `fields` moved in 20 (refused before and after).
- **Movements: 20 plain runs** (12 hand-built, 8 random). 14 only
release keys; 6 also drop a knock-on key. Every released key lands with
its lock FALSE on the stored row; every knock-on key is locked on it.
Two random runs (seed 413, by id and bulk) release a master-detail FK:
the line moves to the header the update names, as in the settlement row
below.
- **Exactness at head:** 3002 of 3016 runs store a row on which every
dropped key is locked and every kept key unlocked (base: 2982). The 14
others are unmoved from base: 13 have a cycle among `record` reads (4
hand-built: the two-exact-set pair and the no-exact-set cycle, by id and
bulk); 1 (seed 371, bulk) has its only cycle through `parent`: the FK's
lock reads `record.f1` and `f1`'s lock reads `parent`, which the FK
decides. Enumerating its 4 sets of `{inv, f1}` by hand, none is exact,
and the fail-safe drop stands.

## A4: the FK settlement

The search applies there: both `judgeFkLock` closures call the same
strips (`only: fk`), which call `settleReadonlyWhenDrops`, and a landing
FK is re-judged by the strip that follows over the same header. Pinned
by id, bulk and strict with `line_cascade` below. The rule for an FK
that does not land is unchanged (its verdict is final, objectstack-ai#19853), and the
new docblock says the settlement's claims are about the views it is
handed for that reason.

## Every movement (base `ae0c90c133` to head `d9af551bb5`)

Measured through the real engine at both trees. The new test file pins
each row by id, on bulk and by id under `strictReadonlyWrites`, except
the six-lock cascade (by id only); the card is also pinned under
`strictReadonlyWrites` on bulk and for `isSystem`.

| write | before | now |
|:---|:---|:---|
| the card by id, bulk (2 rows) and `isSystem` | every row `{ c: L, x:
old, y: old }`; event `[c, x, y]` | every row `{ c: L, x: xv, y: old }`;
event `[c, y]` |
| the card under `strictReadonlyWrites`, by id and bulk | refused,
`fields: [c, x, y]`, nothing lands | refused, `fields: [c, y]`, nothing
lands |
| six-lock cascade (`c` by `previous.c == 'L'`, each `xN` by the
previous one being `'v'`, all set to `'v'`), by id, bulk, `isSystem` |
all six dropped | `x1`, `x3`, `x5` land; event `[c, x2, x4]` |
| the same under `strictReadonlyWrites` | `fields: [c, x1, x2, x3, x4,
x5]` | `fields: [c, x2, x4]` |
| KNOCK-ON: `p` by `previous.p == 'L'`, `m` by `record.p == 'L'`, `j` by
`record.m == 'new'`, `k` by `record.p == 'L' && record.j == 'new'`; all
set to `'new'` on `{ p: L, m: old, j: old, k: old }`, by id, bulk,
`isSystem` | `k: 'new'` stored; event `[p, m, j]` | `j: 'new'` stored,
`k` kept `old`; event `[p, m, k]` |
| the same under `strictReadonlyWrites` | refused, `fields: [p, m, j]` |
refused, `fields: [p, m, k]` |
| SETTLEMENT: `c` by `previous.c == 'L'`, FK `invoice` by `record.c ==
'open'`, `y` by `record.invoice == 'h_open'`, `amt` by `parent.status ==
'paid'`; line `{ c: L, invoice: h_paid, y: old, amt: a0 }`; update `{ c:
open, invoice: h_open, y: yv, amt: a1 }`, by id, bulk, `isSystem` | line
stays under `h_paid`, `y: yv` stored, `amt` stays `a0`; event `[c,
invoice, amt]`; by-id header reads `[h_open, h_paid]` | line moves to
`h_open`, `amt: a1` stored, `y` stays `old`; event `[c, y]`; by-id
header reads `[h_open, h_open]` (the named header, then the repoint's
reference check) |
| the same under `strictReadonlyWrites` | refused, `fields: [c, invoice,
amt]` | refused, `fields: [c, y]`; the line stays under `h_paid` |

**Unmoved (pinned):** the two-lock cycle with no exact set by id and
bulk (`{a, b}` dropped); the pair with exact sets `{a}` and `{b}` (`{a,
b}` dropped); the pair with exact sets `{}` and `{a, b}` (both land);
`only: y` over the card's cascade. PR objectstack-ai#19923's 35 pins, PR objectstack-ai#19905's 18
and PR objectstack-ai#19877's 38 are green.

**Warn lines:** a released key loses its drop line and a knock-on key
gains one; the card by id now warns for `c` and `y` only.

## Declaration note for the contract review

The claim carries `Clause-②: no`, copied above as given. No authorable
key, export or error code moves; the function is not exported
(`index.ts` and `core.ts` export neither it nor the strips).

- **Accept direction (a dropped edit now lands).**
`content/docs/data-modeling/fields.mdx`, "Conditional Logic": "The
server enforces `requiredWhen` on submit and ignores writes to fields
whose `readonlyWhen` predicate is `TRUE`", and the table row "CEL
predicate; field is read-only when `TRUE`".
`content/docs/data-modeling/formulas.mdx` binds `record` to "the row
being evaluated". Every released key's predicate is FALSE on the row the
update stores (checked on every moved run of A2 and A3), so the old drop
is one the text negates. `content/docs/kernel/contracts/data-engine.mdx`
lists the `readonly_when` strip as "A TRUE `readonlyWhen` predicate" and
`strictReadonlyWrites` as "refuse instead of stripping": the old refusal
named `x`, whose predicate was FALSE on the stored row.
- **Drop direction (the knock-on class: a written field is now
ignored).** On the row the update now stores, that field's predicate is
TRUE, so ignoring it is what the same sentence prescribes. The base
wrote it onto a different row, one where another field was dropped with
its own predicate FALSE; only the new answer agrees with every field's
lock on the row it stores. This moves a write to a drop and does not
move any refusal.
- **Settlement.** A master-detail repoint the chain used to hold now
lands where its lock is FALSE on the stored row, and the other fields
are then judged under the header it lands on (`amt` unlocked, `y`
locked): the same "stays to moves" class PR objectstack-ai#19923 declared, reached now
through a chain.
- **`strictReadonlyWrites`.** Whether a write is refused does not move:
the answer is empty only when ①'s first pass locks nothing, exactly as
before, and a set that gives back itself is never empty otherwise (0 of
3016 A3 pairs moved). Only `fields` / `drops` move.
- **Validation.** `requiredWhen` and validation rules run on the
stripped payload, as before, so a field that now lands or is now ignored
reaches them as stored. Declared by class; no shape here carries a
`requiredWhen`.
- **Cycles.** Where no exact set exists the docs are silent and the
fail-safe drop stands, as before (binding per the dispatch).

## Tests

New `packages/objectql/src/engine-readonly-when-exact-drop-set.test.ts`,
19 cases: the card by id, bulk, strict by id, strict bulk and `isSystem`
(stored rows, the one event, the warn lines, `code`
`ERR_READONLY_FIELD_REJECTED` + `fields` + `drops`); the six-lock
cascade; the knock-on by id, bulk and strict; the no-exact-set cycle by
id and bulk; the two two-exact-set cycles; the settlement by id (with
header reads), bulk and strict; three unit cases on
`stripReadonlyWhenFields` (the cascade, `only: x`, `only: y`).

On `2c9cbe94eb` (the patch round changed only the two changesets; the
code is `d9af551bb5`'s):

- `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2`: 308 files / 5177 tests passed, exit 0 (the same at
`d9af551bb5`).
- `pnpm --filter @objectstack/objectql typecheck`: exit 0,
`check:test-typecheck: OK`. At `d9af551bb5` the new file was in the
`tsconfig.test.json` program (`--listFiles`: 1) with 0 diagnostics.
`pnpm --filter @objectstack/objectql test:repo`: 5 passed.
- **Ablation**, run at `b5e3de9a35` (the fix and the tests committed;
`d9af551bb5` adds 5 docblock lines). `rule-validator.ts` restored to its
base blob `f3934b80f6` with `git restore --source`, tree only; on-disk
hash verified equal to it, the fix's marker (the loop bound: `step`, a
less-than sign, `judged.length`) counted 0 and the base's `const
overLocked` counted 1. The four suites (new, objectstack-ai#19911's, objectstack-ai#19887's,
objectstack-ai#19853's) ran **14 failed / 96 passed**: every movement pin in the new
file failed, and its 5 unchanged-behaviour pins (both no-exact-set cycle
pins, both two-exact-set pins, `only: y`) passed with PR objectstack-ai#19923's 35, PR
objectstack-ai#19905's 18 and PR objectstack-ai#19877's 38. The subject is imported from source
(`./engine.js`), so no `dist` sits on the path. Restored with `git
checkout HEAD --`: on-disk blob `35fb361521` equals `HEAD`'s, `git diff
HEAD` 0 bytes, `git status` clean, rerun 110/110 passed. The script
carried an `EXIT` / `INT` / `TERM` trap.

## Gates on `2c9cbe94eb`

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: the same 63 commands as at `d9af551bb5`,
each run with its exit code captured before any pipe: 62 exit 0, and
`node scripts/check-empty-changeset.mjs --base origin/main` exits 1 on
the objectstack-ai#19911 note this PR corrects (the refusal's DELIBERATE CORRECTION
class, declared below). `--ran`: `63 derived famil(ies) accounted for —
63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code
and none of them is 3)`, exit 0. The three dist-reading gates ran after
`pnpm exec turbo run build --filter='./packages/*'
--filter='./packages/*/*' --concurrency=2` (72/72, all cached) and exit
0; `git status` stayed clean.
- `node scripts/check-changeset-no-major.mjs --base origin/main`: exit
0. `pnpm check:nul-bytes`: exit 0. `node
scripts/check-changeset-fixed.mjs`: exit 0.
- `node scripts/check-issue-citations.mjs` (live, the verdict CI blocks
on): exit 0 at `2c9cbe94eb`, 6 citations judged, all resolve. `pnpm
check:authz-resolver`, `pnpm check:filter-alias-parity` and the narrowed
eslint run below were measured at `d9af551bb5`; the patch round touched
only the two changesets, which eslint's config ignores.
- `node scripts/check-system-context-census.mjs`: exit 0. `pnpm
check:query-options-erasure`: exit 0.
- The three roster gates the derivation marks as keeping a roster under
this diff's directories: `node scripts/check-changeset-fixed.mjs`, `pnpm
check:authz-resolver`, `pnpm check:filter-alias-parity`, exit 0 each.
- Lint, narrowed and proven. eslint's own config (`ESLint.isPathIgnored`
/ `calculateConfigForFile`) ignores the changeset and lints the three
TypeScript files with `typescript-eslint/parser` and no
`parserOptions.project` / `projectService`. `eslint --no-inline-config
--format json` over them gives 3 results, 0 errors, 0 warnings. The
config enables no type-aware linting, and its only file reads are two
baselines this diff does not touch, so this diff cannot move a verdict
on any untouched file. `pnpm lint` itself is CI's.

## Neighbour PR objectstack-ai#19728

Textually disjoint, not merged in, not waited on. Driver-free bare probe
(`git clone --bare --shared`, no `merge.*` config): `merge-tree
--write-tree --name-only` of `2c9cbe94eb` (and before it `d9af551bb5`
and `b5e3de9a35`) against its head `3b9c5f2fca` exits 0, and against
`origin/main` `fdeeea0cc9` exits 0. The merged tree carries both changes
(that loop-bound marker 1 in `rule-validator.ts`; objectstack-ai#19728's
`RelatedRecordBinding` 5 there and 2 in `engine.ts`).

## Acceptance notes

- **The pending objectstack-ai#19911 release note is corrected in place.**
`.changeset/19911-readonlywhen-interdependent-locks.md` is unreleased
and compiles into the same CHANGELOG as this PR's entry. This PR made
two of its passages false, so both are rewritten in that file and
nothing else there moves: the "What happens now" sentences on the
release (it now repeats round after round until the dropped fields are
exactly the locked ones, and the first, larger set stands after one
round more than the caller's `readonlyWhen` fields), and the residue
bullet that said "The release is one step, not a search" with the
three-lock cascade as its example. That bullet now says a field whose
lock is FALSE on the stored row can still be dropped only where locks
read each other in a cycle (a `parent`-scoped lock counting as a read of
the master-detail field), gives the two cycle shapes this PR pins, and
says some other updates with two agreeing drop sets store one of them.
This PR's own entry no longer repeats what that entry states (the cycle
residue, validation on the stripped update, the no-lock-opens rule) and
no longer points at the old text. The seat chose this correction in the
patch-round order (option A). `node scripts/check-empty-changeset.mjs
--base origin/main` exits 1 on it by design: "Correcting a pending
release note is a decision about a release rather than a refactor -- say
so on the PR, naming the note and what changed under it, and get it
confirmed." So `Check Changeset` stays red, `skip-changeset` is not
applied, and the seat holds this PR's landing for the maintainer's
confirmation.
- **Cycles.** A cycle can have no exact set, one, or several. With none,
the fail-safe drop stands; with several, the answer is the one the
iteration reaches, or the fail-safe drop (measured: equal to base on all
64 such runs).
- **In-tree usage.** PR objectstack-ai#19923 scanned examples and platform objects and
found no `readonlyWhen` reading another `readonlyWhen` field; this
change moves nothing where no such chain exists (on every run where ① or
its first release settled, head's answer and evaluation count equal
base's).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ger over-locks the rest of the update (objectstack-ai#19979)

Fixes objectstack-ai#19929
Clause-②: no

It removes a drop that published text already rules out:
`content/docs/data-modeling/fields.mdx` says the server "ignores writes
to fields whose `readonlyWhen` predicate is `TRUE`". The dropped `x`
below has a FALSE predicate on the row the update stores.

## What changes

`settleReadonlyWhenDrops`
(`packages/objectql/src/validation/rule-validator.ts`) searched for ONE
drop set across every `readonlyWhen` key in the update. When a cycle
stopped that search from settling, it kept the fixpoint's larger set for
the WHOLE update. Now the keys are settled in groups: the strongly
connected components of "the predicate of k reads j through `record`",
taken in dependency order.

- A key in no cycle is judged once, after every key it reads, against
their final drops.
- Keys that read each other in a cycle are settled together, with the
existing fixpoint, release rounds and fail-safe fallback. The bound is
now the cycle's size, and the fallback covers only that cycle's keys.
- What a predicate reads comes from the AST of the canonical parse
(`parseCelToAst`). Only a `.` or `.?` select on the bare `record` root
counts as reading a named field. Any other use of `record`
(`record['b']`, `'b' in record`, `size(record)`, a macro-bound
`record`), a non-CEL predicate, or one that does not parse counts as
reading every judged key. `previous` and `parent` are fixed within one
strip call, so they add no edge.

`engine.ts` is unchanged (H4). The master-detail settlement calls the
same strips; the new `line_beside` pins show its FK landing beside a
cycle with the same header reads as before (`['h_open', 'h_open']`).

I chose strongly connected components over the card's connected
components on measurement (table below). Connected components still
over-lock a key that is in no cycle but is connected to one, for example
a cycle that reads `x`. The per-field sentence the triage asked to make
true ("only where locks read each other in a cycle") holds only with the
per-cycle grouping.

## Measurements

**H1** holds on `main` `2c1011b01b`, read from the code: the function's
last line returns the fixpoint's set for the whole judged list.

**H2** holds on `main`. The new suite, run before the fix, was 17/17
red. In both card shapes the update stored `x: 'old'`, so all five
fields dropped. The pure strip returned `{ x: 'xv' }` for the chain
alone and `{}` beside the cycle. The strict refusal named `['c', 'x',
'y', 'a', 'b']`.

**H3** was tested with a brute-force probe: a temporary vitest file, not
committed, run against the new strips and the BASE strips copied from
`2c1011b`. It covered 16,500 random lock systems, 12,000 single-row and
4,500 bulk with 2 or 3 matched rows, some with index reads. Every check
came back with 0 violations:
- (S) no kept key is locked on the row the update stores (a key is
judged with its own incoming value);
- (E) every dropped key that is unlocked there is in a cycle, under the
reading above;
- (L) each key's verdict is unchanged when the payload is cut down to
the keys that key transitively reads;
- (D) shuffling field declaration order and payload key order never
changes the drops;
- (C) an acyclic system gives the same answer as base;
- (R) the update drops nothing exactly when base drops nothing, so
`strictReadonlyWrites` refuses the same writes.

| over-locked keys (dropped while unlocked on the stored row) | base |
connected components | per cycle (this PR) |
|---|---|---|---|
| total | 664 | 661 | 575 |
| outside any cycle | 90 | 87 | 0 |

Base and this PR agree on 16,405 of the 16,500 systems. They differ in
95:
- 86 where both fall back and this PR over-locks fewer keys;
- 5 where both reach an agreeing set, but a different one;
- 4 where base reached an agreeing set and this PR keeps the cycle's
fail-safe drops (see the acceptance notes).

The rule-validator blob the probe measured is `87b0b5a7`, unchanged
since `ebb8a761` through this head.

**Ablation of the conservative reader.** I committed first, then ran
`node scripts/ablation-replace.mjs` in WRAP mode to change `every =
true;` to `every = false;` (anchor 1 → 0, blob `87b0b5a7` → `9b744861`).
Exactly the two `index_read` pins went red. The ablated update stored
`w: 'wv'` although `w`'s lock (`record['a'] == 'old_a'`) is TRUE on the
stored row, so the lock opens. The restore was proven: blob equal to
HEAD, and `git diff HEAD` empty. The suite imports
`./validation/rule-validator.js` through a relative path, so the subject
resolves to `src`, not `dist`, and no dist preflight applies. The
fixtures declare `z`/`w` ahead of the cycle. Groups with no read between
them come out in declaration order, so a cycle declared first would have
hidden a missed read.

## Tests (head `2d75a593`)

- `pnpm --filter @objectstack/objectql typecheck`: exit 0, test layer
included (234 errors, 65 signatures held in the ledger, unchanged).
- `pnpm --filter @objectstack/objectql test`: 309 files, 5195 tests, all
passed. Every existing `readonlyWhen` pin is unchanged and green,
including the objectstack-ai#19911 and objectstack-ai#19927 suites and the parent, stored-view and
derived-writes suites.
- New `engine-readonly-when-cycle-isolation.test.ts`: 18 pins. They
cover both card shapes (by id, bulk, strict, `isSystem`), declaration
and payload order, a cycle that reads the chain, locks that read the
cycle (select and index), the master-detail FK beside a cycle (by id and
bulk, header reads), and pure-strip isolation including `only`.
- eslint `--no-inline-config` on the 2 changed `.ts` files: 2 files, 0
errors, 0 warnings. The config never enables type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict on any
file it does not touch. The 2 `.changeset/*.md` files fall outside the
lint `files` globs.

## Gates

I ran `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` from this diff. It derived 63 commands; I
ran those plus the 2 families only the dispatch named
(`check:stack-collection-maps`, `check:swallow-census-controls`).
`--ran` reconciles: 63 derived, 61 run, 2 NOT-MEASURED, 0 UNRUN.
- NOT MEASURED, PREREQUISITE NOT MET (exit 3):
`check:dual-build-cjs-loads` and `check:type-check-debt`. Both need
every workspace package's `dist/`, and only the objectql closure is
built here. As a narrower check, `@objectstack/objectql`'s own build
succeeded (ESM, CJS and DTS), and its CJS entry loads (`require` exit 0,
176 exports).
- `check:engine-split-ratio --days 90` refused on the shallow clone
(exit 2). After `git fetch --shallow-since=2026-06-19 origin main` it
passed (exit 0).
- **Red by design: `check-empty-changeset --base origin/main` (exit 1),
the DELIBERATE CORRECTION class.** This PR rewrites the pending
`.changeset/19911-readonlywhen-interdependent-locks.md`, whose sentences
this PR made false:
- its mechanism paragraph said the release rounds stop "after one round
more than the caller sent fields" and that the larger set "stands
instead" for the whole update;
- its cycle bullet said a FALSE-lock field is dropped "only where locks
read each other in a cycle", which was true for the update and false per
field.
Both now describe per-cycle settlement. The pending
`.changeset/19927-readonlywhen-exact-drop-set.md` carries no such
sentence, and every example in it still holds on the new pins, so it is
untouched. Neither changeset had been consumed by a release at base
`2c1011b`. **This correction of a pending release note needs a
maintainer's confirmation on this PR;** the gate stays red until then. I
did not add a `skip-changeset` label.
- All other commands exit 0.

## Serial neighbour

Draft PR objectstack-ai#19728 is still open. I fetched its head `3b9c5f2f` into
`refs/issue-19929/pr19728` and ran `git merge-tree --write-tree
--name-only` of this head against it from a throwaway bare clone with no
merge driver: exit 0, tree `80a7d59f`, no conflicted paths (merge base
`6eaa0f4a`). That PR rewrites the `@objectstack/formula` import on line
195, so this PR imports `parseCelToAst` in a separate statement further
down the import block.

## Acceptance notes

- **A delta inside cycles with two or more agreeing sets.** In 4 of the
16,500 probe systems, base reached one of a cycle's agreeing sets
through the whole-update trajectory, and this PR keeps that cycle's
fail-safe drops instead. Example: `k1: previous.k1 == 'q'`, `k2:
record.k1 == 'q'`, and the cycle `k0: record.k3 == 'p' && record.k2 ==
'q'` / `k3: record.k0 == 'q'`, on the row `{k0: p, k1: q, k2: q, k3: q}`
with `{k0: q, k1: p, k2: p, k3: p}`. With `k2` settled first the cycle
agrees with both `{k0}` and `{k3}`, and the release rounds alternate
between `{}` and `{k0, k3}`. Base's first pass judged `k0` against a
`k2` it had not dropped yet and landed on `{k3}`. Such a cycle's pick
was always an artifact of the iteration (the documented rule is "the one
② reaches, or ①'s when it reaches none"). It now depends only on the
locks the cycle reads. The 19929 changeset states this. No pin flipped.
- A predicate that reads `record` other than as a field select counts as
reading every judged key. In the probe that created a cycle that was not
really there 9 times, each over-locking in the fail-safe direction. This
is the conservative direction the ablation above proves is load-bearing.
- `fields.mdx` never mentions the residual over-lock inside a cycle. The
docs are incomplete there, not wrong, so I noted it and filed nothing.
Carrier: none.
- The `onFieldsDropped` event lists fields in the payload's key order
(`reportDroppedFields` walks the payload). That is existing behaviour,
and the order pin in the new suite states it.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ds for the row it stores (objectstack-ai#20012)

Fixes objectstack-ai#19989
Clause-②: no (narrowing)

## What this fixes

A row-level security `check` (declared on a policy, or defaulted from
its `using`) is a guarantee about the row that is stored
(`RowLevelSecurityPolicySchema.check`; ADR-0058 D4). An insert and a
predicate update are judged on the row the driver stores, through the
engine-run `OperationContext.postHookWriteImageCheck` seam. A by-id
update was judged only inside the security middleware, on the caller's
pre-image merged with the change set as sent, before `next()` runs the
`beforeUpdate` chain. A value a hook wrote into a checked field after
that point was never judged, so the row could be stored outside the
policy, including in an organization the caller does not hold.

A by-id update is now also judged on the row it stores, with the one
existing refusal (`PERMISSION_DENIED` / 403, nothing stored). The
middleware's existing judgement of the change set as sent stays, so this
change only ever refuses more.

## Landing: two packages

- `packages/objectql/src/engine.ts` (`domain:engine`, declared
cross-seat in the claim). The by-id branch of `update()` runs the
installed seam right after `assertNoStrictDrops()`, the same point the
predicate path uses. By then the `beforeUpdate` chain, the hand-back of
withheld read-only values, both readonly strips and the strict-drop
refusal have run, and nothing below changes a value before
`driver.update`. The image is the prior row (already read under the
not-found gate) merged with the final payload, coerced as the predicate
path's images are. The seam's doc comments now name the by-id path.
- `packages/plugins/plugin-security/src/security-plugin.ts`, step 3.6.
The seam is installed for every update, not only a predicate update. The
by-id change-set judgement is unchanged. The post-`next()` fail-closed
guard therefore covers a by-id update too.

**Why the engine is the producer (measured).** The middleware's only
image of a by-id update is taken before the hooks run. The engine is the
one place that holds the final payload and the prior row together, and
it already runs this seam for the other two write shapes.

**One adjacent edge, closed so that the change never admits more.** The
middleware reads a falsy scalar payload `id` as a row address; the
engine does not, and binds a truthy scalar `where.id` instead. In that
shape the middleware's by-id gates judged a different row than the one
written. Before this change the write reached the driver and was refused
only afterwards, by the fail-closed guard, because the seam had been
installed for a predicate path the engine never took (measured: 403 with
the row already stored). With the engine now running the seam on the
by-id path, that guard would stop firing and the 403 would become a 200.
So step 3.6 asks the engine's own dispatch predicate
(`resolveEngineUpdateDispatch`, from `@objectstack/metadata-core`,
already a runtime dependency) and refuses that shape before `next()`,
with nothing stored. It applies only where a `check` applies, which is
the set the old guard covered.

## Mechanism hypotheses (dispatch Section 2), measured

Base: `origin/main` `e8f163fc3a` at worktree creation. It is one
unrelated commit (service-analytics) after the dispatch's `009da14713`.

1. **Held.** Step 3.6's by-id branch, verbatim on the base: `// BY-ID
UPDATE — unchanged. Build the post-image: the caller's pre-image merged
with the change set`, then `let postImage = { ...(opCtx.data) }`, `if
(pre) postImage = { ...pre, ...(opCtx.data) }`, `if
(!satisfiesCheck(postImage)) denyCheck();`. All of it runs before
`next()`.
2. **Reproduced, both drivers (driver-sql on better-sqlite3,
driver-sqlite-wasm), through the real `SecurityPlugin` and `ObjectQL`.**
- The organization shape: admitted, and the stored row carried an
organization the caller does not hold.
- The simpler shape: admitted, and the stored row carried a value the
check refuses.
- Step 3.7 does not catch the organization shape independently: it
judges only an `organization_id` supplied in the change set as sent. On
the tenant-wall harness, re-pointing to a parent in another tenant is
refused earlier, by the reference check (`VALIDATION_FAILED`), not by
step 3.7. A related Layer 0 reading is handed to the seat as an
out-of-scope finding; it is not fixed here.
3. **Found.** The by-id branch of `update()` has the equivalent point:
after `assertNoStrictDrops()` and before `evaluateValidationRules`. The
predicate path's seam call sits in the same position.

## Tests (code head `7efc9eb010`; round 2 head `0474ea38f3`)

New:
`packages/plugins/plugin-security/src/rls-check-by-id-update-post-hook.test.ts`,
24 cells (12 per driver). It uses the real `SecurityPlugin` and
`ObjectQL` on both SQL drivers. Every refusal asserts `code`
`PERMISSION_DENIED` and `status` 403 plus the gate's developer half,
then reads the stored rows straight off the driver's table.

- The negative cells: the organization shape; the simpler shape; a
hook-closed row failing on a field the update never carried (the stored
row, not the change set); fail-closed on a host that strips the
installed seam; a falsy payload `id` beside a truthy `where.id` (for
both `''` and `0`).
- The controls: an in-scope by-id update is admitted and stored; a hook
that touches no checked field is unchanged; the change set as sent is
still refused; a change set the check refuses stays refused when a hook
would replace it with an admitted value; the predicate twin gives the
same answer; a falsy payload `id` with no `where.id` is still a judged
predicate update.
- **Failing first.** The file was committed first (`36f1dff9d6`). Then
both source files were set back to the base blobs and restored from
`HEAD` (blobs equal HEAD, `git diff HEAD` empty). The run gave 12 red
(the six negative cells × two drivers) and 12 green controls.

Ablations were run on the final head, and the counts were identical on
the pre-merge head `414909be35`. Each is one mutation through
`scripts/ablation-replace.mjs`: the anchor hit 1 → 0 and the blob
changed, then the file was restored with blob == HEAD and an empty `git
diff HEAD`. On resolution, the plugin is imported relatively and
`@objectstack/objectql` is aliased to `src/index.ts` in this package's
`vitest.config.ts`, so no `dist/` sits between a mutation and the run.

| # | mutation | red |
|---|---|---|
| A1 | the engine never runs the seam on the by-id path | 10: the three
by-id negative cells (organization, plain, unchanged field) are refused
only by the fail-closed guard, after the row is stored, and the two
admitted controls are refused, × 2 |
| A2 | the plugin installs the seam only for a predicate update (the old
condition) | 8: org, plain, unchanged-field, fail-closed, × 2 |
| A3 | the by-id change-set judgement dropped | 2: the only-refuses-more
cell, × 2 |
| A4 | the falsy-id refusal dropped | 4: both falsy-id cells, × 2 |
| A5 | the engine's image is the payload alone, not merged with the
prior row | 2: the unchanged-field cell, × 2 |
| A6 | the post-`next()` fail-closed guard dropped | 2: the fail-closed
cell, × 2 |
| A7 | the engine never runs the seam on the predicate path | 4: the
predicate twin and the falsy-id predicate control, × 2 |

Suites on `7efc9eb010`, after merging `origin/main`, with the closure
rebuilt:

- plugin-security: 128 files, 2501 tests passed.
- objectql local project: two halves, 156 files / 2454 tests and 155
files / 2784 tests, all passed.
- objectql repo project: 1 file / 5 tests.
- Round 2, on `0474ea38f3`: plugin-auth 114 files / 2439 tests passed
(CI Test Core 6/6 had 8 red in `sys-user-self-service-route.test.ts` on
`7efc9eb010`; see the surface section); runtime 277 files / 3911 passed,
1 skipped; plugin-dev 8 files / 80 passed; plugin-auth `typecheck` exit
0 (test-layer ledger held at 10 files / 94 errors).
- Round 2, real engine: a member updating their OWN `sys_user` row (real
`ObjectQL` on driver-sql, the real `SysUser` schema, the shipped
permission sets, the real identity write guard) is admitted and stored
for `locale` and `name`, with the seam installed and honoured. The peer
row is refused by the by-id pre-image gate, and `role` by the identity
guard. So the plugin-auth red was the harness, not the product path.
- `typecheck` green for both packages. The plugin-security test layer is
at 0 files / 0 errors; objectql holds its ledger unchanged at 40 files /
234 errors.

## Gates

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`
derived 69 families from this diff on `0474ea38f3` (68 on `7efc9eb010`,
plus `check-dev-prereqs --self-test` for the plugin-auth file). All 69
ran and `--ran` reconciled 69 derived / 69 run / 0 NOT-MEASURED. 68 exit
0; `check-empty-changeset` exits 1 by design (see Deliberate
correction). That list is a superset of the dispatch-time list; the
additions include `check:engine-double-contract`,
`check:adr-0087-registration`, `check:empty-changeset`,
`check:durability-log-level` and the type-check ratchets.
- `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`
first answered PREREQUISITE NOT MET (exit 3) and were re-run green after
a full package-closure build.
- The board-probing `GITHUB_TOKEN=… node
scripts/check-issue-citations.mjs` passed: 15 citations resolve. The
dead tracker the in-tree note cited is removed, and is referred to in
words.
- Narrowed lint: `eslint --no-inline-config --format json` over the 13
touched `.ts` files gave 13 files, 0 errors and 0 warnings. Three facts
make it a full measurement for these files. The population is the
`**/*.{ts,…}` and `packages/**` blocks of `eslint.config.mjs`. The
count, 13, comes from the JSON output. The config never enables
type-aware linting (no `parserOptions.project`, no typed rules), so this
diff cannot move the verdict for any untouched file. The repo-wide `pnpm
lint` is left to CI.

## Surface beyond the claim, with reasons

- Eight existing plugin-security test files. Their engine doubles now
run the stored-row check on a by-id update the way the real engine does;
otherwise the fail-closed guard refuses them, which is the intended
behaviour. The files are `authored-row-write-verdict`,
`authz-matrix-gate`, `check-only-write-scope`,
`controlled-by-parent-detail-write-authority`,
`controlled-by-parent-master-widener`, `rls-check-membership-staging`
(its double had judged the payload alone), `security-denial-user-copy`
and `select-only-write-visibility`. `security-plugin.test.ts` changes a
comment only.
- `insert-check-post-image.test.ts`: the in-tree note that recorded this
gap as open now points at the new pins.
- Round 2:
`packages/plugins/plugin-auth/src/sys-user-self-service-route.test.ts`.
Its terminal `next()` stood in for the engine without running the seam,
so the middleware's fail-closed guard refused every own-row write the
file pins as admitted. The failing run's developer message was the
guard's own ("the update on 'sys_user' was executed without the
row-level CHECK being evaluated"), not the check refusal. The terminal
now runs the seam on the fixture row merged with the payload, through
the producer's dispatch predicate, without adding a read to the recorded
pre-image wheres. The census of `SecurityPlugin` hosts outside its own
package found one other hand-made engine double,
`runtime/src/domains/share-links-enforcement-context.test.ts`. It passes
unchanged (runtime suite green), so it is untouched.
- Round 2: the two pending changesets named under Deliberate correction.
- A merge of `origin/main` brought PR objectstack-ai#19728, which touched both files.
It left one conflict in `engine.ts`, at this exact point: the seam and
objectstack-ai#19728's related-record binding for validation both follow
`assertNoStrictDrops()`. The resolution keeps both, with the seam first,
as on the predicate path.

## Behaviour that changes (all in the refusing direction)

- A by-id update whose `beforeUpdate` chain leaves a checked field at a
value the check refuses is refused, and nothing is stored.
- A by-id update on a host that installs the judgement and never runs it
is refused (403, `error` log), as inserts and predicate updates already
are.
- An update whose payload `id` addresses no row while `where.id`
addresses one, under a policy with a `check`, is refused before anything
runs. It used to be written and then refused.

## Deliberate correction

This PR rewrites one sentence in each of two pending release notes it
did not add. Both sentences read as release-level claims, and this PR
makes them false in a release that ships it. Every other byte of both
files is unchanged (reversing each replacement reproduces the base blob
exactly).

- `.changeset/19950-rls-check-multi-row-writes.md`, under "What does not
change".
- Old: "A by-id update and a single-row insert are judged exactly as
before."
- New: "A single-row insert is judged exactly as before. A by-id update
is not changed by this entry; its judgement on the row it stores is its
own entry (objectstack-ai#19989)."
- Why: this PR changes how a by-id update is judged, so "judged exactly
as before" would be false in the release.
- `.changeset/insert-check-post-image.md`, under "What changed,
mechanically".
  - Old clause: "`update` is unchanged."
- New clause: "this entry leaves `update` unchanged, and the predicate
and by-id updates move onto the same seam in their own entries (objectstack-ai#19950,
objectstack-ai#19989)."
- Why: the by-id update now uses the same seam, so a bare "`update` is
unchanged" would be false in the release.

`check-empty-changeset` goes red on this PR, and that is expected. It
refuses any change to a changeset present on the merge base, and names
both files with the DELIBERATE CORRECTION class. Its remedy for that
class is to keep the correction and have it confirmed on the PR, not to
restore the old text. Local run on `0474ea38f3`: exit 1, "This PR
changes a changeset it did not add", listing both files as "present on
the merge base and CHANGED by this PR".

## Acceptance notes

- The change-set judgement is kept, so that this change only refuses
more. As a consequence, a change set the check refuses as sent stays
refused even when a hook would overwrite the refused value with one the
check admits (pinned). Dropping it would match the insert's ruled "judge
the stored row only" semantics, but it would widen, so it is a separate
decision.
- The seam's position is pinned relative to the hooks: the refusal cells
can only pass on post-hook values. Its position relative to the readonly
strips is not separately pinned on this path.
- Ablation A3 reading: the "change set as sent still refuses" cell stays
green without the middleware judgement, because the seam refuses the
same value after the hooks. It is a behaviour control, not a pin of the
middleware judgement; the only-refuses-more cell is that pin.
- The doubles in the two controlled-by-parent harnesses and in
`authz-matrix-gate` model only the by-id path, through the producer's
dispatch predicate. The `security-plugin.test.ts` double holds no table
and judges no rows on an update; this is documented there.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants