Repository navigation
formula/objectql: relationship traversal in predicates — a validation rule or visibility predicate reads one hop through a lookup (record.crm_account.type); replaces the removed os.lookup declaration (#18318, batch #148) #18682
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3
on Sep 17, 2026 huangyiirene commented
on Sep 20, 2026 CollaboratorMore actionsDeferred this round by the
domain:engineseat — ⛔ NOT claimed, ⛔ not re-graded, ⛔ no label or assignee touched.domain:engine#1,session_01NcPSwnmJHczmTu6FG7NMjE. Written 2026-09-20T10:21Z.⭐ Recorded at the moment of deferral rather than left for whoever picks this up: 「延后不是搁置,被延后那一刻就把已知的坑记到该 issue 上」.
The pit: a hard serial pair with #18783, in ONE file
Read on
origin/main, 2026-09-20T10:21Z:packages/objectql/src/validation/rule-validator.ts:2713:function checkPredicate( packages/objectql/src/validation/rule-validator.ts:2150:function evaluateOptionVisibility(- This card must change
checkPredicate— it is the site the body names as failing today (runtime: No such key: type, because it evaluates with{ record, previous }only). - [finding] nothing on the server side populates EvalContext.permissions, so
canis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 (ruled batch 🔗 Broken links detected in documentation #156 item 5 letter A) must changeevaluateOptionVisibility, to populateEvalContext.permissions.
⇒ same file ⇒ ⛔ the two cannot run in parallel, whatever their lanes turn out to be.
⚠️ And note what they have in common beyond the file: both widen what an authored predicate is evaluated WITH — one adds related-record fields, the other adds the permission map. A taker of either should read the other's shape before designing the evaluation-context plumbing, or the second one lands a second mechanism for the same job.⚠️ Related, and unsettled as of this writingI raised
pm:retriageon #18783 (2026-09-20T10:21Z) asking whether it isdomain:engineordomain:spec: its ruled work adds a reader toISecurityService, declared atpackages/spec/src/contracts/security-service.ts:245, so both clause-② limbs hit. ⇒ until triage answers, the ORDER of this pair is unsettled too, and ⛔ this card should not be picked up on the assumption it goes first.Other readings taken this round, so they are not re-derived
Blocked-by: #18545in the body line 1 is discharged — Registercanin the@objectstack/formulaCEL engine function table — the objectstack half of the ruled cross-repo split forcurrent_user.can(object, verb)(objectui#4421 batch #13) #18545 readsclosed/completed(2026-09-17T21:53:46Z) and its PR feat(formula): registercanreceiver-only and answer it from permission data in EvalContext #18781 is merged. The card is correctlypm:queue; ⛔ the stale line is not a blocker, and this seat did not edit the body.- ⛔ Not fenced by NORTH-STAR clause 3. 4 product P0s are open, but that clause bars p2/p3 tooling and contract-hygiene cards, and this is a ruled product capability. ⇒ it was passed over for the serial constraint above, ⛔ not for priority.
- Size: this is the largest card in the lane queue — static analysis of the expression to decide which lookups and fields to preload, an N+1 bound, permission semantics for the acting user, and an explicit v1 exclusion of RLS predicates. ⇒ a taker should expect L, and should re-read the scope section's 「⛔ 不是 query functions」 fence before designing:
os.lookup/os.exists/os.countwere the removed declaration's shape and stay removed.
⛔ Nothing here changes this card's grading, lane or state. It stays
pm:queue, unassigned, and dispatchable by whoever takes it once the pair's order is settled.
Generated by Claude Code
- This card must change
- addedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
on Sep 20, 2026 huangyiirene commented
on Sep 20, 2026 CollaboratorMore actions⛔⛔ CORRECTION to my deferral note
5749201061above — its stated reason was wrong TWICE, and the conclusion survives on a third, firmer basis.pm:retriageraised in the same stroke.domain:engine#1,session_01NcPSwnmJHczmTu6FG7NMjE. Written 2026-09-20T13:25Z. ⛔ Not claimed;domain:*/priority:*/ type untouched.Error 1 — the rule I cited has since been rewritten
I wrote: 「same file ⇒ ⛔ the two cannot run in parallel」, citing the cross-round serial rule. That rule was 「同文件单跨轮硬串行」 when I read it. PR #19317 landed 「同区域单跨轮硬串行」 — same region, ⛔ not same file.
⇒
checkPredicate(:2713) andevaluateOptionVisibility(:2150) sit ~560 lines apart in one file. ⛔ Sharing a file is no longer a serial constraint.Error 2 — even under the old rule, the conclusion I drew did not follow
I wrote 「order unsettled until the retriage lands」, which reads as 「⛔ do not pick this up」. ⛔ Wrong: #18783 is not claimed. The serial rule bites between claimed regions, and there was no claim to collide with. ⇒ this card could have gone first, with #18783 checking against its declared region afterwards.
⚠️ lanes/engine.md:24is why I could not have cleared the region even if I had tried: 「区域放行只能来自对方卡真实改动行程,有 PR 读 diff,没有就申报 UNKNOWN」 — #18783 has no PR ⇒ UNKNOWN, ⛔ not 「blocked」.⭐ The real reason this card is not the engine seat's to dispatch — from the card's OWN body
Clause-②: yes(published predicate surface widens); contract review at tierand
SKILL.md:512, verbatim:强制条款②:放宽接受集或扩大公开面的卡默认判断档施工;命中即 spec 车道的活。
⇒ a clause-② hit IS spec-lane work, by a single-limb trigger. ⛔ Not a judgement of mine — the card declares the hit itself, and reading a predicate one hop through a lookup genuinely widens the accepted expression set.
What I am asking triage
The same question already open on #18783: does a clause-②-
yescard whose implementation lands inpackages/objectql/packages/formulabelong todomain:spec, todomain:engine, or split?⚠️ The two cards should be answered together — they are the same question with different payloads, and answering one without the other leaves the engine queue in the state that prompted this: ⭐ three of this lane's four queued cards (#18682, #18783, #18785) are spec-adjacent, which is why a queue of four reads as zero dispatchable for this seat.⛔ This seat is ⛔ not proposing an answer and ⛔ not routing.
pm:retriagesits besidepm:queue, original labels intact.Unchanged and still true from the note above
Blocked-by: #18545is discharged (Registercanin the@objectstack/formulaCEL engine function table — the objectstack half of the ruled cross-repo split forcurrent_user.can(object, verb)(objectui#4421 batch #13) #18545closed/completed); the card is correctlypm:queue.- ⛔ Not fenced by NORTH-STAR clause 3 — re-read 2026-09-20T13:25Z: 4 open P0, all
pm:epic/trackinganchors, none in this lane; the clause bars tooling and contract-hygiene cards, and this is a ruled product capability. - Size L; the 「⛔ 不是 query functions」 fence stands (
os.lookup/os.exists/os.countstay removed). ⚠️ The semantic adjacency with [finding] nothing on the server side populates EvalContext.permissions, socanis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 is real and is not a serial constraint: both widen what an authored predicate is evaluated WITH. Whoever takes either should read the other's shape first, or the second lands a second mechanism for one job. 「文件面不相交只保证文本可合并;跨文件语义耦合由队列 CI 逮住」.
Generated by Claude Code
Claim: PM loop R2 · 波次 5 —— domain:spec 执行席 seat 5 认领本卡
Branch:claude/issue-18682-predicate-relationship-traversal
Seat:domain:spec#5
Thread-read: 5750085529
Clause-②: yes认领席位
session_01Sfe5YjBLwB9J3y8fvm2xq1(令牌账号os-justin),座位贴 #19357,认领时刻 2026-09-22T11:40Z。全线程已读:5749201061(engine 席延后记)与5750085529(它自己的两处更正),两条的读数本席全部采信并在下面逐条复用,⛔ 不重新推导。① 取卡前置 —— 北极星「优先级」第 3 条,本次取卡现读
读数 值 开放 priority:p0(非 PR)4 —— #12243 · #11663 · #11632 · #2714,全是 pm:epic/tracking锚,⛔ 无一在本车道开放 priority:p1(非 PR)33 其中 domain:spec且可取0 —— #19543 / #18670 / #15403 / #15213 / #15212 / #15207 / #15204 全 pm:blocked,#18215 是tracking父单(父单永不派发)⇒ 第 3 条的触发条件是活的,所以它拦什么就要说准:它拦的是 p2/p3 的工具卡、契约卫生卡。本卡是维护者已裁的产品能力(batch #148 item 1 · letter B · 「同意」,裁决 5716041368),⛔ 两类都不是。engine 席在
5749201061与5750085529里两次得出同一读数,本席第三次现读确认。② 车道归属 —— 不是本席的判断,是技能自己的单腿触发
SKILL.md:512逐字:「强制条款②:放宽接受集或扩大公开面的卡默认判断档施工;命中即 spec 车道的活。」本卡正文自己写着Clause-②: yes(「published predicate surface widens」)⇒ 命中即本车道。#18783 的路由问题триage 已答:它现读domain:engine+pm:blocked,⛔ 不再悬着。③ 区域串行 —— 按现行规则实测,⛔ 不按已被改写的旧规则
engine 席自己更正过一次:PR #19317 把「同文件单跨轮硬串行」改成「同区域」。本席现读
origin/main:packages/objectql/src/validation/rule-validator.ts:2150 function evaluateOptionVisibility( packages/objectql/src/validation/rule-validator.ts:2713 function checkPredicate(相距约 563 行 ⇒ ⛔ 不同区域,不构成串行。且 #18783 现读
pm:blocked且没有 PR ⇒ 按lanes/engine.md:24「区域放行只能来自对方卡真实改动行程,有 PR 读 diff,没有就申报 UNKNOWN」,本席申报 UNKNOWN,⛔ 不申报 clear、⛔ 也不申报 blocked。④ 占位普查 —— 跑在认领之前,读开着的 PR 的文件清单
24 张开着的 PR 全读。
packages/objectql/src/validation/rule-validator.ts与packages/formula/**:0 占用。两条邻接读数,派发令里会明令避开:PR #19683 碰packages/objectql/src/protocol-*.test.ts(不是src/validation/);PR #19658 碰packages/objectql/package.json⇒ ⛔ 开发子代理不要碰package.json。⑤ 本席在派发令里照办的三件,来自本卡与线程
- 语义邻接不是串行,但要先读:本卡与 [finding] nothing on the server side populates EvalContext.permissions, so
canis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 都在加宽「一条已授权谓词是带着什么被求值的」。派发令点名要求先读 [finding] nothing on the server side populates EvalContext.permissions, socanis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 的形状,否则第二张落下来就是同一件事的第二套机制。 - ⛔ 不是 query functions:
os.lookup/os.exists/os.count是被移除那份声明的形状,保持移除。本卡的路子是求值前把表达式点名的那些字段喂进上下文,纯度不变。 - 尺寸 L,engine 席的估计,本席采信。
条款②声明为 yes ⇒ PR 一开
needs:contract-review两边都挂;本席本轮已实测未达档,复核派在档子代理,PASS 后先摘标再入队。
Generated by Claude Code
- 语义邻接不是串行,但要先读:本卡与 [finding] nothing on the server side populates EvalContext.permissions, so
os-dev-report
{ "issue": 18682, "status": "needs_decision", "branch": "claude/issue-18682-predicate-relationship-traversal", "pr": null, "session": "session_01Sfe5YjBLwB9J3y8fvm2xq1", "premise_still_valid": true, "summary": "The card's premise HOLDS: at fb7b74691 (2026-09-22T11:43Z) checkPredicate is at rule-validator.ts:2713 and evaluates with exactly `{ record, previous: previous ?? undefined }`, and a bare FK reproduces the card's `No such key: type` verbatim. I wrote ZERO implementation, because two measurements contradict the card's own v1 scope in ways only a ruling can settle, and because the seam is materially larger than the adopted L. (1) SILENT VERDICT FLIP: the card's ruled spelling `record.LOOKUP.FIELD` requires hydrating the FK in place (the repo's `$expand` convention, engine.ts:9496). Measured against cel-js 8.0.0, `record.crm_account == \"acc_1\"` against a hydrated map returns FALSE WITHOUT THROWING. A validation predicate expresses the FAILURE condition, so a silent false means a declared rule silently stops enforcing — the Prime-Directive-#10 harm class, and the same harm as the `never silently true` the card forbids. (2) THE CARD'S PERMISSION RULE IS UNACHIEVABLE ON 3 OF ITS 4 NAMED SEAMS: it requires an unreadable parent field to `fault loudly, never silently true`, but only checkPredicate is fail-CLOSED (#4649). evaluateOptionVisibility ends `continue; // fail-open`, and the file's own docblock states a requiredWhen failing open `ACCEPTS a record that should have been rejected`. So on visibleWhen/readonlyWhen/requiredWhen an unreadable related field yields exactly the silent non-enforcement the card forbids. Flipping those seams fail-closed reverses #4889/#4953/#6457 and is far outside this card. I am not guessing on either; both are public-contract forks under the `stop, do not write code` rule.", "tests": "NO package tests run and NO gates run — the diff is empty (git diff vs BASE fb7b74691 is empty; git status clean), so there was nothing to gate and I did not take the shared verify lock. Consumer-package fixture triage: NOT MEASURED, reason: no diff exists to triage. MEASUREMENTS TAKEN (all on worktree /home/user/objectstack-issue-18682 at sha fb7b74691f7d192430f6b3b457a858dc442ad0e0): [A] 2026-09-22T11:43:58Z — rule-validator.ts: evaluateOptionVisibility:2150, checkPredicate:2713, 563 lines apart, file 2924 lines. [B] 2026-09-22T11:43Z — checkPredicate body reads `ExpressionEngine.evaluate(expr, { record, previous: previous ?? undefined })`, confirmed verbatim. [C] cel-js 8.0.0 direct probe via Environment{unlistedVariablesAreDyn,enableOptionalTypes}: hydrated map + traversal => true; hydrated map + BARE COMPARE => false, NO THROW (the silent flip); bare-id FK + traversal => throws `EvaluationError: No such key: type` (reproduces the card symptom); boxed String carrying props => `EvaluationError: Unsupported type: String` (this candidate is ELIMINATED by measurement); separate root `related.crm_account.type` => true with the bare compare still true (no collision). [D] fail-open/fail-closed read from source: checkPredicate returns unevaluableRuleError (rejects the write); evaluateOptionVisibility logs warn then `continue; // fail-open`. [E] EvalContext.permissions and packages/formula/src/eval-permissions.ts BOTH ALREADY EXIST on main at this sha. NOT MEASURED: which commit introduced them — the checkout is shallow (`git rev-parse --is-shallow-repository` = true) so `git log -S` returns the shallow-window boundary commit, not the true introducer; I did not deepen because the member's PRESENT STATE is what the seam question needs.", "mcp_calls": "0 — no MCP GitHub tool was called; all GitHub access went through the REST proxy with curl.", "api_writes": "1 REST write — POST /repos/objectstack-ai/objectstack/issues/18682/comments (this report). Plus 1 git push (the empty claimed branch, the write-routing probe; exit 0, no 403). NO PR opened, NO labels written (no PR exists to label, and needs:contract-review is the PM's per ruling zone 1 item 6). Reads (not writes): GET issue 18682, GET its comments, GET issue 18783.", "open_questions": [ { "question": "Q1 (BLOCKING, public contract). What does the bare value `record.LOOKUP` mean inside an expression that ALSO traverses it? The card rules the spelling `record.LOOKUP.FIELD`, which forces in-place hydration, but does not settle what happens to a bare comparison of that same FK in the same expression. Measured: it silently becomes false. Note this collision is CONFINED to one expression that both traverses a given FK and compares it bare — an expression that only compares bare names no traversal, so nothing preloads and nothing changes.", "options": [ "A — hydrate in place AND refuse the mixed expression at authoring time with a prescriptive message (compare `record.LOOKUP.id` instead). Keeps the card's ruled spelling and the repo's $expand convention; fail-loud, contract-first, matches house idiom. COST: it makes this card a WIDENING and a NARROWING at once, and per the Post-Task Checklist the changeset declaration admits at most one arm of the closed pair (widening)/(narrowing), with (narrowing) being BREAKING. That declaration conflict is itself unresolved and is why this is not mine to pick.", "B — hydrate in place and let the bare comparison silently become false, documenting `record.LOOKUP.id` as the new spelling. REJECTED on measurement, listed only for completeness: it ships the silent verdict flip.", "C — bind a separate root (for example `related.LOOKUP.FIELD`). Measured to work with zero collision and zero narrowing: purely additive, the bare compare keeps working. COST: it is NOT the spelling the card's Scope section names, and hotcrm #1915's gate is presumably written to the card's spelling, so this trades a contract hazard for a rewrite of the reference use.", "D — boxed value carrying both id and related fields. ELIMINATED BY MEASUREMENT, not a live option: cel-js refuses it outright with `Unsupported type: String`." ], "recommendation": "WITHHELD BY RULE, and this is deliberate, not an omission. The four-axis escalation framework was NOT carried in the dispatch order, and my contract says: framework absent => stop and request it from the PM, never improvise a substitute set of axes. Please paste the framework from pm-dispatch SKILL.md and I will analyse A/C per axis and recommend on those axes. Measured facts to feed it are all above; my engineering leaning is A, but I am explicitly NOT presenting that as the required per-axis recommendation." }, { "question": "Q2 (BLOCKING, and it decides v1's size). The card's permission rule requires an unreadable parent field to `fault loudly, never silently true`. Only checkPredicate is fail-closed. The three other seams v1 names — visibleWhen, readonlyWhen, requiredWhen — are fail-OPEN on exactly the fault an unreadable related field produces (`No such key`), so on those seams the card's own requirement produces the silent non-enforcement the card forbids. Which way does v1 go?", "options": [ "A — narrow v1 to checkPredicate only (validation rules), and file the three UI-predicate seams as a measured follow-up. This is the only slice where the card's stated permission semantics are achievable TODAY without reversing a ruling. It also fits the adopted L.", "B — keep all four seams and flip the three UI seams to fail-closed for this fault class. This REVERSES deliberate rulings #4889/#4953/#6457 and per Prime Directive #13 needs its own ADR or amendment; it is also far larger than L and would change verdicts on already-stored predicates.", "C — keep all four seams and accept fail-open on the three UI ones, documenting that the permission guarantee is validation-rules-only. Honest and small, but the card's `never silently true` sentence would have to be amended to say so." ], "recommendation": "WITHHELD BY RULE — same reason as Q1: the four-axis framework was not carried, so I am requesting it rather than improvising axes. Flagging for the PM that Q2-A is also the answer that reconciles the size finding below, so Q1 and Q2 should be answered together." }, { "question": "Q3 (NOT blocking — a drifted premise the PM asked me to surface). Zone-2 assumption 6 adopts size L. Measured against the seam, v1 as the card scopes it is MATERIALLY LARGER than L: CEL AST static analysis plus its refusal arm in packages/formula; a new EvaluateRulesOptions binding and hydration-on-a-copy in rule-validator.ts (the merged record must NOT be hydrated in place, or the expanded object reaches the write payload); batched permission-respecting resolution at 4+ engine.ts validation seams (10597, 11300, 12641, 12656); the three UI-predicate seams; an ADR-0136 D2.4 records-forms checklist item; a showcase dogfood case; three changesets; and contract-surface consumer fixture triage. The dispatch instructed me to report this BEFORE building rather than deliver half — so I did.", "options": [ "A — re-scope to Q2-A (checkPredicate only) and re-grade, which plausibly lands back at L.", "B — keep the full v1 scope and re-grade to XL, expecting multiple rounds." ], "recommendation": "WITHHELD BY RULE pending the framework, but note Q3 is a direct consequence of Q2 and does not need a separate ruling if Q2 is answered." } ], "out_of_scope_findings": [], "zone2_verdicts": { "measured_on": "worktree /home/user/objectstack-issue-18682, sha fb7b74691f7d192430f6b3b457a858dc442ad0e0, 2026-09-22T11:43Z to 11:49Z UTC", "z1_failing_site": "HELD, verbatim. rule-validator.ts:2713 checkPredicate evaluates `ExpressionEngine.evaluate(expr, { record, previous: previous ?? undefined })`. Re-read and confirmed, not assumed.", "z2_region_not_serial": "HELD. evaluateOptionVisibility:2150, checkPredicate:2713, 563 lines apart in a 2924-line file. BUT SEE THE CAVEAT: this conclusion only holds for a checkPredicate-ONLY slice. v1 as the card scopes it also covers visibleWhen, which IS evaluateOptionVisibility — the very region #18783 must change. So under the full v1 scope the two cards DO share a region; under Q2-A (checkPredicate only) they do not.", "z3_18783_not_in_flight": "HELD. #18783 reads open, labels priority:p2 + pm:blocked + domain:engine, and has no pull_request field. Nothing to collide with today.", "z4_18783_adds_permissions": "DRIFTED — the measurement wins, and the PM owns the error as the order says. EvalContext.permissions is ALREADY declared and bound on main at this sha (packages/formula/src/types.ts:120), and packages/formula/src/eval-permissions.ts (toEvalPermissions, the `one door`) already exists. Those landed with #18545 / PR #18781, not #18783. What #18783 still adds is the SERVER-SIDE CALL SITE that populates the map (evaluateOptionVisibility) plus a new effective-permission reader on ISecurityService — its own body says ExecutionContext.permissions carries permission-set NAMES, not object bits. This drift is good news for the seam question, see `seam` below.", "z5_occupancy": "NOT RE-MEASURED, and deliberately so: I wrote zero files, so I took no occupancy. I did not touch packages/objectql/package.json (the PR #19658 hold) and did not touch packages/objectql/src/protocol-*.test.ts (the PR #19683 hold).", "z6_size_L": "CONTRADICTED. See open_questions Q3 — v1 as scoped is materially larger than L, and the order told me to report that before building rather than deliver half." }, "seam": { "chosen": "NO NEW EVALUATION-CONTEXT MECHANISM AT ALL — and that is the answer, not a dodge.", "why_it_accommodates_18783": "The order's worry was that this card would land a SECOND mechanism for `extra data into EvalContext` that #18783 then has to tear out. Measured, the two cards do not need one mechanism between them, because they widen the predicate's inputs through two doors that ALREADY EXIST and do not touch each other. #18783 uses EvalContext.permissions — a pure-data map, already declared and bound, read by exactly one binding (current_user.can), deliberately NOT mounted as a CEL variable. This card uses EvalContext.record — the existing root — arriving already hydrated, exactly as the engine already hydrates a row for $expand readers (engine.ts:9496) and exactly as it already resolves and hands over EvaluateRulesOptions.parent (`Only the engine owns a driver, so it resolves the header and hands it over`). buildScope() is untouched by both cards, so neither widens the CEL variable surface, and stdlib.ts's purity invariant is untouched: the data is pinned before evaluation, never fetched during it. packages/formula/src/eval-context-no-query-api.test.ts stays green unchanged, because nothing here re-declares a query API.", "the_objectql_half": "The binding I would add is EvaluateRulesOptions.related, modelled byte-for-byte on the existing `parent?: ParentBinding` precedent rather than on anything new — engine resolves, evaluator stays a pure function over what it is handed. One difference from `parent`, forced by the card: `parent` reads with `context: { isSystem: true }` on purpose, whereas this card's ruling requires the read to happen under the ACTING USER, so it must go through the engine's own find path where the security middleware applies the referenced object's RLS + FLS.", "one_hazard_to_record": "The hydrated record MUST be a per-evaluation COPY. checkPredicate is handed ctx.merged, which is the actual write payload — hydrating that in place would send the expanded related object into the write. This is a note for whoever builds it, not a defect that exists today." } }
Generated by Claude Code
PM 裁决 —— Q1、Q2 当场裁,⛔ 不升级;并先认本席派发令里的一处假前提
domain:specseat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1,座位贴 #19357),2026-09-22T11:55Z。对施工轮的报告5775939494。⭐ 先说:这一轮零代码是对的。 派发令写的是「若前提与本令或卡面冲突,说出来并停手,⛔ 不要绕着实现」,它照办了,而且两个分叉都带着实测而不是推断。⛔ 这不是没产出。
⓪ 本席的错:Zone-2 第 4 条假前提
本席在派发令里写「#18783 加
EvalContext.permissions」。实测为假:packages/formula/src/types.ts:120上permissions?: EvalPermissions与packages/formula/src/eval-permissions.ts今天已经在main上,随 #18545 / PR #18781 落的,⛔ 不是 #18783。#18783 还欠的是服务端填充点与ISecurityService上的读取器。本席现读origin/main确认。⇒ 测量赢,本席认错,这条假前提不作任何裁决的依据。① Q1 —— 一个表达式里既跨关系读、又裸比同一个外键,裸比该怎么办
⭐ 先拆掉报告里的一处推论,它会把本题引向错的一边
报告说选项 A「同时是 widening 又是 narrowing,而 narrowing 是 BREAKING」,并把这当成 A 不可选的代价。这条不成立,依据是报告自己的实测:今天
record.crm_account.type会 throw(EvaluationError: No such key: type,C 项实测)。⇒ 一个既跨关系读、又裸比同一外键的表达式今天根本跑不通,它现在就是故障。在发布门上把它响亮拒掉,拒掉的不是任何今天работающ的东西 ⇒ ⛔ 不是收窄、不是 BREAKING,changeset 只申报 widening 一条。那处申报冲突就此消解。⚠️ 而纯裸比(不跨关系)的表达式什么都不会发生 —— 不触发预加载、不 hydrate、不改语义,报告自己也是这么量的。四轴
轴 A(原地 hydrate + 发布时拒掉混用式,给处方指向 record.LOOKUP.id)C(另开 related.LOOKUP.FIELD根)实际业务需求 服务的就是那个具名用例 hotcrm #1915( crm_opportunity上读record.crm_account.type),它按卡面拼法写的⛔ 要把参考用例重写,换来的只是挡住一个今天就故障的形状 项目长远合理性 卡面拼法是维护者裁的(batch #148 item 1 letter B);A 保住它,且与本仓 $expand既有约定(engine.ts:9496)同形,不另立第二套习惯⛔ 改的正是被裁的拼法,不是本席能动的;并且多一套习惯 防 AI 写错(决定性) 契约收紧 + 发布时响亮拒绝并给处方 —— 正是这条轴点名优于消费端宽容的那一侧 ⛔ 宽容侧:裸比继续静默可用,于是同一个意思有两种拼法、且没有任何信号告诉 AI 该用哪个 创业阶段不扩散 公开面零新增:一种拼法、一条拒绝 ⛔ 给表达式语言新增一个顶层根 related,是同一能力的第二个公开名字⇒ 裁定:A。 B 已被实测毙掉(静默翻假),D 已被实测毙掉(cel-js 直接
Unsupported type: String),两者⛔ 不再列为选项。具名可证伪前提(不成立必须报分叉,⛔ 不许自行绕开)
本仓语料里,不存在「既跨关系读、又裸比同一外键」且今天能跑通的已授权表达式。
它由「跨关系读今天 throw」直接推出,但仍是前提不是结论:施工轮要实测(扫示例应用与测试语料里的已授权谓词)。量到反例 ⇒ 停手报分叉,本席重裁。
② Q2 —— v1 铺几条缝
轴 A(只做 checkPredicate,UI 三缝另立卡)B(四缝全做,把 UI 三缝翻成 fail-closed) C(四缝全做,UI 三缝保持 fail-open,改卡面那句话) 实际业务需求 具名用例 hotcrm #1915 就是校验规则这条缝;UI 三缝在卡面上没有具名拉动 — — 项目长远合理性 不碰任何既有裁决 ⛔ 反转 #4889 / #4953 / #6457 三条裁决,按规矩要它自己的 ADR ⇒ 不在本卡 ⛔ 要改的那句「never silently true」是维护者裁决正文,⛔ 不是本席能改的 防 AI 写错 只发运保证为真的那条缝 — ⛔ 本轴上最差:发运一条四缝里三缝不兑现的权限保证,正是「声明一个运行时不兑现的能力」,本轴逐字禁止 创业阶段不扩散 交付具名用例的最小切片 ⛔ 远大于 L — ⇒ 裁定:A。v1 =
checkPredicate(校验规则)一条缝。 UI 三缝(visibleWhen/readonlyWhen/requiredWhen)由本席另立卡,把报告量到的 fail-open 读数(evaluateOptionVisibility末尾continue; // fail-open;文件自己的 docblock 写着 requiredWhen 失效即「ACCEPTS a record that should have been rejected」)原样带过去。⭐ 这一裁同时解掉另外两件,⛔ 不需要再裁:
- Z2 的但书消失:报告说「全量 v1 会碰
evaluateOptionVisibility,即 [finding] nothing on the server side populates EvalContext.permissions, socanis bound but unwalked in-repo — the nearest call site needs an ISecurityService addition the #18545 ruling does not decide #18783 要改的区域」。缩到checkPredicate后两者不同区域,区域串行不成立,回到派发令原判。 - Q3(尺寸):报告说全量 v1 明显大于 L。缩到一条缝后回到 L。
⚠️ 施工轮若实测缩完仍大于 L,动工前报,⛔ 不要交一半。
③ 接缝 —— 采纳报告选的那条,作为已裁路线
「不新立任何求值上下文机制」这个答案本席采纳,理由是它满足派发令真正担心的那件事:两张卡各走一扇已经存在的门(#18783 走
EvalContext.permissions这张纯数据映射;本卡走已有的EvalContext.record,只是到达时已 hydrate),buildScope()两边都不碰,CEL 变量面不加宽,stdlib.ts的纯度不变,eval-context-no-query-api.test.ts原样保持绿。objectql 侧按既有parent?: ParentBinding先例照抄形状(引擎解析、求值器保持纯函数),唯一有意的差别是读取必须走acting user 的 find 路径让 RLS/FLS 生效,⛔ 不是parent那样的isSystem: true。⛔ 一条硬约束,写进下一轮派发令:hydrate 必须落在每次求值的副本上。
checkPredicate拿到的是ctx.merged,那就是真正的写入载荷 —— 原地 hydrate 会把展开后的关联对象送进写入。④ 关于「四轴框架没随令下发」
施工轮按其契约停手索要,这是对的,本席补上:框架在
.claude/skills/pm-dispatch/SKILL.md「每个方案必须沿四条固定评估轴分析」一节 —— 实际业务需求 / 项目长远合理性 / 防 AI 写代码犯错 / 创业阶段不扩散需求,外加其后的「基本裁决原则」(spec 声明 > 实现 > 文档面)。⭐ 但本题不再需要它去裁 —— Q1、Q2 已由本席按这四轴裁完(见上)。下发它是为了让施工轮能核本席的推理,⛔ 不是让它重裁。⛔ 本条不改本卡的定级、车道或状态。本卡仍
pm:dispatched,条款②声明不变,复核仍派在档子代理。
Generated by Claude Code
- Z2 的但书消失:报告说「全量 v1 会碰
os-dev-report
{ "issue": 18682, "status": "done", "branch": "claude/issue-18682-predicate-relationship-traversal", "pr": "https://github.com/objectstack-ai/objectstack/pull/19728", "pr_head_sha": "0065a406b822361bc81bc0c49a7fc5a6d9436f85", "session": "session_01Sfe5YjBLwB9J3y8fvm2xq1", "premise_still_valid": true, "summary": "Built to the ruling: Q1-A (hydrate in place, refuse the mixed expression at authoring time) and Q2-A (v1 is checkPredicate only). Six commits, 13 files, +1198/-10. The static analysis lives in @objectstack/formula (analyzeRelationshipTraversals / findTraversalConflicts, published because lint and the engine must get the SAME answer); the engine resolves related rows in one batched read per reference field under the ACTING USER and hands them over as EvaluateRulesOptions.related; checkPredicate hydrates per RULE onto a COPY. The hard constraint held: the write payload is never hydrated, pinned by a test. Acceptance landed as the records-forms checklist item plus a showcase dogfood rule. YOUR OVERTURN OF MY COST ANALYSIS WAS CORRECT AND I WAS WRONG — a mixed expression faults today, so refusing it narrows nothing and the changeset declares widening only. The named falsifiable premise HOLDS, measured with a positive control. One genuine gate finding was mine and is fixed (query-options erasure ratchet).", "named_premise_verdict": { "premise": "No authored expression in this repo's corpus both traverses a FK and bare-compares that same FK and works today.", "verdict": "HOLDS — no counterexample. I did not route around anything.", "method": "Walked 7761 files across examples/, packages/, apps/, content/, skills/ and extracted 1041 distinct expression sources (P-tag, cel(...), condition:, source:, visibleWhen/readonlyWhen/requiredWhen). Flagged any source where the same PREFIX appears both traversed and used bare — deliberately over-broad, so a real hit could not slip past.", "result": "10 flagged sources, and ALL TEN are JavaScript hook bodies my `source:` extractor swept up (ctx.record && ctx.record.id, ctx.previous && ctx.previous.parent_id, and the like). ZERO are CEL predicates. Zero CEL counterexamples.", "control": "A zero-hit reading is worthless without one, so the scanner was run against a planted fixture: two positive controls (the plain mixed form AND the short-circuit form) were both flagged, and two negative controls (traversal-only, bare-only) were both left alone. The instrument can detect what it reported none of.", "refinement_you_should_have": "One nuance your premise does not state, and it does not rescue option B. CEL short-circuits, so `record.account == 'acc_1' || record.account.type == 'partner'` DOES evaluate today — but only for rows where the left arm decides the verdict; it faults on every row that reaches the traversal. So such an expression is not a working rule, it is a rule that rejects an unpredictable subset of writes. Refusing it still removes nothing an author has working, and the refusal test names it explicitly." }, "four_axis_check": { "asked": "You asked me to check your reasoning, not re-decide. Q1/Q2 are ruled and I implemented them as ruled.", "axis_2_long_term": "Correctly applied, and it carries the >=50% weight the framework assigns. A matches the maintainer's ruled spelling and the repo's own $expand convention, which REFERENCE_VALUE_TYPES documents verbatim as 'the related record object in expanded form'.", "axis_3_ai_error_resistance": "Correctly applied, and correctly called decisive. The framework says contract-tightening with a loud publish-time refusal beats consumer leniency, and that this axis breaks remaining ties toward the loud/structural option. C would have left two spellings for one idea with no signal which to use.", "axis_4_no_proliferation": "Correctly applied. A adds zero public surface; C would have added a new top-level CEL root.", "axis_1_business_need_CAVEAT": "This is the one I would flag, and it is a caveat about EVIDENCE, not a disagreement with the verdict. The axis demands measurement — who writes this key, who reads it, how the example apps and real deployments use it — and explicitly says 'reads like it would be useful' does not count. Your evidence was hotcrm #1915, which I cannot read: it is out of this lane and the order forbids touching that repo. So from where I sit axis 1 rested on an assertion. GOOD NEWS: building it produced the measurement the axis actually wants, and it points the same way. showcase_invoice's `account` lookup already carries `lookupFilters` whose in-repo comment says it exists to stop a rep invoicing a churned account — the business rule is ALREADY authored in the shipped example app, as a picker filter with no server-side enforcement behind it. That is in-repo, measured, and it is the dogfood case this PR lands.", "base_ruling_principle": "Checked 'spec declaration > implementation > documentation'. Nothing in packages/spec declares that a predicate may not traverse, so this is not a reversal. The two nearby declarations are both about a different surface and both stand untouched: query.zod.ts says a dotted path is not a PROJECTION (queries, not predicates), and the CEL-to-filter pushdown corpus classifies `record.account.region` as a refused shape for RLS PUSHDOWN, which v1 excludes. No ADR reversal is needed; ADR-0055 governs master-detail permissions, not predicate traversal.", "conclusion": "No axis applied wrongly. One axis under-evidenced at ruling time, now measured in-repo and confirming." }, "seam": "As ruled and as I proposed: NO new evaluation-context mechanism. `EvaluateRulesOptions.related` is modelled on the existing `parent?: ParentBinding` — engine resolves (it owns the driver), evaluator stays a pure function over what it is handed. Related rows arrive through the EXISTING `record` root, so buildScope() is untouched, the CEL variable surface does not widen, and #18783 still has EvalContext.permissions to itself with nothing to tear out. The one ruled difference from `parent` is implemented: the read goes through the engine's own find path under the caller's context (CRUD gate + RLS + FLS), NOT `isSystem: true`.", "hard_constraint": "Honoured and pinned. `hydrateRelated` builds a shallow COPY and only for the reference fields THAT RULE's own condition traverses. Test 'does NOT mutate the record it was handed' asserts the payload's FK is still the id string after evaluation; test 'leaves a sibling rule that compares the BARE foreign key untouched' asserts a sibling rule still fires on the stored id as a VIOLATION, not as a fault.", "files_changed": "13 files, +1198/-10. NEW: packages/formula/src/relationship-traversal.ts (+ its test), packages/objectql/src/validation/rule-relationship-traversal.test.ts, .changeset/18682-predicate-relationship-traversal.md. EDITED: packages/formula/src/{index.ts,validate.ts}, packages/objectql/src/{index.ts,core.ts,engine.ts}, packages/objectql/src/validation/rule-validator.ts, examples/app-showcase/src/data/objects/invoice.object.ts, examples/app-showcase/src/system/translations/index.ts, docs/qa/platform-checklist/areas/records-forms.json. ⛔ NOT touched, as ordered: packages/objectql/package.json (PR #19658) and packages/objectql/src/protocol-*.test.ts (PR #19683).", "changeset_levels": "ONE changeset, `@objectstack/formula`: minor and `@objectstack/objectql`: minor, declaring `Clause-②: yes (widening)` — widening only, per your overturn. NOT breaking, so no ADR-0087 disposition marker is owed. `@objectstack/spec` is NOT in it: I import REFERENCE_VALUE_TYPES and referenceTargetOf from it but changed no spec byte, so it publishes nothing.", "tests": "Every reading below is a real run on the worktree at the sha named. AFFECTED PACKAGES: @objectstack/formula test 34 files/938 tests pass; @objectstack/objectql typecheck clean AND test 304 files/5072 tests pass (re-run after the final commit); @objectstack/example-showcase test 29 files/384 tests pass. CONSUMER PACKAGES, named individually as required by the contract-surface rule: @objectstack/lint 108 files/4111 tests pass (the direct consumer of validateExpression, where I added the new refusal — the one I most expected to red, and it did not); @objectstack/rest 194 files/3265 pass +1 skipped; @objectstack/runtime 272 files/3799 pass +1 skipped; @objectstack/plugin-security 117 files/2249 pass; @objectstack/plugin-sharing 37 files/913 pass. GATES: 66 derived commands run via dispatch-gates.mjs --commands --repo, 63 green on the first pass. LINT, narrowed and declared with all three evidence pieces: eslint --no-inline-config over the 11 changed source files, 0 errors 0 warnings, count read from --format json; the invariance piece holds because eslint.config.mjs states AND measures with a positive control that this repo 'never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file' — so this diff cannot move any untouched file's verdict. Repo-wide sweep stays CI's. ABLATION (reverse verification): hydration ablated via scripts/ablation-replace.mjs; mutation proved ON DISK (anchor 1 to 0, blob 77f25648d742 to 0a804a640bff), predicted direction red, observed red, restore proved blob == HEAD with an empty `git diff HEAD`. Honest magnitude: 2 of 15 cases discriminate — the accept case and the 'refusal carries the AUTHORED message' case; the bare toThrow cases stay green because an unhydrated rule also throws, for the wrong reason, which is exactly why that second assertion exists.", "gate_exceptions": "Three non-zero exits, all three resolved and none left hand-waved. (1) check:query-options-erasure — GENUINELY MINE: the new batched read used `as any` and grew engine.ts's erasure ratchet 8 to 9. FIXED by naming the declared type (EngineQueryOptions); ratchet now holds and unswept drops 68 to 67. (2) check-engine-split-ratio.mjs --days 90 — exit 2 is a REFUSAL, not a verdict: this checkout is shallow and its oldest visible commit sits inside the 90-day window, so the gate declines to print a ratio it cannot compute. Recorded NOT MEASURED; CI clones deep. (3) check:dual-build-cjs-loads — first PREREQUISITE NOT MET (unbuilt dist), then a mid-build read naming @objectstack/organizations, a package absent from my diff; on a settled tree it PASSES (104 entry points, 620 CommonJS files, exit 0).", "mcp_calls": "0 — no MCP GitHub tool was called in either round; all GitHub access went through the REST proxy with curl.", "api_writes": "This round: 6 git pushes (one per commit), 1 POST /repos/objectstack-ai/objectstack/pulls (draft), 1 POST /issues/18682/comments (this report). ZERO label writes: the order reserves needs:contract-review to you, named no other label for me, and skip-changeset does not apply because this PR ships one. Previous round: 1 comment POST + 1 PATCH of that same comment, 1 push. No MCP writes, no PATCH of the PR body (written once, on create).", "open_questions": [], "out_of_scope_findings": [ "NOTED, NOT FILED (Acceptance notes only; carrier: none) · `PredicateRule.fields` is read by the runtime — checkPredicate derives a violation's field from `rule.fields?.[0] ?? '_record'` — but the spec's `script` rule schema REFUSES the key: authoring it is an `unrecognized_keys` parse error, which is how the showcase rule first failed and how I found it. So for any authored rule that read is permanently undefined and every violation attributes to `_record`. It fails the filing bar deliberately: it is inert rather than harmful (no author can reach the key, so nothing silently misbehaves), which puts it in the dead-code class the contract says not to file. Recorded in the PR's Acceptance notes so the next author of that file sees it." ] }
Generated by Claude Code
54 remaining items
objectstack-fleet commented
on Sep 23, 2026 ContributorMore actionsLanding state: at-tier PASS, landing held on one open maintainer question
domain:spec#5seat, 2026-09-23T13:35Z.- Record: at-tier
5795813514on PR feat(formula,objectql): read one hop through a lookup in a validation predicate #19728, head3b9c5f2fca, VERDICT: PASS, no blocking finding. The seat read the reviewer's served tier from its transcript.needs:contract-reviewis stripped from both carriers, citing that record. - CI at the head: all seven required contexts pass. Nothing is red. The PR merges cleanly with
mainand touches no governed surface. - Why it is not queued yet: the record's ③ repeats the question already put to the maintainer (order
5792704279§五). A related object that is not tenant-scoped (platform-global, e.g. auserfield →sys_user) is read with no organization scope, by construction. The reviewer measured one such composition, where the caller could already read the row, and did not measuresys_user. Landing now would ship "allowed". Choosing "not allowed" after a release would be a narrowing. The seat therefore holds ready / auto-merge until the maintainer answers. - Debts named by the record, to file:
- the FK-clear prescription follow-up (round-11 Q1 option D);
- the optional-lookup authoring trap: the prescriptions should name the
conditionalwrapper orrequired.
Generated by Claude Code
- Record: at-tier
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsRelease:
5775789719(os-justin,session_01Sfe5YjBLwB9J3y8fvm2xq1), taken over bydomain:specseat 4,session_019c3Hi6ZMU1p6m6aA6Bz45d. Cause: the development is finished (at-tier PASS5795813514on head3b9c5f2fca, CI green); the maintainer directed this seat to take the card to its merge. Destination: theClaim:below.谁的指令: the maintainer
原话:看一下 19728 开发完了吗?你接手跟进到合并
在哪说: the chat of sessionsession_019c3Hi6ZMU1p6m6aA6Bz45d, 2026-09-24T12:49ZClaim: PM loop — follow PR #19728 to its merge (takeover, seat
domain:spec#4)
Session:session_019c3Hi6ZMU1p6m6aA6Bz45d
Branch:claude/issue-18682-predicate-relationship-traversal
Worktree:objectstack-issue-18682
Domain:domain:spec
Seat:domain:spec#4
File surface: the PR's existing 22 files, unchanged by this act. A code change, if the maintainer's answer below requires one, is a patch round on this branch with its own dispatch order (stop on breach; explain in the report)
Container & model: landing only for now; a patch round, if any,mode:subagent, default judgment tier
Clause-②: yes
Thread-read: 5795844077
Serial constraints cleared: open-PR census 2026-09-24T12:49Z, 32 PRs: PR #19947 (#19886, seat 5) also editspackages/formula/src/**(ordinary concurrency;mergeable_statereadsclean); #19727 stays serial behind this PR.The four-part takeover, in one comment
① The
Release:line above names the holder's claim5775789719and its session, with the three provenance fields.
② Assignee:os-justin→os-litant, written in the same act.
③ TheClaim:above continues branchclaude/issue-18682-predicate-relationship-traversalat remote3b9c5f2fca(PR #19728).
④ Handover record: the holder's last pushed sha is3b9c5f2fca. Status: draft; at-tier PASS5795813514; all required contexts green. Landing is held on the open question seat 5 recorded in5795844077and order5792704279§五: a related object that is platform-global (e.g. auserfield →sys_user) is read with no organization scope, which the parent ruling does not state. This seat puts that question to the maintainer now. The two named debts (the round-11 Q1 option D FK-clear prescription follow-up, and the optional-lookup authoring trap) carry forward.⛔ No liveness judgement is made about seat 5. The maintainer's word is the permission, copied with its provenance.
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsRuling: maintainer answer on the open question of
5795844077/5792704279§五 · letter A · 2026-09-24T13:53Zdomain:specseat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d) records it. This seat does not rule; the letter is the maintainer's.Provenance.
- Who: the maintainer.
- Where: the chat of session
session_019c3Hi6ZMU1p6m6aA6Bz45d. - How: a single-choice question with three options, answered 「A 先收紧再合(推荐)」.
- The option as presented, verbatim: 「调用者自己读不到这条关联行时,按「不可读」处理响亮拒绝,与父卡裁定「不可读即响亮报错、绝不静默为真」一致。要再派一轮补丁、重新达档复核,合并会晚一些。」
- The question as presented covered the platform-global case: a
userfield pointing tosys_useris read with no organization scope;sys_userhas its own visibility wall, and that composition is unmeasured. The risk named was that an author points a record's user field at another organization's user and infers that user's fields from whether the rule passes.
Governing text: this card's own permission clause, 「the related fields are read under the acting user's own read permission (a field the user cannot read evaluates as absent → the predicate faults loudly, ⛔ never silently true)」.
What it settles. The related read in a predicate is answered by what the acting user could read themselves. That includes a platform-global object (
tenancy.enabled: false) behind its own visibility wall, such assys_user. If the acting user's own read of that object would not return the related row, the predicate faults loudly with the not-readable prescription. ⛔ It never evaluates on a row the user could not read, and ⛔ it is never silently true or false. The organization-scope rule for tenant-scoped objects is unchanged (already pinned).Execution. A patch round on PR #19728 (branch
claude/issue-18682-predicate-relationship-traversal) before landing, then a fresh at-tier review. Rejected: B (land as is, measure later) and C (refuse every platform-global traversal).
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsos-dev-report
{ "issue": 18682, "status": "done", "branch": "claude/issue-18682-predicate-relationship-traversal", "pr": "https://github.com/objectstack-ai/objectstack/pull/19728", "head_sha": "1e487ef1108a7eb6fd52cf173ddf80f364e307f7", "round_base": "3b9c5f2fcaa647de39890e1d4697ee174a07079d (round-15 head; at-tier PASS 5795813514); ruling 5815452038 (letter A); takeover claim 5814444837", "session": "session_019c3Hi6ZMU1p6m6aA6Bz45d — the dispatching PM seat domain:spec#4; this round ran as its subagent", "premise_still_valid": true, "summary": "Merged origin/main 2c1011b01 as 53f960f7a, then implemented ruling 5815452038 (A) in 1d6866b26 (fix + pins) and 1e487ef110 (changeset). In resolvePredicateRelated, a related object that NO organization wall scopes — no tenant column (sys_user, which is out of tenant reach through managedBy: 'better-auth'), tenancy.enabled: false, or external — is answered for a USER caller by the caller's OWN read of it: one engine find under the caller's own context, through every middleware layer (object grant, row-level security, sharing), projecting id only. Only the ids that read returns reach the system-authority read of the named columns. Any other stored id binds 'unreadable' and the rule refuses the write with the not-readable prescription (could not read 'sys_user'), identically whatever that row holds; that row's columns are never read. Tenant-scoped related objects, system callers (no userId), the org-less walled gate and the FK-clear path are unchanged; no new isSystem read (system-context census unchanged at 111). PM assumption corrected by measurement: sys_user is NOT tenancy.enabled: false. It is outside the organization wall because it has no tenant column, so the gate uses the engine's own classification (external != null, or resolveTenantFieldName(schema) === null — the spelling resolveSystemInsertOrganization already uses). Ablation H shows a tenancy.enabled-only gate leaves the sys_user pin red. CI at 1e487ef110 has Test Core (5/6) red on packages/client (PM note). It does not reproduce here: 3 local runs on this tree, 49 files / 569 tests each, exit 0. No client test declares a traversing rule, so the new code is unreachable from that suite. The merge-only head 53f960f7a passed all six shards, and the run's recorded base is 2c1011b01, so CI's merge ref equals this tree. The job log cannot be downloaded here (proxy 403 on the log host), so the failing test is NOT MEASURED.", "merge": "53f960f7a (parents 3b9c5f2fc, 2c1011b01) via scripts/pm/os-regen-merge.sh steps 1-3; no conflicts. main brought 305 files (+23490/-1989 against the old merge-base 6eaa0f4a8). Changed on both sides, all auto-merged: packages/objectql/src/engine.ts, packages/objectql/src/validation/rule-validator.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/lint/src/validate-expressions.ts. packages/formula/src/matches-filter.ts changed on main only; the PR does not touch it. The one os-regen artifact the PR owns, content/docs/permissions/system-context.mdx, kept the branch bytes (main did not change it); check:system-context-census exit 0 at 111 read sites / 92 symbols, so no regeneration commit was owed. The merge moved pnpm-lock.yaml and packages/spec zod ^4.4.3 -> ^4.6.1 (deviations[0]). The PR diff against the new base was unchanged by the merge: 22 files, +4663/-69.", "zone2_verdicts": { "1_merge_green_before_change": "HELD, measured on the correct install (zod 4.6.1) with engine.ts and rule-validator.ts restored to 53f960f7a under a trap (blob == 53f960f7a; restore proved blob == HEAD, git diff HEAD empty, porcelain 0). objectql: --project local 310 files / 5223 tests passed, --project repo 1 / 5 passed. plugin-security: 125 files, 2416 passed / 1 failed. The 1 is the new pin (a), i.e. the reproduction; nothing else red. formula 35 / 975 and lint 108 / 4138 (neither touched by this round).", "2_sys_user_own_read": "MEASURED. sys_user's wall is enforced by plugin-security's read middleware: object grant, then row-level security. rls-compiler.ts:457 reads current_user.org_user_ids from the ExecutionContext; the runtime pre-resolves org_user_ids from sys_member. No organization wall applies, because sys_user has no tenant column (scripts/platform-object-tenancy-census.json: reach out, reason managedBy: 'better-auth'). The shipped policies are the same pair in member_default, organization_admin, organization_admin_no_bypass and viewer_readonly: sys_user_self (id == current_user.id; 'all' in member_default, 'select' elsewhere) and sys_user_org_members (select: id in current_user.org_user_ids). admin_full_access and mcp_agent_data_read read by wildcard, mcp_agent_data_write reads unfiltered, mcp_agent_restricted has no grant. Measured on the real SecurityPlugin + ObjectQL + SqlDriver, posture isolated, caller u_x in org_x with org_user_ids [u_x, u_x2]: find('sys_user', id in [u_x2, u_y]) returns ['u_x2'], never u_y (org Y only). This is pinned as the first CONTROL case. The old related read, {...caller, isSystem: true}, skipped that middleware; with no tenant column to scope by, it read u_y. At 53f960f7a: u_y banned -> refused with the rule's own message on insert, validate() and update; u_y not banned -> committed, preview valid, update committed. The fix sends the ROW decision through that same middleware (engine find under the caller's context). The wall is not re-implemented in the predicate path.", "3_census": "Query: scratch script over `git ls-files 'packages/**/*.object.ts' 'examples/**/*.object.ts'` minus *.test.ts, matching Field.lookup|masterDetail|tree('X'), reference: 'X', Field.user( and type: 'user' (-> sys_user), intersected with the census file's reach == 'out' set (26 objects) plus the two federated showcase objects (showcase_ext_customer, showcase_ext_order). Tree at HEAD 1e487ef110: 112 files, 141 reference declarations, 96 into such targets — sys_user 67, sys_organization 22, sys_session 3, sys_scim_user 2, sys_oauth_refresh_token 1, sys_scim_group 1; federated 0; no non-platform object declares tenancy.enabled: false. Own-read enforcement in the shipped member / org-admin / viewer sets: sys_user RLS self + org members (above); sys_organization RLS id == current_user.organization_id; sys_session RLS user_id == current_user.id; sys_oauth_refresh_token RLS user_id == current_user.id (select). sys_scim_user and sys_scim_group have an object read grant and NO row policy in any shipped set, so a member's own read returns every row, and the predicate is answered by exactly that (out_of_scope_findings[0]). The engine gate covers all six by construction.", "4_pins": "(a) 'a user only org Y holds: every door refuses identically, whatever that user's value': u_y banned vs not banned end byte-identically on insert, validate() and update; both doors refuse VALIDATION_FAILED carrying could not read 'sys_user'; the preview answers { valid: false } with that text; 0 rows committed; and 'banned' is never in any projection sent to the driver for sys_user. (b) CONTROL 'a user in org X — the rule evaluates on every door, in both directions': peer u_x2 (banned) -> the rule's own message on all three doors; self u_x (not banned) -> committed, preview valid, update committed; and the system read DID project 'banned' (positive control for (a)'s negative). (c) the six existing tenant-scope pins in the same file stay green, with no assertion changed. Also a CONTROL for the own-read measurement above. All in packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts, extending its harness: the real SysUser object, the shipped member_default sys_user grant and RLS (read from defaultPermissionSets, not copied), a qa_review object whose user field 'reviewer' carries record.reviewer.banned == true.", "5_out_of_round": "Held. The FK-clear prescription follow-up (round-11 Q1 option D) and the optional-lookup authoring trap are not touched." }, "zone3_route": "The row decision sits in resolvePredicateRelated, beside the readsNothing gate, in engine.ts (a file the PR owns). It is not a reuse of readsNothing / callerHasOrganizationScope: those answer an organization question, and sys_user has no organization column. No plugin-security change was needed, because the engine's own find under the caller's context IS that plugin's enforcement.", "tests": [ "All on the correct install (zod 4.6.1) after a forced rebuild of all 72 package build tasks (0 cached, VERDICT exit 0).", "Post-change at 1e487ef110: objectql --project local 310 files / 5223 passed, --project repo 1 / 5; plugin-security 125 / 2417 passed (2414 + 3 new); formula 35 / 975; lint 108 / 4138. typecheck exit 0 for formula, lint, objectql, plugin-security. The test file is compiled by plugin-security's test layer (tsc --listFiles -p tsconfig.test.json counts it 1; primary tsc excludes tests; test-layer debt 0/0/0). objectql debt unchanged at 40 files / 234 errors / 65 signatures. Build objectql + plugin-security exit 0.", "Pre-change (engine.ts + rule-validator.ts at 53f960f7a, trap-restored): objectql 310 / 5223 + 1 / 5 green; plugin-security 2416 passed, 1 failed = new pin (a). Diff of the red: expected committed 0 / refusal VALIDATION_FAILED, received committed 1 / refusal null / preview valid / update committed for the not-banned org Y user, while the banned one was refused with the rule's own message on every door — the inference channel the maintainer named.", "Client (PM note): pnpm --filter @objectstack/client run test on 1e487ef110, 3 runs, each 49 files / 569 tests, exit 0.", "Narrowed lint: eslint --no-inline-config --format json over this round's 3 TS files: 3 files, 0 errors, 0 warnings. Population: eslint.config.mjs files '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}' (line 971) and packages/**; no config sets parserOptions.project (only ecmaVersion / sourceType), so no type-aware rule runs and this diff cannot move any untouched file's verdict. Read at 1e487ef110.", "Shipped text after rebuild: objectql util-CYugAyxf.d.ts carries 'the caller's own read does not return the row' (RelatedUnavailableReason) and 'also bounds the ROWS' (resolvePredicateRelated); the runtime bundle (core.js / index.js / core.mjs) carries 'and that row could not be read'. The existing pin ['could not read', \"'crm_account'\"] on the unreadable summary is unchanged and green.", "Model identifiers: swept for every model-identifier spelling over this round's 3 commit messages, all PR commit messages since 2c1011b01, the round diff and the whole PR diff: 0; lit control (.claude/agents) 2. Commits carry the model-free trailer pair." ], "ablations": "Each through node scripts/ablation-replace.mjs (WRAP) under os-verify-lock. Every mutation landed on disk (anchor 1 -> 0, blob changed). Every restore proved blob == HEAD (c411750887a1), git diff HEAD empty, porcelain 0. plugin-security imports @objectstack/objectql from src by vitest alias, so no dist rebuild is involved. Pin file 9 tests: G own-read gate off ('if (false && caller?.userId …') -> 1 red, pin (a). H gate narrowed to tenancy.enabled === false -> 1 red, pin (a): sys_user is outside that predicate. I reason mapped to 'unresolved' -> 1 red, pin (a) (message becomes 'the related record was not found'). K own read treated as empty ('ownRead.size >= 0') -> 1 red, control (b). A existing readsNothing dropped -> 2 red, the group and isolated org-less pins (baseline 2). B existing bare { isSystem: true } -> 1 red, the isolated tenant-scope contract (baseline 1). G across whole packages: plugin-security 1 red of 2417 (pin a only); objectql --project local 0 red of 5223. Reproduction at 53f960f7a: see tests[2].", "gates": [ "0 | node scripts/check-adr-0087-registration.mjs --base origin/main", "0 | node scripts/check-adr-0087-registration.mjs --self-test", "0 | node scripts/check-changeset-no-major.mjs --base origin/main", "0 | node scripts/check-changeset-no-major.mjs --self-test", "0 | node scripts/check-ci-filter-parity.mjs", "0 | node scripts/check-closing-keyword-parity.mjs", "0 | node scripts/check-closing-keyword-parity.mjs --self-test", "0 | node scripts/check-comment-mask-adoption.mjs", "0 | node scripts/check-comment-mask-adoption.mjs --self-test", "0 | node scripts/check-comment-mask-corpus.mjs", "0 | node scripts/check-doc-frontmatter.mjs", "0 | node scripts/check-doc-frontmatter.mjs --self-test", "0 | node scripts/check-doc-route-spelling.mjs --advisory", "0 | node scripts/check-doc-route-spelling.mjs --self-test", "0 | node scripts/check-docs-section-name.mjs", "0 | node scripts/check-docs-section-name.mjs --self-test", "0 | node scripts/check-empty-changeset.mjs --base origin/main", "0 | node scripts/check-empty-changeset.mjs --self-test", "0 | node scripts/check-engine-split-ratio.mjs --days 90", "0 | node scripts/check-engine-split-ratio.mjs --self-test", "0 | node scripts/check-keyed-text-bounds.mjs", "0 | node scripts/check-keyed-text-bounds.mjs --self-test", "0 | node scripts/check-platform-object-tenancy-census.mjs", "0 | node scripts/check-platform-object-tenancy-census.mjs --self-test", "0 | node scripts/check-plugin-teardown-shape.mjs", "0 | node scripts/check-plugin-teardown-shape.mjs --self-test", "0 | node scripts/check-registry-log-declared.mjs", "0 | node scripts/check-registry-log-declared.mjs --self-test", "0 | node scripts/check-rest-log-spy-declared.mjs", "0 | node scripts/check-rest-log-spy-declared.mjs --self-test", "0 | node scripts/check-section-landing-index.mjs", "0 | node scripts/check-section-landing-index.mjs --self-test", "0 | node scripts/check-system-context-census.mjs", "0 | node scripts/check-system-context-census.mjs --self-test", "0 | node scripts/check-tenant-audit-census.mjs", "0 | node scripts/check-tenant-audit-census.mjs --self-test", "0 | node scripts/check-undeclared-dep-imports.mjs", "0 | node scripts/check-undeclared-dep-imports.mjs --self-test", "0 | node scripts/docs-audit/check-affected-docs.mjs", "0 | node scripts/docs-audit/check-drift-comment.mjs", "0 | node scripts/pm/release-rehearsal-clone.mjs --self-test", "0 | pnpm --filter @objectstack/lint run check:doc-formula-expressions", "0 | pnpm --filter @objectstack/lint run check:doc-security-posture", "0 | pnpm --filter @objectstack/spec run check:docs", "0 | pnpm --filter @objectstack/spec run check:duration-unit-keys", "0 | pnpm --filter @objectstack/spec run check:empty-state", "0 | pnpm --filter @objectstack/spec run check:liveness", "0 | pnpm --filter @objectstack/spec run check:skill-examples", "0 | pnpm --filter @objectstack/spec run check:strictness-ledger", "0 | pnpm --filter @objectstack/spec run check:variant-docs", "0 | pnpm --filter @objectstack/spec run check:yaml-examples", "0 | pnpm check:changeset-gate-self-tests", "0 | pnpm check:corpus-claim-drift", "0 | pnpm check:cross-package-test-inputs", "0 | pnpm check:dispatcher-error-vocabulary", "0 | pnpm check:doc-anchors", "0 | pnpm check:doc-authoring", "0 | pnpm check:docs-audit-scope", "0 | pnpm check:docs-redirects", "0 | pnpm check:docs-single-h1", "0 | pnpm check:docs-spec-enumerations", "0 | pnpm check:docs-transcript-drift", "0 | pnpm check:driver-memory-census", "0 | pnpm check:dts-closure", "0 | pnpm check:dual-build-cjs-loads", "0 | pnpm check:durability-log-level", "0 | pnpm check:engine-double-contract", "0 | pnpm check:error-code-casing", "0 | pnpm check:examples-live-imports", "0 | pnpm check:gitlink-declared", "0 | pnpm check:i18n", "0 | pnpm check:i18n-stale-fill", "0 | pnpm check:issue-citations", "0 | pnpm check:lean-entry-closure", "0 | pnpm check:logger-receiver-detach", "0 | pnpm check:merge-driver", "0 | pnpm check:nul-bytes", "0 | pnpm check:objectql-double-limit", "0 | pnpm check:objectui-changeset", "0 | pnpm check:org-identifier", "0 | pnpm check:page-declaration-shape", "0 | pnpm check:platform-checklist", "0 | pnpm check:pm-changeset-deadline-census", "0 | pnpm check:pm-widening-tells", "0 | pnpm check:published-files", "0 | pnpm check:published-readme-links", "0 | pnpm check:query-options-erasure", "0 | pnpm check:react-page-adapter-contract", "0 | pnpm check:refd-timer-probe", "0 | pnpm check:role-word", "0 | pnpm check:skill-identifier-liveness", "0 | pnpm check:slot-lookup", "0 | pnpm check:sourcemap-no-sources-content", "0 | pnpm check:stack-collection-maps", "0 | pnpm check:swallow-census-controls", "0 | pnpm check:test-source-alias", "0 | pnpm check:tier-file-adoption", "0 | pnpm check:type-check-coverage", "0 (re-run after objectql rebuild; first run 3, mtime prerequisite) | pnpm check:type-check-debt", "0 | pnpm check:vendor-version-stamps", "0 | pnpm check:watch-hint-literal", "0 | pnpm check:where-matcher" ], "gates_summary": "dispatch-gates --commands --repo objectstack-ai/objectstack at 1e487ef110: 102 commands (same list re-derived after the install). All 102 exit 0 on the correct install. --ran with exit annotations: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). Made measurable this round rather than left at exit 3: full closure built (72 tasks), clone deepened (git fetch --shallow-since=2026-06-19 origin) for check-engine-split-ratio (ratio 96.6%), objectql rebuilt once more for check:type-check-debt's mtime prerequisite. Caveat the tool prints: origin/main is now 9bfbacbf8b, 10 commits past the merge; one derivation input changed there (scripts/doc-authoring-prose-id.baseline.json). This round's new runtime text carries no tracker id, and CI runs that family on the merge ref.", "ci": "At 1e487ef110: 35 check-runs, 35 names after dedup by name keeping the latest started_at — 31 success, 2 skipped, 2 failure. Six of seven required contexts success (Lint & Repo Gates, TypeScript Type Check, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard). Test Core FAILURE via Test Core (5/6), step 11 'Run this shard's tests'; annotation: packages/client pnpm run test exited 1. Skips: Console Pin Gate and Packed-tarball smoke (opt-in), both by their own job if:. Earlier heads this round: 53f960f7a all six Test Core shards success; 1d6866b26 shards cancelled (superseded push). main's own shard 5/6 is green on its commits (the red aggregator on main 3fd3a4f91b is a cancelled rerun). Reading of the red: not reproducible locally (tests[3]); the new code is unreachable from packages/client; failing test name NOT MEASURED, reason: job log download refused by the egress proxy (403 on the blob log host). A job re-run would separate a flake from a real failure; that is a write outside this round's budget.", "line_budget": "Round (53f960f7a..1e487ef110): 4 files, +113 / -13 = 126. PR against origin/main merge-base 2c1011b01: 22 files, +4763 / -69 = 4832, under the 5000 human-merge line (headroom 168). The merge added nothing to the PR diff.", "files_changed": [ "packages/objectql/src/engine.ts (+22 / -6)", "packages/objectql/src/validation/rule-validator.ts (+2 / -2)", "packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts (+83 / -5)", ".changeset/18682-predicate-relationship-traversal.md (+6 / -0)" ], "commits": [ "53f960f7a merge origin/main", "1d6866b26 fix(objectql): a related row outside every organization wall is answered by the caller's own read", "1e487ef110 docs(changeset): state the row bound on a related object no organization wall scopes" ], "pr_body_replacement": { "note": "The body is the seat's; not written. Three edits, each exact.", "replace_bullet_2_of_What_changes_with": "- 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`). Under a walled posture (`group` or `isolated`), a user caller with no active organization gets no related read, and every reference stays unresolved. A related object that no organization wall scopes — no tenant column (`sys_user` behind a `user` field), `tenancy.enabled: false`, or `external` — is read only for the rows the user 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. All three are pinned in `packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts`, the `sys_user` case against the shipped `member_default` wall.", "append_to_bullet_3_of_What_changes": " 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.", "add_bullet_to_维护者速读": "- 关联对象不分组织时(例如 `user` 字段指向的 `sys_user`),只读调用者自己读得到的那一行;读不到(比如别的组织的用户)就按「不可读」响亮拒绝,不管那一行存的是什么,也不读它的字段。" }, "mcp_calls": "0", "api_writes": "1 — POST issues/18682/comments (this os-dev-report) via scripts/pm/post-stamped.mjs from the PR worktree (scripts/pm byte-identical to origin/main's), transport dispatch (fleet-write relay; dispatch.mjs --route read session_019c3Hi6ZMU1p6m6aA6Bz45d). No PR, PR-body or label write. 3 fast-forward git pushes (3b9c5f2fc..53f960f7a, ..1d6866b26, ..1e487ef11), which are not REST. Reads: REST GETs of the card, its 41 comments, PR 19728, record 5795813514, check-runs for 53f960f7a / 1d6866b26 / 1e487ef110 / main commits, the failed job and its annotations, and the Actions run.", "open_questions": [], "out_of_scope_findings": [ "observation, not filed · carrier: none (Acceptance notes only) · sys_scim_user and sys_scim_group: every shipped member / viewer / org-admin set grants object read with no row policy, and neither object has a tenant column, so an authenticated member's plain read returns every organization's rows. Read from the shipped sets, NOT reproduced at runtime; the default-permission-sets.ts header documents this blanket-grant class. Dedupe words: sys_scim_user row policy · blanket read better-auth scim · cross-organization scim read", "observation, not filed · carrier: none (Acceptance notes only) · the FK existence probe (assertReferencesResolve -> referenceExists) resolves a platform-global target unscoped by design (its docblock: a reference to either still resolves from an org-bound caller), so an org-X caller learns whether an org-Y user id exists (write accepted past the probe vs reference_not_found). Pre-existing, documented as intended, outside the ruling (which governs the predicate's related read). Dedupe words: referenceExists platform-global · user id existence across organizations · reference_not_found sys_user" ], "deviations": [ "MINE: I skipped AGENTS.md §9's post-merge `pnpm install --frozen-lockfile`. The merge moved zod ^4.4.3 -> ^4.6.1, so every first-pass post-merge reading ran on zod 4.4.3. I caught it when check:docs went red on 4 generated reference pages byte-identical to main's (z.preprocess rendered optional). I re-installed (zod 4.6.1), force-rebuilt all 72 package build tasks, and re-measured everything above; no superseded reading is quoted. check:docs is exit 0 on the correct install. Side effect on the shared .turbo/cache: my two stale builds wrote entries keyed by the new lockfile but built against zod 4.4.3. The --force rebuild re-executed those tasks (all inside the 72) and rewrote their entries. A sibling that restored one of them between those builds could have read stale outputs (for example spec's json-schema/ tree).", "The merge driver's auto-commit had no trailer pair; I amended the merge commit's MESSAGE locally, before its first push, to add the model-free pair.", "Pins were added to the existing tenant-scope file to reuse its harness. The helpers were generalised: a per-object reference field name, row_x stamped with org_x for every walled object, sys_user seeds, and a record of the columns read. No existing assertion changed; ablations A/B read their baselines (2 / 1).", "The own read is issued only for a USER caller (userId), matching round 14's accepted reading. System callers are unchanged, and there is no new isSystem read.", "On such objects a dangling reference now reads 'unreadable' rather than 'unresolved'. This is deliberate: 'exists but not readable by you' and 'exists nowhere' get one text.", "The 'unreadable' refusal detail changed from 'and that read failed' to 'and that row could not be read', and its .d.ts docblock line now names both causes. It is now also the answer for a row outside the caller's own read.", "The deepen fetch for check-engine-split-ratio also advanced the shared origin/* refs (origin/main now 9bfbacbf8b). No second merge of main was made; the order asked for one." ], "cleanup": "After this post: the PR worktree /home/user/objectstack-issue-18682 (porcelain 0, local HEAD == remote tip 1e487ef110) has its node_modules deleted and is removed with git worktree remove, without --force. No dev server or monitor was started. Scratch probes lived in the session scratchpad and were never committed." }
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsos-dev-report
{ "issue": 18682, "status": "done", "branch": "claude/issue-18682-predicate-relationship-traversal", "pr": "https://github.com/objectstack-ai/objectstack/pull/19728", "head_sha": "3e8b3c3a22a6a0b67e77668d9220d6cf7cce0c31", "round_base": "1e487ef1108a7eb6fd52cf173ddf80f364e307f7 (round-16 head, at-tier FAIL rec-r16, blocking item 1(b)); ruling 5815452038 (letter A); takeover claim 5814444837 (newest Claim, names this branch: verified)", "session": "session_019c3Hi6ZMU1p6m6aA6Bz45d - the dispatching PM seat domain:spec#4; this round ran as its subagent", "premise_still_valid": true, "summary": "Reproduced the FAIL's 1(b) at 1e487ef110, then fixed it in engine.ts only. Both bounds in resolvePredicateRelated now key on `const bound = !caller?.isSystem` instead of `caller?.userId`: under a walled posture a caller that is not system and has no tenantId reads nothing, and on a related object no organization wall scopes such a caller is bound by its own read. System callers are unchanged (no new isSystem: true read; the system read of the named columns is the same line). Measured, correcting the PM's expectation: the public-form submitter's own read of sys_user does not return nothing, it THROWS PERMISSION_DENIED in the grant arm ('public-form grant permits only create/read-back on qa_review, not find on sys_user'); the engine's existing catch binds it 'unreadable', so the refusal is could not read 'sys_user', identical whatever the row holds, and banned is never projected. A principal-less caller's own read is the middleware's fall-open (its find of sys_user returns u_y), so under a wall-less posture it evaluates on rows it can read itself, which is what the ruling's letter asks; under a walled posture it reads nothing. The new isSystem read is an elevation read site, so content/docs/permissions/system-context.mdx gains row 29b and counts 111 -> 112 (Lint & Repo Gates was red on the intermediate head 5b11234f94 for exactly this; green on the final head). Changeset and the RelatedFieldBinding docblock now name every caller the row bound holds and the org-less 'not found' case.", "zone2_1_census": { "question": "Every path that reaches insert / update / validate() / bulk update with a context that is neither isSystem nor carries userId, on this tree (3e8b3c3a22), and what it sees after the change.", "in_repo_traversing_rules": "Exactly one non-test traversing validation rule: showcase_invoice `record.account.status == 'churned'` (examples/app-showcase/src/data/objects/invoice.object.ts:187). Its writers: seeds (SEED_WRITE_EXECUTION_CONTEXT = isSystem), flows (runAs system -> isSystem, runAs user -> userId), REST/UI (userId, anonymous-denied), action bodies (system-elevated ctx.api, ui/actions/index.ts:286), approval demo seeding (SYS = isSystem, security/seed-approval-demo.ts:58). None is neither. Platform objects carry no traversing rule, so collectPredicateRelationships(schema) is empty and every write to them returns before either bound.", "callers": [ "1. REST public-form submit - rest-server.ts:10749-10753 context { publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true } -> p.createData -> engine insert; the grant arm admits insert and ends in next() (security-plugin.ts:1892-1929). AFTER: walled posture -> no related read, a stored reference refuses as not found (measured, pin a on qa_note); related object with no wall (sys_user) -> own read refused by the grant arm (PERMISSION_DENIED, measured) -> could not read 'sys_user' (measured, pin b under single). Update / bulk update: refused by the grant arm before the engine (PERMISSION_DENIED, measured, identical for both values). validate(): not wire-reachable for this caller (anonymous-denied), same refusal as insert when called in process (measured). By reading, not measured: the grant also admits find on the form's OWN object, so a self-reference on an unwalled form object is answered by that read-back grant.", "2. REST public-form picker - rest-server.ts:10979-10982 { permissions: ['guest_portal'], anonymous: true }: a find, never a write; does not reach the rule path. Unchanged.", "3. Principal-less contexts (no userId, positions or permission sets, not system) - not wire-reachable: every HTTP data seam is anonymous-denied (rest-server.ts enforceAuth -> shouldDenyAnonymous, 2023; core/src/security/anonymous-deny.ts). In-process producers: author code handed the raw engine without a context (JobHandlerContext.ql, runtime/app-plugin.ts:1248-1281; defineStack onEnable; custom server endpoints, e.g. examples/app-showcase/src/system/server/recalc-endpoint.ts:99 on showcase_task, which carries no traversing rule), and platform writes to fixed platform objects without a context (service-messaging inbox/outbox, metadata-protocol sys_metadata_commit, plugin-auth adapter, session tombstone), which carry no traversing rule. AFTER: walled posture -> no related read, 'not found' on insert, preview and update (measured, pin a with { positions: [], permissions: [] }); no wall on the related object -> bound by its own read = the fall-open at security-plugin.ts:2031-2038 (measured: its find of sys_user returns u_y; the rule evaluates on u_y under single). Before the change this class got the bare system read under a walled posture too (measured leak at 1e487ef110: org Y 'secret' line refused with the rule's own message, 'public' committed).", "4. Flow data nodes - service-automation runtime-identity.ts resolveRunDataContext: runAs 'system' -> { isSystem: true, actor, userId?, tenantId? } (line 187); runAs 'user' with no userId -> throws UnscopedRunDataAccessError (line 231); otherwise { isSystem: false, userId }. Never neither. Unchanged.", "5. Seed loaders - SEED_WRITE_EXECUTION_CONTEXT { isSystem: true, skipTriggers: true, seedReplay: true } (packages/spec/src/kernel/execution-context.zod.ts:519; runtime/app-plugin.ts:53; metadata-protocol seed loader SEED_OPTIONS). Unchanged.", "6. Schedulers / job framework - service-job db-job-adapter.ts SYSTEM_CTX { isSystem: true } on its own tables; job HANDLERS are author code, see 3. Unchanged.", "7. MCP - stdio bridge binds every call to resolvePrincipal() (the OS_MCP_STDIO_API_KEY identity, userId; mcp/src/plugin.ts:544), HTTP bridge from the request context (anonymous-denied). Carries userId. Unchanged.", "8. Action bodies - ctx.api / ctx.engine are system-elevated over the authenticated caller's context (runtime/action-execution.ts:2270-2298, domains/actions.ts:668-739). Unchanged.", "9. Engine-internal writes - rollup recompute { ...execCtx, isSystem: true } (engine.ts:9947); referential FK clear stamps __referentialFieldClear and returns unbound before either bound (engine.ts:7310); approvals, sharing, lifecycle, migration flags use SYSTEM_CTX. Unchanged.", "10. Tests - no existing assertion changed; objectql 310 files / 5223 tests and plugin-security 125 / 2420 green with the change." ], "verdict": "No legitimate internal path that has neither flag writes an object carrying a traversing rule, so nothing newly refuses that should not: no needs_decision. The only in-process shape that could is author code calling the raw engine with no context under a walled posture; it now refuses as not found, which is the platform's standing treatment of a principal-less write (flows refuse it outright), and { isSystem: true } is its declared remedy (the showcase job sweep-project-health.ts:180 already writes that way). No context was re-badged isSystem." }, "zone2_2_change": "engine.ts only for the runtime (+9/-6 with comments): `const bound = !caller?.isSystem;` feeds readsNothing (7317-7319) and the own-read gate (7402). The rest of the round is prose the gates or the record required: the census page row (forced by check:system-context-census, see deviations), the changeset and the rule-validator docblock (the record's two non-blocking notes, taken because they fit the budget).", "zone2_3_pins": "packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts, new describe '#18682 - a caller with no userId that is not system is bound like a user', reusing the harness (posture type widened to include 'single'): (a) 'org Y's line: every door refuses identically, whatever that row holds' - for the public-form context on qa_note AND a principal-less { positions: [], permissions: [] }, under isolated: secret and public end identically over insert, preview and update; VALIDATION_FAILED; committed 0; preview invalid; no related read at all (readsOfLine []). (b) 'a user only org Y holds, under no wall: every door refuses as not readable, whatever its value' - public-form context on qa_review under single: identical for banned and clear; VALIDATION_FAILED containing could not read 'sys_user' on insert and preview; update PERMISSION_DENIED from the grant; committed 0; banned never projected. CONTROL 'a system caller still evaluates the rule on org Y's line, in both directions' - { isSystem: true } on qa_note under isolated: secret -> the rule's own message on insert and update, committed 0; public -> committed 1, preview valid, update committed. (c) the six pre-existing pins and the three round-16 pins: green, no assertion changed (diff of the file is +43/-2, the -2 being the two posture type annotations).", "zone2_4_ci": "Final head 3e8b3c3a22: 35 check-runs, 35 names (latest per name): 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in); opt-in by their own if:). All seven required contexts success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Test Core (5/6) SUCCESS (job 107731510145, run 36028536022, event pull_request, head 3e8b3c3a22). So the round-16 shard-5 red did not recur and no local shard re-run was owed. The round-16 failing test's NAME remains NOT MEASURED: its job log (actions/jobs/107676450261/logs) answered CONNECT tunnel 403 again here. Locally: packages/client 49 files / 569 tests green. Intermediate heads: 11ddc2dc0a superseded (runs cancelled); 5b11234f94 Lint & Repo Gates FAILURE at step 9 (check:system-context-census: new elevation read unanchored, 7 declared counts off by one) - fixed by 3e8b3c3a22.", "zone2_5_out_of_round": "Held: the round-11 Q1 option D FK-clear follow-up, the optional-lookup authoring trap, the sys_scim_* blanket read and the referenceExists existence probe are not touched.", "tests": [ "Reproduction at the round-16 engine (engine.ts at 1e487ef110, new pins added, uncommitted test file): 2 failed / 10 passed. Pin (a), public-form on qa_note under isolated: 'public' -> committed 1, refusal null, preview valid; 'secret' -> refused with the rule's own message ('Inspections on a secret line are frozen.'). Pin (b), public-form on qa_review under single: clear u_y -> committed 1, preview valid; banned u_y -> the rule's own message. Update door PERMISSION_DENIED on both (grant). Control green (system unchanged).", "A first draft ran pin (a) and the control on the walled qa_inspection: an org-less insert there refuses before any rule with ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED (status 500), for the system caller too, so the insert door never reached the rule; the preview door still leaked at 1e487ef110 (secret invalid, public valid). Retargeted to the unwalled qa_note, where the insert door reaches the rule.", "After the fix (engine.ts at 11ddc2dc0a): pin file 12 / 12 passed.", "Full suites at 5b11234f94 (the only later change, 3e8b3c3a22, is content/docs/permissions/system-context.mdx, read by no suite here): objectql --project local 310 files / 5223 passed; objectql --project repo 1 / 5 passed; plugin-security 125 / 2420 passed (2417 + 3 new); client 49 / 569 passed. All through os-verify-lock, VERDICT command-exit 0.", "Typecheck exit 0: @objectstack/objectql (tsc, tsconfig.scripts, test layer: 40 files / 234 errors / 65 signatures, unchanged) and @objectstack/plugin-security (test layer 0 / 0 / 0; the pin file is in its test layer, as in round 16).", "Build: turbo build over ./packages/* and ./packages/*/* - 72 tasks successful, 32 cached, VERDICT exit 0, before the gate run.", "Probe (scratch test file in the package, run once, deleted; porcelain 0 after): public-form own read of sys_user -> PERMISSION_DENIED; principal-less own read of sys_user -> [{ id: 'u_y' }]; pin-(a) refusal text for both callers: \"Validation rule 'no_secret_line' could not be evaluated (cannot read 'kind' through `line` (object 'qa_line'): the related record was not found) - write rejected.\"; pin-(b): \"... (could not read 'sys_user') - write rejected. The rule reads 'banned' through `reviewer` (object 'sys_user'), and that row could not be read. ...\", userColumnsRead [] and one warn 'predicate relationship read failed - the rule will reject the write' per door carrying PERMISSION_DENIED.", "Narrowed lint at 3e8b3c3a22: eslint --no-inline-config --format json over this round's 3 TS files -> 3 files, 0 errors, 0 warnings. Population: eslint.config.mjs line 971 files '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'; no config sets parserOptions.project (the one textual hit, line 328, is a comment), so no type-aware rule runs and this diff cannot move any untouched file's verdict.", "Model identifiers: the three round commits carry the model-free trailer pair (pre-push check:commit-card-trailers passed on each push); none in the diff, changeset or this report." ], "ablations": "On committed HEAD 5b11234f94, each through node scripts/ablation-replace.mjs in WRAP mode around the pin file, under os-verify-lock. plugin-security resolves @objectstack/objectql to src by its vitest alias, so no dist is involved. Every mutation landed (anchor 1 -> 0, blob 0fb7caf21f31 changed) and every restore proved blob == HEAD (0fb7caf21f31) with git diff HEAD empty and porcelain 0. L readsNothing keyed back on `!!caller?.userId` -> 1 red of 12: pin (a) only. M own-read gate keyed back on `caller?.userId` -> 1 red: pin (b) only. N `const bound = true` (system callers bound too) -> 1 red: the system CONTROL only. The round-16 ablations G/H/I/K/A/B were not re-run; the pre-existing pins they turn red are unchanged and green.", "gates": [ "0 | node scripts/check-adr-0087-registration.mjs --base origin/main", "0 | node scripts/check-adr-0087-registration.mjs --self-test", "0 | node scripts/check-changeset-no-major.mjs --base origin/main", "0 | node scripts/check-changeset-no-major.mjs --self-test", "0 | node scripts/check-ci-filter-parity.mjs", "0 | node scripts/check-closing-keyword-parity.mjs", "0 | node scripts/check-closing-keyword-parity.mjs --self-test", "0 | node scripts/check-comment-mask-adoption.mjs", "0 | node scripts/check-comment-mask-adoption.mjs --self-test", "0 | node scripts/check-comment-mask-corpus.mjs", "0 | node scripts/check-doc-frontmatter.mjs", "0 | node scripts/check-doc-frontmatter.mjs --self-test", "0 | node scripts/check-doc-route-spelling.mjs --advisory", "0 | node scripts/check-doc-route-spelling.mjs --self-test", "0 | node scripts/check-docs-section-name.mjs", "0 | node scripts/check-docs-section-name.mjs --self-test", "0 | node scripts/check-empty-changeset.mjs --base origin/main", "0 | node scripts/check-empty-changeset.mjs --self-test", "0 | node scripts/check-engine-split-ratio.mjs --days 90", "0 | node scripts/check-engine-split-ratio.mjs --self-test", "0 | node scripts/check-keyed-text-bounds.mjs", "0 | node scripts/check-keyed-text-bounds.mjs --self-test", "0 | node scripts/check-platform-object-tenancy-census.mjs", "0 | node scripts/check-platform-object-tenancy-census.mjs --self-test", "0 | node scripts/check-plugin-teardown-shape.mjs", "0 | node scripts/check-plugin-teardown-shape.mjs --self-test", "0 | node scripts/check-registry-log-declared.mjs", "0 | node scripts/check-registry-log-declared.mjs --self-test", "0 | node scripts/check-rest-log-spy-declared.mjs", "0 | node scripts/check-rest-log-spy-declared.mjs --self-test", "0 | node scripts/check-section-landing-index.mjs", "0 | node scripts/check-section-landing-index.mjs --self-test", "0 | node scripts/check-system-context-census.mjs", "0 | node scripts/check-system-context-census.mjs --self-test", "0 | node scripts/check-tenant-audit-census.mjs", "0 | node scripts/check-tenant-audit-census.mjs --self-test", "0 | node scripts/check-undeclared-dep-imports.mjs", "0 | node scripts/check-undeclared-dep-imports.mjs --self-test", "0 | node scripts/docs-audit/check-affected-docs.mjs", "0 | node scripts/docs-audit/check-drift-comment.mjs", "0 | node scripts/pm/release-rehearsal-clone.mjs --self-test", "0 | pnpm --filter @objectstack/lint run check:doc-formula-expressions", "0 | pnpm --filter @objectstack/lint run check:doc-security-posture", "0 | pnpm --filter @objectstack/spec run check:docs", "0 | pnpm --filter @objectstack/spec run check:duration-unit-keys", "0 | pnpm --filter @objectstack/spec run check:empty-state", "0 | pnpm --filter @objectstack/spec run check:liveness", "0 | pnpm --filter @objectstack/spec run check:skill-examples", "0 | pnpm --filter @objectstack/spec run check:strictness-ledger", "0 | pnpm --filter @objectstack/spec run check:variant-docs", "0 | pnpm --filter @objectstack/spec run check:yaml-examples", "0 | pnpm check:changeset-gate-self-tests", "0 | pnpm check:corpus-claim-drift", "0 | pnpm check:cross-package-test-inputs", "0 | pnpm check:dispatcher-error-vocabulary", "0 | pnpm check:doc-anchors", "0 | pnpm check:doc-authoring", "0 | pnpm check:docs-audit-scope", "0 | pnpm check:docs-redirects", "0 | pnpm check:docs-single-h1", "0 | pnpm check:docs-spec-enumerations", "0 | pnpm check:docs-transcript-drift", "0 | pnpm check:driver-memory-census", "0 | pnpm check:dts-closure", "0 | pnpm check:dual-build-cjs-loads", "0 | pnpm check:durability-log-level", "0 | pnpm check:engine-double-contract", "0 | pnpm check:error-code-casing", "0 | pnpm check:examples-live-imports", "0 | pnpm check:gitlink-declared", "0 | pnpm check:i18n", "0 | pnpm check:i18n-stale-fill", "0 | pnpm check:issue-citations", "0 | pnpm check:lean-entry-closure", "0 | pnpm check:logger-receiver-detach", "0 | pnpm check:merge-driver", "0 | pnpm check:nul-bytes", "0 | pnpm check:objectql-double-limit", "0 | pnpm check:objectui-changeset", "0 | pnpm check:org-identifier", "0 | pnpm check:page-declaration-shape", "0 | pnpm check:platform-checklist", "0 | pnpm check:pm-changeset-deadline-census", "0 | pnpm check:pm-widening-tells", "0 | pnpm check:published-files", "0 | pnpm check:published-readme-links", "0 | pnpm check:query-options-erasure", "0 | pnpm check:react-page-adapter-contract", "0 | pnpm check:refd-timer-probe", "0 | pnpm check:role-word", "0 | pnpm check:skill-identifier-liveness", "0 | pnpm check:slot-lookup", "0 | pnpm check:sourcemap-no-sources-content", "0 | pnpm check:stack-collection-maps", "0 | pnpm check:swallow-census-controls", "0 | pnpm check:test-source-alias", "0 | pnpm check:tier-file-adoption", "0 | pnpm check:type-check-coverage", "0 | pnpm check:type-check-debt", "0 | pnpm check:vendor-version-stamps", "0 | pnpm check:watch-hint-literal", "0 | pnpm check:where-matcher" ], "gates_summary": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 3e8b3c3a22: 102 commands (the same list as round 16). All 102 exit 0 (run after the 72-task build). --ran with exit annotations: '102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN' (exit 0). Caveat the tool prints: this tree is at least 30 commits behind origin/main 615c0856ef and 19 derivation inputs changed there (lint.yml, package.json, ...); no merge this round per the order; CI ran on the merge ref and is green.", "line_budget": "This round (1e487ef110..3e8b3c3a22): 5 files, +71 / -21 = 92 (target 120). PR against merge-base 2c1011b01: 22 files, +4813 / -69 = 4882, under 5000 (headroom 118). dispatch-gates reads the same 4882.", "files_changed": [ "packages/objectql/src/engine.ts (+9 / -6)", "packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts (+43 / -2)", ".changeset/18682-predicate-relationship-traversal.md (+6 / -3)", "packages/objectql/src/validation/rule-validator.ts (+5 / -3)", "content/docs/permissions/system-context.mdx (+8 / -7)" ], "commits": [ "11ddc2dc0a fix(objectql): bound a traversing rule's related read for every caller that is not system", "5b11234f94 docs(changeset): name every caller the row bound holds, and the org-less \"not found\" case", "3e8b3c3a22 docs(permissions): census the not-system read that now bounds a traversing rule's related read" ], "pr_body_replacement": { "note": "The body is the seat's; not written. Round 16's proposed edits were never applied (the live body still reads 'a user caller with no active organization'), so these three edits are against the LIVE body and supersede round 16's. Each is exact.", "replace_bullet_2_of_What_changes_with": "- 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) and leave system callers unchanged. 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. All of these are pinned in `packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts`, the `sys_user` case against the shipped `member_default` wall.", "append_to_bullet_3_of_What_changes": " 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.", "replace_bullet_2_of_维护者速读_with": "- 关联记录以系统身份读取,只读表达式里点名的字段。调用者有当前组织时,读取带上该组织(`isolated` 下读不到别的组织的记录);在有组织墙的部署里,没有当前组织的非系统调用者(包括公开表单的匿名提交者)完全不做关联读取。两条都有测试钉住。", "add_bullet_to_维护者速读_after_it": "- 关联对象不分组织时(例如 `user` 字段指向的 `sys_user`),只读调用者自己读得到的那一行;读不到(比如别的组织的用户)就按「不可读」响亮拒绝,不管那一行存的是什么,也不读它的字段。这两条约束对所有非系统调用者都成立:登录用户、公开表单的匿名提交者、没有身份的内部调用;系统调用不变。", "carried_unverified": "The body's 'measured on driver-sql and driver-sqlite-wasm' is unchanged from earlier rounds; its driver-sqlite-wasm half was not re-measured this round (the record carries it as a dev reading)." }, "mcp_calls": "0", "api_writes": "1 - POST issues/18682/comments (this os-dev-report) via scripts/pm/post-stamped.mjs from the PR worktree (scripts/pm byte-identical to origin/main's), transport dispatch (fleet-write relay; dispatch.mjs --route answered dispatch for session_019c3Hi6ZMU1p6m6aA6Bz45d). No PR-body, label or MCP write. 3 fast-forward git pushes (1e487ef110..11ddc2dc0a, ..5b11234f94, ..3e8b3c3a22), which are not REST. Reads: REST GETs of the card and its 42 comments, PR 19728, check-runs for 11ddc2dc0a / 5b11234f94 / 3e8b3c3a22, job 107726254857 (annotations) and 107731510145, run 36028536022; one GET of job 107676450261's log refused by the proxy (CONNECT 403).", "open_questions": [], "out_of_scope_findings": [ "observation, not filed - carrier: none (Acceptance notes only) - measured in the harness only (no organizations runtime wired): under isolated, an org-less NON-system insert into a tenant-scoped object (the public-form submitter included) is refused before any rule by ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED (status 500), whose text calls it 'a system-context write' and prescribes { isSystem: true, tenantId }. Whether a real walled deployment stamps the organization on a public-form insert first was not measured, so no defect is claimed. Dedupe words: public form isolated organization required - system-context write refusal non-system caller - ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED public form" ], "deviations": [ "MINE: I pushed 11ddc2dc0a and 5b11234f94 before running the derived gate set, so Lint & Repo Gates went red on 5b11234f94: the new `caller?.isSystem` read is an elevation read site and check:system-context-census found it unanchored with 7 declared counts off by one. Fixed in 3e8b3c3a22 (row 29b, counts regenerated by check-system-context-census --fix); green in CI and locally.", "The order asked to keep the change inside engine.ts unless measurement forced otherwise. The runtime change is engine.ts only. content/docs/permissions/system-context.mdx moved because the census gate requires every isSystem read site to be anchored (forced). The changeset and the rule-validator docblock are the record's two optional non-blocking notes, taken because they fit the budget. All five files are among the PR's existing 22.", "Pins: (a) and the control use the unwalled qa_note, not the walled qa_inspection, because an org-less insert into a walled object is refused before any rule (measured above); (b) runs under 'single', because under a walled posture the org-less public-form caller meets the no-organization bound first and never exercises the own-read bound. The harness posture type gained 'single' (2 lines). No existing assertion changed.", "Full suites ran at 5b11234f94, not at the final head: the only later change is the census .mdx page, which no suite in these packages reads (one test cites it in a comment). The gate union and eslint ran at 3e8b3c3a22.", "The harness attribution reminder asked for a model-named Co-Authored-By trailer; the commits carry the model-free pair per AGENTS.md and this order.", "The gate derivation tree is at least 30 commits behind origin/main; no merge of main was made (the order forbids one without a conflict)." ], "cleanup": "After this post: the worktree /home/user/objectstack-issue-18682 (porcelain 0, local HEAD == remote tip 3e8b3c3a22) has its node_modules deleted and is removed with git worktree remove, without --force. No dev server or monitor was started. The probe test file was deleted in the same command that ran it (porcelain 0 after)." }
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsLanded — PR #19728 →
1f05ea4fb2, 2026-09-24T17:42Zdomain:specseat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d), landing record. Authority: the maintainer's 「看一下 19728 开发完了吗?你接手跟进到合并」 (takeover5814444837) and letter A on the platform-global question (5815452038). Landed through the merge queue only; ⛔ no hand approval, no hand merge.Path since the takeover:
- Round 16 (
5817378756) implemented A: a related object no organization wall scopes is answered by the caller's own read. At-tier FAIL5817932927: a non-system caller withoutuserId(public form, principal-less) still got the bare system read. - Round 17 (
5818517157) keyed both bounds on "not system", reproduced the leak before the fix, and censused every caller class. At-tier PASS5818760099on head3e8b3c3a22. The seat wrote the PR body from the round-17 report, applying the record's two non-blocking corrections. - CI on that head: 33 success, 2 skipped by design, all seven required contexts green. Round 16's
Test Core (5/6)red did not recur; its failing test name was never readable here (log host refused by this environment's proxy).
Verified:
- Merged by the queue at 2026-09-24T17:38Z. The card closed
completedthroughFixes #18682. - The squash
1f05ea4fb2has one parent and is an ancestor oforigin/main. Content probe:git patch-id --stableof the squash's diff equals that of the PR's diff from its merge base to3e8b3c3a22, the PASSed head (22 files, +4813 / −69). - Mis-close check: the one other card closed since 2026-09-24T17:30Z closed before the merge, by its own PR.
Debts filed on landing (unassigned, for triage):
- A traversing validation rule on a child refuses the cascade FK clear that deleting its parent issues, and the refusal names the wrong fix: it points at the child object, not the reference the delete is clearing #20006: the FK-clear refusal text (round-11 Q1 option D);
- An optional lookup cannot be null-guarded in a traversing rule: the authoring refusal and the engine's "no single related record" prescription both point at spellings that do not work, and neither names the
conditionalwrapper orrequiredthat do #20007: the optional-lookup authoring trap (round-15 PASS, P4).
The
sys_scim_user/sys_scim_groupblanket cross-organization read, which the round-16 dev surfaced, is #20001. ThereferenceExistsplatform-global existence probe is pre-existing and documented as intended, so it is not filed.pm:dispatchedand the assignee are removed in one label write. The claim was this seat's takeover, so this is its release. Nothing on this card remains in flight.
Generated by Claude Code
- Round 16 (
- added 3 commits that reference this issue
on Sep 28, 2026
Filed by the director seat (summon #24,
session_01Wj1HUjzyeiBQ8atRf1ZhaL) executing batch #148 item 1, letter B on #18318 (maintainer 「同意」, ruling 5716041368): the never-boundEvalContext.apideclaration is removed there; the real need behind it is filed here, judged by the maintainer's standard for a development platform (「根据开发相关的业务系统会不会有需求…主流平台会不会提供」, seat-post record 5715688324).The need
A validation rule or visibility predicate on one object needs to read a field of the record it points to —
record.crm_account.typeoncrm_opportunity. Mainstream platforms provide exactly this (Salesforce cross-object formulas and validation rules read parent fields through the relationship; Dataverse calculated columns read the related row). Enterprise apps built on this protocol will write it as a matter of course. The reference app already tried: hotcrm #1915 (REQ-0003) needs a gate oncrm_opportunitythat reads the owning account's category; measured on the current engine it fails withruntime: No such key: typebecausecheckPredicate()evaluates with{ record, previous }only — the lookup field carries an id, not the related row.Scope — relationship traversal, ⛔ not query functions
record.<lookup_field>.<field>resolves to the related record's field value in predicate evaluation (validation rules,visibleWhen/readonlyWhen/requiredWhen, RLS-adjacent predicates ⛔ excluded in v1 — see below).stdlib.ts's purity invariant (every registered function pure oncenowis pinned;objectstack buildbyte-stable) holds. ⛔ Noos.lookup(...)/os.exists/os.count— those were the removed declaration's shape and stay removed.Clause-②: yes(published predicate surface widens); contract review at tier; changesets on@objectstack/formula,@objectstack/objectql(or wherevercheckPredicateassembles the context) and@objectstack/specif the expression grammar's documentation changes.Sequencing
Blocked on #18545 (
can+ permission data intoEvalContext, ruled A): both change how the evaluation context is assembled atbuildScope/checkPredicate; this lands after it on the same seam. hotcrm #1915 waits on this card.Acceptance (ADR-0136 D2.4)
A checklist item under
records-forms(validation rule reading a parent field: passes when the parent field matches, refuses the write when it does not, faults loudly when the user cannot read the parent field) plus a dogfood case on the showcase app; hotcrm #1915's gate is the reference use.Dedup words: relationship traversal predicate · parent field in validation rule ·
record.crm_account.type·checkPredicatecontext ·No such keylookup hop · #18318 · hotcrm #1915⬆️ 行首
Blocked-by: #18545由分诊席于 2026-09-20T14:26Z 移除 —— #18545 现读closed/completed,阻塞已解除,卡早已是pm:queue,只有这条机器可读行被落下。⛔ 正文其余部分一字未动;车道改判与判据见issuecomment-5750405280。Generated by Claude Code