fix(app-shell): lint conditional-formatting in the record scope, and align the predicate scope to the engine's root vocabulary - #8164
Conversation
…scope
The conditional-formatting editor authored its CEL in the `flattened` scope,
where any bare identifier is legal, and advertised `data` in its autocomplete
roots. Phase 2 of the row-predicate canon retired both spellings on runtime
record surfaces: `evalRowPredicate` binds the row as `record.*` and nothing
else, so `status == 'overdue'` and `data.status == 'overdue'` fault at runtime
while the editor linted them green.
- `CelPredicateField` authors in `scope="record"`, the scope the field
conditional rules already use, so a bare field ref is an ERROR carrying the
`record.<field>` fix.
- `ROW_PREDICATE_ROOTS` drops `'data'`.
- The docblock and the inline comment describing the old three-way binding are
rewritten to the one binding that survives.
The shared `hint.scope ?? 'flattened'` default is untouched: RLS predicates and
flow conditions are not row surfaces.
Tests: the pin that asserted "a bare field lints clean" is turned to assert the
`record.<field>` diagnostic — its own comment predicted this edit. The
roots-to-runtime pin is repaired: it looped every advertised root asserting
`size(<root>) >= 0` against a host scope that itself carried `data: {}`, so for
`data` the probe hit the host's own empty object and could not fail. Each root
is now checked against the binder that is supposed to supply it, in both
directions, and `data` and `os` get their own pins against a scope that does
carry them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
PM review — verdict on the diff, and why the PR is held rather than landedReviewed by the PM seat (session Two framings in the report I have to correct① "the exported I checked this the way I should have checked A star re-export would have put the name on the published face without the literal ever appearing in This does not un-bind Clause-②: the clause has two legs, and the accept/reject leg fires hard here — the editor's lint verdict flips for a whole class of input. ② The The report and #8155 both frame return { current_user: user, user, ctx: { user }, os: { user }, app, data, features };
Mitigating, and measured: zero in-tree uses of One gap neither the dispatch nor the report namesThe PR closes the bare-field half of the silent-death hole and leaves the From #8155's own measurement, the engine's accept set at
That is not a defect this PR introduces — it is pre-existing and unchanged — and it is not a reason to hold. But the changeset currently reads as though the editor now catches the retired spellings, and it catches one of two. The changeset's "Known gap" section should name this alongside the What was verified and stands
Held, and on whatStaying draft pending a maintainer ruling on #8155, where I have put the ship/hold question with a fourth option the report did not enumerate. My reasoning: this PR trades a silent wrong answer for a hard block with no workaround. The trade is probably right — a silent dead rule is the worse failure — but knowingly shipping a no-escape-hatch block to an unmeasurable population is a call for the maintainer, not for me. The producer-side fix is one string in one array; if it is fast-tracked, the question dissolves. ⛔ Not folded in, correctly: #7728, #7835. Generated by Claude Code |
Contract review returned PASS-WITH-FINDINGS — two blockers. Patch round in progress.Tier note: the designated contract-review tier was measured unavailable this session ( Correcting myself: in my review above I wrote "The diff is sound" and separately vouched for the changeset. The implementation logic is indeed sound and is not changing. The changeset is not — it carries a false claim, and I did not catch it. Blocker 1 — "the last place still teaching a spelling the runtime had already retired" is false
Blocker 2 — the PR leaves its own hole open while implying it is closedAt That hole turns out to be live on main today, independent of this PR, on Also going into the patch round
Confirmed clean, so nobody re-checks itThe Still held in draft, now on two counts: this patch round, and the unresolved ruling on #8155. Generated by Claude Code |
… pin the data half Contract-review follow-up on the conditional-formatting scope flip. No implementation logic changes; this is coverage and text. - The roots-to-runtime suite no longer writes the app-shell predicate bag out by hand. It calls `buildExpressionScope`, derives the advertised-root expectation from `Object.keys(...)` minus an explicit curated-exclusion list, and runs the `os` and `data` pins against that same bag. A literal cannot disagree with its producer, so it silently absorbs drift -- and it already had: the literal omitted `os`, which the producer really does bind, so the old `os` assertion "proved" it unbound. The previous `os` pin also handed `os` in by hand, which showed only that `evalRowPredicate` forwards `scope`; it now reads the producer and carries an unbound-root control. - New characterization pin: a `data.*` condition still lints CLEAN. Dropping `data` from the advertised roots stops recommending it, not accepting it -- the engine's `SCOPE_ROOTS` lists `data`, so the record-scope lint waves it through while the runtime pin one suite lower asserts it is false. Green here plus false there is the defect, and the pair is the referent. - The `app` pin's comment now says which of its card's two candidate fixes it is a tripwire for; the closure assertion covers the other. - The host-roots test no longer says "advertised host roots must survive" while sitting above a test proving one of them does not. The changeset drops the false "last place" claim, states that this closes the bare-field half of the retirement and not the `data.*` half, spells out that a saved view with a legacy condition becomes unsavable in the designer until it is rewritten, and records the bare-position autocomplete change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
`ROW_PREDICATE_ROOTS` is an `export const`, which the release note read as an API change. Measured: `packages/app-shell/src/index.ts` has 0 `export *` lines and names neither the const nor `ConditionalFormattingEditor`, and the package `exports` map is `"."` plus `./styles.css` with no deep subpath -- so nothing outside the package can import it. The behavioural narrowing is real and is what the note is about; the published surface is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Unblocked — #8155 ruled option B (director seat, decision batch #67, 2026-09-07)objectui aligns to the engine's Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract review — REWORK (director seat, summon #17,
|
Contract review (
|
| comment | seat / tier | head reviewed | verdict |
|---|---|---|---|
| 5562553734 | PM seat, default tier under a quota exemption | 35e49ace1 (commit 1) |
PASS-WITH-FINDINGS — 2 blockers + 6 items + 1 open question |
| 5571523408 | director seat, CONTRACT_REVIEW_TIER (hotlong) |
a478691a (this head) |
REWORK — 4-item patch round |
The PR body's "Contract review returned PASS-WITH-FINDINGS; the commits after the first answer it" describes 5562553734 only. 5571523408 states that the quota exemption does not apply to contract review, so 5562553734 is a seat review and the tier gate on this head is already REWORK. The head has not moved since (PR updated_at = that comment's timestamp; 0 commits after a478691a). The body's "the ship decision is parked on a maintainer ruling in #8155" is stale: the ruling landed 2026-09-07T04:15Z, nine hours before the REWORK.
Prior findings — discharged at a478691a?
From 5562553734 (PASS-WITH-FINDINGS, on 35e49ace1):
| # | finding | status | evidence at a478691a |
|---|---|---|---|
| B1 | changeset's "last place still teaching the retired spelling" is false | discharged | sentence gone; changeset :70-73 now says the opposite and points at #8167 |
| B2 | data.* still lints clean; needs an authoring pin + honest changeset |
discharged | test :179 KNOWN GAP — a data.* condition still lints CLEAN…, labelled "NOT desired behaviour"; changeset :64 "closes the bare-field half … only", tracks #8166 |
| 3 | suite models the host instead of reading it | discharged | test :15 imports buildExpressionScope; :280 fullHostScope = buildExpressionScope({...}); :292 CURATED_EXCLUSIONS = ['os','data']; :303 HOST_BOUND_ROOTS = Object.keys(fullHostScope).filter(...) |
| 4 | app.* pin overclaims (reads on option A only) |
discharged as written, now moot | :213-218 says option A only, names the closure assertion for option B — but see F1: the ruling is option B, so this pin must now flip, not annotate |
| 5 | bare-position autocomplete radius undocumented | discharged | changeset :44 "Autocomplete moves with the scope"; CelPredicateField.tsx:216-225 confirmed: scope === 'record' ? { ...scopeInfo, fields: [] } |
| 6 | migration impact undersold | discharged | changeset :13-18: blocking via onBlockingIssuesChange, "unsavable in the designer until … rewritten — including when you opened the view to change something unrelated" |
| 7 | self-contradicting host-roots test title | discharged | test now titled "host roots the ENGINE KNOWS", covers features/current_user/user/ctx |
| OQ | does introspectScope move member completion? |
answered in body; not independently re-run (no node_modules in this seat's checkout) — accepted as the author's measurement, immaterial to the verdict |
From 5571523408 (REWORK, on this same head a478691a):
| # | required by ruling | status | evidence |
|---|---|---|---|
| R1 | buildExpressionScope stops binding app |
not discharged | ExpressionProvider.tsx:88 still return { current_user: user, user, ctx: { user }, os: { user }, app, data, features } |
| R1 | ROW_PREDICATE_ROOTS drops 'app' |
not discharged | ConditionalFormattingEditor.tsx:65-72 still ['record','current_user','user','features','app','ctx'] |
| R1 | app.* pin asserts aligned state |
not discharged | test :198 still KNOWN GAP — an app.* condition is advertised yet the record-scope lint refuses it, expects aria-invalid + /bare reference/ |
| R2 | changeset says app is retired at runtime and from the editor |
not discharged | changeset :66-68 still describes app as an open gap "filed as objectui#8155" |
| R3 | Part of #7727 → Fixes #7727 unless a deliverable of this card is open |
not discharged | body still Part of #7727 with the now-false reason ("parked on a maintainer ruling in #8155") |
| R4 | re-run suite/type-check/ablation on the new head | n/a | no new head |
1. Ruling
#8155, comment 5564935834 by os-zhuang (MEMBER, director seat), heading ## Ruling recorded — option B: objectui aligns to the engine's root vocabulary (director seat, decision batch #67, 2026-09-07). Maintainer's words, verbatim as recorded: 「同意」 (all five batch #67 recommendations adopted). The operative text, verbatim:
Ruling. The engine's
SCOPE_ROOTSis the contract. objectui stops bindingapp:buildExpressionScope(packages/app-shell/src/providers/ExpressionProvider.tsx) drops it,ConditionalFormattingEditor.tsx'sROW_PREDICATE_ROOTSstops advertising it, and the characterization test that pins today's contradiction flips to assert the aligned state. In-treeapp.*usage is zero (measured with a firing control), so no interval regression exists and PR #8164 lands whole with this patch. Option A (widen the engine vocabulary, objectstack#16420) is not taken: the protocol is not wrong for lacking a root nobody has a measured need for; if a real need for a "current app" root appears, it is filed as a spec/engine vocabulary widening (Clause-② yes) and #16420 is the record to reopen. ⛔ The diagnostic is never suppressed incelAuthoring.ts.Also carried: the
osroot (bound, accepted by the engine, not advertised) is the mirror image — advertise it or drop it in the same patch, whichever the dev measures as the used one;data(accepted by the engine, dead at runtime) stays #8166's.
Labels on #8155 today: bug · package: app-shell · pm:queue · priority:p2 · domain:ui — no needs-user-decision (the ruling comment records the flip needs-user-decision → pm:queue). So: the ship decision is not blocked on a pending ruling and is not a decision-inbox item. It was a decision-inbox item and has been decided. The same-day echo on this PR (5564939035, "Unblocked — #8155 ruled option B") says so. #7727 has no ## Ruling recorded; its last comment (5571552950, hotlong) hangs needs:contract-review on the card pending a tier re-review of the patched head.
2. The change (verified)
ConditionalFormattingEditor.tsxblob is5caa9119b84c25a5e04a9d504d9ebf81d331275bat all four commits (35e49ace1,7a1e2723e,86be827b2,a478691af);mainhas4e744f569. Byte-identical across the PR: true.:356scope="record"on theCelPredicateFieldmount: true.:65-72ROW_PREDICATE_ROOTShas no'data': true (still has'app', see §1). Docblock:35-63rewritten to the singlerecordbinding, with the ambient-datacaveat: true.packages/app-shell/src/index.ts: 0export *lines; grep forROW_PREDICATE_ROOTS|ConditionalFormattingEditorreturns nothing.package.jsonexports="."(./dist/index.*) +"./styles.css", no deep subpath. Published surface unchanged by this head: true.⚠️ Not true of the next head:index.ts:14exportsExpressionProviderandevaluateVisibility;buildExpressionScopeitself is not exported. Option B touches the predicate-scope bag only, so it can be done without a surface change — but only if theExpressionProviderappprop (:106,:111-114, still fed tocreateExpressionEvaluator) is left alone. See F2.
3. The regression (#8155) — real, and what the maintainer must decide
Verified independently: @objectstack/formula resolves to 17.2.0 in pnpm-lock.yaml (:4263, :12722); the source at objectstack origin/main 73053ed27 (2026-09-08, package 17.3.0) cel-engine.ts:94-118 lists record previous input output os vars variables automation context args item env user step result trigger event payload data params config settings ctx features parent current current_user — no app, at either version. ExpressionProvider.tsx:88 binds app; ObjectGrid.tsx:2086 hands that bag to resolveConditionalFormatting; ctx and os carry { user } only. So at this head an author who types app.name == 'crm' gets a blocking error and has no resolving spelling. Confirmed. In-tree app.* in any condition/visible*/readonlyWhen/requiredWhen string: my grep (control shape for record. fires 59 times) returns only the PR's own test fixture.
The framing the brief asked for, under the standing 2026-08-27 instruction 「项目在创业阶段,用户也很少,短期不考虑渐进」: the maintainer has already made the call this PR was parked on, and option B is the no-gradual-migration answer — app is retired outright, no interval, no compatibility shim. What remains is not "A / B / C" but whether this PR follows the ruling. Framed for the record, not decided here:
- (i) Implement option B on this PR and re-review — what the ruling and the REWORK say. Business meaning:
app.*disappears from the row-predicate scope entirely (runtime and editor), breaking any out-of-tree metadata that used it; that population is measured zero in-tree and unmeasurable outside, and the maintainer accepted that. - (ii) Land this head as-is, accepting the
app.*refusal until Therecord-scope CEL lint refuses theapproot, which app-shell's predicate scope DOES bind —@objectstack/formula'sSCOPE_ROOTShas noapp#8155 is fixed separately — the PR body's original ask. Business meaning:mainknowingly carries an editor that advertises a root and then blocks Save on it, with no workaround. This contradicts the recorded ruling ("lands whole with this patch") and would need the maintainer to reverse batch Release version 0.2.0 #67, not a reviewer to approve. - (iii) Hold until something else lands — no longer has a referent: option A (objectstack#16420) was explicitly not taken.
Nothing in the ruling or in the head warrants a new needs-user-decision; the open work is a patch round.
4. Tests (verified on the head)
- Host roots derived from the producer:
buildExpressionScopeimported (:15),fullHostScopebuilt from it (:280-285),CURATED_EXCLUSIONS = ['os','data'](:292),hostScope/HOST_BOUND_ROOTSderived by filter (:297-303). Confirmed — no hand-written host literal remains. data.*accepted-but-dead pin (:179-195) labelled "NOT desired behaviour … this card does NOT close", paired with the runtimedata.status == 'overdue'→falsepin (:350-364). Confirmed.- Closure assertion
:339expect([...ROW_PREDICATE_ROOTS].sort()).toEqual([...HOST_BOUND_ROOTS, 'record'].sort()). Confirmed, and it is the assertion that reddens under option B's runtime half if the advertised list is not changed in step (dropappfrom one side only → fails). - Reverting
scope="record"→"flattened": by inspection, two tests redden —:137expects/record\.status/+aria-invalid="true"on a bare ref (flattened lints it clean), and:198expectsaria-invalid="true"onapp.name(flattened accepts any bare identifier). Matches the author's ablation leg C.⚠️ Reasoned from the code, not executed in this seat (no install in the read-only checkout); the author's leg C and CI green on the head are the executed evidence.
5. Files and governed paths
Diff vs merge-base, 3 files: .changeset/7727-conditional-formatting-record-scope.md (+75), packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.test.tsx (+199/−17), packages/app-shell/src/views/metadata-admin/ConditionalFormattingEditor.tsx (+35/−12). Governed paths (AGENTS.md, CLAUDE.md, .claude/**, docs/adr/**): no — none touched. Governed Surface Queue Guard check: success.
6. Changeset
'@object-ui/app-shell': minor. Body states: the accept/reject flip to a blocking ERROR (:8-11), the autocomplete radius (:44-51), the migration impact / unsavable views (:13-20), and that the published surface is unchanged (:34-40). Level is per AGENTS.md 版本号策略 :236-241 ("objectui 自身的破坏性变更也标 minor"); scripts/check-changeset-no-major.mjs exists and the Changeset Bump Policy check passed on the head. app paragraph (:66-68) will be false once option B lands (F3).
7. CI on a478691a
32 check runs: 29 success, 0 failure, 0 in_progress, 3 skipped (dependabot, Test (coverage), Test (coverage shard …/4) — coverage jobs skipped by design). Nothing red or pending. mergeable_state: unknown (GitHub had not computed it at read time). Merge-base cdbc852e9 (2026-09-07 05:21Z); origin/main 40a7c538a (2026-09-08 03:45Z) is 102 commits ahead of that base; the PR carries 4 commits main lacks.
Findings
F1 — The head does not implement the option-B ruling that unblocked it (blocking). ExpressionProvider.tsx:88 still binds app; ROW_PREDICATE_ROOTS still lists 'app'; the app.* pin still asserts the refusal. Expectation for this PR: the patch round in 5571523408 item 1, exactly — after which the closure assertion at :339 should pass with app on neither side and the app.* test should assert unbound and unadvertised (e.g. evalRowPredicate('size(app) >= 0', row, { scope: buildExpressionScope({ app: {...} }) }) → false, and ROW_PREDICATE_ROOTS not containing 'app'), so a re-bind reddens it.
F2 — Option B's runtime half must be scoped to the predicate bag, or it becomes a published-surface change (blocking on the next head, not this one). index.ts:14 exports ExpressionProvider, whose app prop (:106) feeds createExpressionEvaluator (:113) and the context value (:114) — that is on the published face. The ruling names buildExpressionScope only. Expectation: drop app from the buildExpressionScope return (:88) and, if the ExpressionProvider app prop is also removed, say so in the changeset as a surface change (still minor); if it is kept, say that app stays available to evaluateVisibility/the evaluator and is removed from row-predicate scope only. ExpressionProvider.predicateScope.test.ts:65-74 pins this bag and will need to move with it.
F3 — Changeset and PR body describe the pre-ruling state. Changeset :66-68 calls app an open gap filed as #8155; the body says the ship decision is parked on #8155 and that a PASS-WITH-FINDINGS is the standing review. Expectation: 5571523408 items 2 and 3 — the app paragraph becomes a runtime-binding removal, breaking for authored app.* row predicates, ruled acceptable on zero measured usage; Part of #7727 becomes Fixes #7727 unless a deliverable of #7727 itself stays open (#8166/#8167 are their own cards); the body stops citing 5562553734 as the tier gate.
F4 — The os half of the ruling is unaddressed. The ruling carries "advertise it or drop it in the same patch, whichever the dev measures as the used one". At this head os is bound (:88), curated out (CURATED_EXCLUSIONS), and #8156 is still open. Expectation: measure in-tree os.* usage in row predicates with a firing control, pick one side, and either add 'os' to ROW_PREDICATE_ROOTS (and remove it from CURATED_EXCLUSIONS) or drop os from buildExpressionScope — with #8156 closed by the same PR or explicitly deferred with the measurement quoted.
F5 — Re-run on the new head (non-blocking, procedural). 5571523408 item 4: package suite, type-check, and the ablation — the app pin's ablation direction inverts (re-adding app to buildExpressionScope or to ROW_PREDICATE_ROOTS must redden). CI is green on this head, which proves nothing about the patched one.
Confirmed and not re-litigated: the scope="record" flip, the 'data' de-advertisement, the docblock, the turned bare-field pin, the producer-derived host scope with closure assertion, the untouched hint.scope ?? 'flattened' default (celAuthoring.ts:214, :315), the refusal to suppress the diagnostic, minor, and the no-published-face measurement at this head.
Seat limits observed: one comment, no review submitted, no label/body/branch/file touched; throwaway ref deleted, checkout left as found.
Generated by Claude Code
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…ditional-formatting-cel-scope
…lary The maintainer ruling on objectui#8155 (director seat, decision batch #67, 2026-09-07) takes option B: the engine's SCOPE_ROOTS is the contract, and this consumer aligns to it rather than the engine growing a root to match it. `app` was bound by `buildExpressionScope` and advertised by `ConditionalFormattingEditor`, while `@objectstack/formula` refused it — ADR-0068 declares `current_user` with the `user` / `ctx.user` aliases and nothing named `app`. So `app.name == 'crm'` raised a blocking error whose suggested remedy, `record.app`, was nonsense, and no spelling both linted clean and resolved. It is now bound by nothing and advertised by nothing, so the three surfaces agree. `os` is the exact mirror and is settled the other way by the same ruling: bound here, ACCEPTED by the engine, and merely never offered. It is the spec's canonical identity spelling and the measured in-tree one — authored predicates spell `record.owner == os.user.id` across core, components and plugin-grid, including a conditional-formatting condition — so it joins ROW_PREDICATE_ROOTS. `data` is deliberately untouched: the engine accepts it but the row is not reachable through it, and that half is objectui#8166. The characterization pin that recorded the old contradiction now asserts the aligned state, and it is three-sided on purpose — `app` returning to either producer alone reddens it, which the closure assertion cannot see because that one only catches the pair moving together. `ExpressionProvider` still publishes `app` on its React context value, which components read as a plain value; only the expression scope loses it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
`turbo run type-check` found two things the suite could not, because vitest does
not type-check:
- the fallback in `useExpressionContext` was annotated `ExpressionScopeInput`,
which widens every member to optional, so the spread no longer satisfied
`ExpressionContextValue` (TS2322). Dropped the annotation; the inferred
literal type is what the surrounding contract needs.
- the contract suite's host-scope fixture still passed `app: { name: 'crm' }`
into `buildExpressionScope`. That argument had become inert the moment the
builder stopped reading it, so the suite stayed green while the fixture said
something untrue.
The second one is worth keeping in view: removing `app` from
`ExpressionScopeInput` as well as from the returned bag means passing one is now
a COMPILE error (TS2353), not a silently ignored argument. That is a third fence
on the same fact, and the only one that holds without running the suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
Contract review — accepted, flipped out of draft, auto-merge armed. The batch #67 ruling is implemented; the card was ready for a dev on 2026-09-07 and had simply not been picked up. I verified the three ruled edits by reading the branch rather than taking the report's word: and the two call sites that were passing
The sweep was re-run properly, which mattered because ⭐ The ⭐ The flipped pin is three-sided, and the reason it had to be is the sharpest thing in the report: the closure assertion only catches the pair moving together, and "precisely the disagreement state #8155 was filed about is the one it cannot see." So Type-check earned its keep, and the catch is instructive: the contract suite's fixture was still passing The routing call was right and is the kind a dev is not obliged to make. Three Also caught in its own draft: the literal Generated by Claude Code |
`check:spec-symbols` (rule 2) flagged `ROW_PREDICATE_ROOTS`: its docblock read "the spec's canonical identity spelling" within the claim window of an `@objectstack/spec` mention, so the gate read the declaration as claiming spec alignment while referencing nothing spec-bound. The claim is the false part, not the evidence. This list is a deliberate NARROWING of the engine's `SCOPE_ROOTS` (6 of 27) — there is no spec symbol for "which roots this editor offers", so neither deriving it nor a CLAIM_ALLOW entry would be honest; both would plant exactly the premise the gate warns about. The sentence now says what is true: `os.user.id` is the identity spelling ADR-0068 declares and the spec's expression docs describe, and authored predicates in this tree really spell `record.owner == os.user.id`. Comment-only; no behaviour, no exported value changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Post-merge contract audit at
|
| # | Finding | Status on c915e4db |
Evidence |
|---|---|---|---|
| F1 | Implement option B | Discharged | ExpressionProvider.tsx:111-117 bag { current_user, user, ctx:{user}, os:{user}, data, features }; ConditionalFormattingEditor.tsx:90-97 ROW_PREDICATE_ROOTS = ['record','current_user','user','features','os','ctx']; pin flipped ConditionalFormattingEditor.test.tsx:199-224 |
| F2 | Keep the runtime half scoped to the predicate bag, or declare a surface change | Discharged | ExpressionProvider keeps its app prop and publishes it on the context value (:134, :139, :144) but no longer feeds it to the evaluator (:141) or the PredicateScopeProvider scope (:152-155); changeset :71-74 says exactly that. index.ts:14/:62 exports unchanged; buildExpressionScope, createExpressionEvaluator, ExpressionScopeInput, ROW_PREDICATE_ROOTS still not exported |
| F3 | Changeset + body describe post-ruling state; Part of → Fixes #7727 unless a #7727 deliverable stays open |
Partially discharged | Changeset and body rewritten. Part of #7727 kept with the reason "until #8166/#8167 are triaged" — the reason the tier pre-rejected (they are their own cards). Consequence on the board: #7727 open + pm:dispatched + assignee after a status: done report; #8155 open + pm:queue with its ruling executed and no closing keyword anywhere |
| F4 | Settle the os half by measurement |
Discharged | Advertised on measured usage (16 authored hits, record. control 484, app 0); ROW_PREDICATE_ROOTS:95; pin ConditionalFormattingEditor.test.tsx:390-403 with zzz negative control; CURATED_EXCLUSIONS = ['data'] :305; #8156 closed 09-07 |
| F5 | Re-run suite / type-check / ablation on the new head | Discharged | Author's run 6434 tests / type-check 30/30 / ablation legs 1, 2, 3, A′–F′ with movements explained; CI green on c915e4db (below). Final commit is a 3-line JSDoc reword to clear check:spec-symbols (report 5601282010) |
Delta summary (a478691 → c915e4d, PR-own hunks)
ExpressionProvider.tsx:44-56ExpressionScopeInputlosesapp(compile fence, TS2353);:111-117builder dropsapp;:141/:152-155provider stops passingapp;:183-184fallback context keepsapp: {}as a field but builds the evaluator from{ user, data, features }.AppContent.tsx:658-666,RecordFormPage.tsx:203-214stop passingapptocreateExpressionEvaluator(forced by the fence).ConditionalFormattingEditor.tsx:90-97−'app' +'os'; docblock:37-88rewritten;scope="record"unchanged at:381.- Tests:
ConditionalFormattingEditor.test.tsx:162-178every advertised root derived from the const and linted clean;:199-224three-sided pin (:216ROOTS ∌ app,:217bag ∌ app,:222-224DOM refusal ofapp.name == 'crm');:352closure assertion;:390-403osadvertised + control.ExpressionProvider.predicateScope.test.ts:73-86toStrictEqualbag withoutapp. - Changeset
+47/−7(now:45-85app removal + os advertised + fail-open cost); threecontent/docsone-token corrections (enhanced-actions.mdx:156-160,console-architecture.md:125,plugin-form.mdx:196-197).
Clause-② reading
Card declared Clause-②: yes (5562091474). Confirmed yes on the merged head, on the authored-metadata leg only: the importable @object-ui/app-shell face is byte-unchanged (index.ts:14, :62), but the scope bag an authored predicate can reference narrows from {current_user,user,ctx,os,app,data,features} to {…, os, data, features} on every app-shell predicate surface — nav/area visible, field visibleWhen (isObjectFieldVisible :399-408), action/toolbar predicates through PredicateScopeProvider, and row predicates handed to resolveConditionalFormatting. The editor's advertised set is −app +os; the lint accept/refuse set is the engine's and did not move (app still refused). For out-of-tree metadata: any saved app.* predicate stops resolving; there is no replacement spelling. The changeset states the fail-open consequence explicitly (:59-69) and declares minor — correct per AGENTS.md:238-240 (breaking → minor, major banned). One precision defect: :63-66 lists "a conditional-formatting condition" among the predicates that "fail open"; row predicates do not — evalRowPredicate defaults fallback ?? false (packages/core/src/evaluator/listConditional.ts:250, :313, :336), so a stale app.* formatting rule silently stops matching rather than firing.
Fail-open behaviour
Real and confirmed on the merged head. evaluateVisibility (ExpressionProvider.tsx:273-326) delegates to evaluator.evaluateCondition(expression, { onFault }) (:318); the core catch at packages/core/src/evaluator/ExpressionEvaluator.ts:389-408 calls onFault and return true (:407-408) unless throwOnError; the outer catch returns true at :324. useCondition without throwOnError takes the same path (packages/react/src/hooks/useExpression.ts:234). Pre-existing, not introduced here: identical at the PR base 0e3bca45 (:254, :280, :286) and documented as "FAIL-OPEN IS UNCHANGED, deliberately" since commit 7fdf74eb (2026-08-26, #6486). What this PR changes is the population that faults: a saved visibleWhen: app.tier == 'pro' previously resolved (the active app was bound) and now faults → visible. Classification: security-adjacent concealment gate, not an authorization bypass — the client already holds the record payload, the code itself calls it "shipped permission-boundary semantics" (:292-297), and the fault warns in production (visibilityDiagnostic.ts:379, #6038 option B). It warrants a follow-up (F1/F3 below), not a security card — but the warning an author receives on exactly this path currently gives wrong advice (F1).
Governed surface
node scripts/check-governed-queue-guard.mjs --test <10 paths> → NOT GOVERNED (exit 0), independently re-run; Governed Surface Queue Guard check success on both SHAs. The skills/** twins were correctly excluded and filed as #8810 (open, unlabelled).
CI
c915e4db: 34 runs — 31 success, 3 skipped (coverage ×2 by design, dependabot). Lint 11:47:59Z success, Type Check 11:50:32Z success, Test shards 1–4 success, Changeset Bump Policy / Declaration / Fixed Group success. Merge commit 2615505d: 57 runs (merge-queue run 11:59Z + post-merge main run 12:16Z) — 53 success, 4 skipped (matrix placeholder rows, Changeset Release). All green; nothing pending. check-clause2-carriers --pair 8164 cannot form the pair post-merge (exit 2) — inconclusive, not a clearance.
Findings
- F1 (follow-up fix owed,
main) — published@object-ui/reactdiagnostic still advertisesappas bound.packages/react/src/utils/visibilityDiagnostic.ts:154-158SCOPE_TIER_ADVICE['app-shell']reads "…plusappandfeatures", docblock:119-120quotes the old bag withapp; byte-pinned byvisibilityDiagnostic.adviceTier.test.ts:182expect(msg).toContain('app'). This is the production-emitted warning printed precisely when a staleapp.*predicate faults after this PR, and it tells the authorappis bound — the declared-but-unbound shape the ruling removed. review-checklist.md:27: a published claim this round made false must be fixed, not merely noticed; the PR docblock (ExpressionProvider.tsx:87-88) acknowledges the quote "still namesapp" and moves on. No card exists (finding(skills): two published guides nameappas an expression scope root, which the #8155 ruling removes #8810 coversskills/**only). - F2 (factual error in the published changeset and source) — "objectstack#16420 stays open as the record to reopen" is false. #16420 was closed
not_planned2026-09-07T04:16:22Z byos-zhuangin the same batch. Repeated in changeset:69,ExpressionProvider.tsx:102-104, the PR body and Therecord-scope CEL lint refuses theapproot, which app-shell's predicate scope DOES bind —@objectstack/formula'sSCOPE_ROOTShas noapp#8155 comment 5600986967. The changeset is release-note input; correct before release. - F3 (precision) — changeset
:63-66attributes fail-open to conditional-formatting conditions; those fail closed (listConditional.ts:250). Fix alongside F2. - F4 (sibling repo, card owed) —
@objectstack/specpage.zod.ts:303/:326.describe()states "The shipping renderer additionally mountsapp…" — a published.zod.tsstring now false for objectui's renderer. No objectstack card found. - F5 (board) — the fail-open fact was not put in front of the maintainer as claimed. The acceptance note says "put it in front of the maintainer separately"; the only record is The
record-scope CEL lint refuses theapproot, which app-shell's predicate scope DOES bind —@objectstack/formula'sSCOPE_ROOTShas noapp#8155 comment 5600986967 on a card still labelledpm:queue. Noneeds-user-decisioncard; the batch ledger (objectstack#12708) mentions fix(app-shell): lint conditional-formatting in the record scope, and align the predicate scope to the engine's root vocabulary #8164 only as a no-tier-PASS audit item. The migration-note / deprecation-window question is a decision-inbox item, not a comment. - F6 (board) — closure bookkeeping. finding(app-shell/metadata-admin): the conditional-formatting CEL editor lints row predicates in the
flattenedscope and advertises bare fields — declared-but-unbound once #5741 (Phase 2) retires the bare-field binding #7727 open +pm:dispatched+ assignee afterstatus: done; Therecord-scope CEL lint refuses theapproot, which app-shell's predicate scope DOES bind —@objectstack/formula'sSCOPE_ROOTShas noapp#8155 open +pm:queuewith the ruling executed (a PM loop would redispatch it). NeitherFixesnor a manual close was done; per review-checklist.md:13Part ofclosure requires the manual label strip. - Confirmed and not re-litigated: three-sided pin discharges the CHANGES REQUIRED expectation exactly;
datauntouched (finding(app-shell):data.*lints CLEAN atscope:'record'and resolves against the host's ambientdata, never the record —visibleWhen/readonlyWhen/requiredWhensilently constant-false today #8166);celAuthoring.tsuntouched;ConditionBuilder(ConditionBuilderpasses noscope, so an action'svisible/disabledguard lints bare field refs clean on a row surface — the #7727 defect, at five more mount sites #8167) untouched;hint.scope ?? 'flattened'default untouched;minorcorrect; nocontent/docs/releases/edits.
Acceptance notes
- Identity coincidence, stated explicitly: Implemented-by = branch
claude/issue-7727-conditional-formatting-cel-scope,mode:subagentdev of PM sessionsession_01YBWFb5YgMU5dw8p2VKj16S(os-justin). Accepted-by =os-justin, note 5600983103 (11:19:37Z) — noReviewed-by:/Implemented-by:pair, no session id, no tier-fuse reading, and it reads the branch itself. Merged-by =os-justin(ready 11:19:04Z, auto-merge 11:19:10Z, queued 11:59:19Z, merged 12:16:43Z). Author, acceptor and merger are one seat; the dispatching session accepted its own dispatch → SELF-REVIEW under contract-review.md:37, not an independent review. - No tier PASS exists on any post-ruling head. The only
CONTRACT_REVIEW_TIERverdicts are REWORK (hotlong,a478691a) and CHANGES REQUIRED (os-zhuang,a478691a); 0 PR reviews. Landing check ① (in-seat tier PASS on record) was not met; the earlier self-review 5562553734 ran at default tier under a quota exemption that contract-review.md:60 confines to dispatch. - Carrier discipline broken.
needs:contract-reviewwas removed from PR and card 2026-09-08T09:06Z as a handoff whose text says "re-hang both with the patched head" (5582320051); the patched head arrived 10:33–11:37Z and nothing was re-hung, so the PR entered the queue carrier-less. contract-review.md:22: no PASS + head moved ⇒ re-hang. - Substance: the merged head does implement batch Release version 0.2.0 #67 option B exactly, with
ossettled on evidence and a stronger fence than ruled (theExpressionScopeInputremoval is an internal type, not a published-face change). The process defect is the missing independent tier gate, not the code; F1–F4 are the residue a tier review on the patched head would have caught.
Generated by Claude Code
Part of #7727
⛔ Deliberately not a closing keyword, and it must stay that way. #7727 stays open until #8166 and #8167 are triaged, and the parser does not read intent — a stray closing keyword near that number would shut the card regardless of the sentence around it. (A stray closing keyword aimed at that number was caught and removed once on this PR already — and this very sentence originally quoted it verbatim, which would have re-armed it.)
record.FIELD), never in angle-bracket form. GitHub's body sanitiser silently eats tag-shaped fragments — backticks and fenced blocks do not protect them — and an eaten placeholder turns a sentence into its own opposite.Session that produced this work:
session_01YBWFb5YgMU5dw8p2VKj16S.What this round does — the #8155 ruling, executed
The previous revision of this description said the scope flip introduced a live regression and that the ship decision was still open — "⚠️ The measured regression this flip introduces", "there is no author workaround… Save stays shut", "This is the thing to decide in contract review."
All of that is now stale and has been removed. The maintainer ruled on 2026-09-07 (director seat, decision batch #67; maintainer reply 「同意」). The block no longer exists, because
appis no longer bound or advertised.The ruling — option B: objectui aligns to the engine's root vocabulary. ADR-0068 declares
current_userwith theuser/ctx.useraliases and nothing namedapp;@objectstack/formula'sSCOPE_ROOTShas noapp. objectui had bound a root the protocol never declared. Option A (widen the engine, objectstack#16420) is not taken and that card stays open as the record to reopen if a real need is ever measured. ⛔ The diagnostic incelAuthoring.tsis not suppressed — that is the lenient-fallback shape AGENTS.md #0.1 bans, and it was refused twice on this card.buildExpressionScopedropsappproviders/ExpressionProvider.tsx— plusappremoved fromExpressionScopeInput, so passing one is now a compile error, not a silently ignored argumentROW_PREDICATE_ROOTSstops advertisingappConditionalFormattingEditor.tsxos— advertise it or drop it, whichever is measured as the used one⛔
datais untouched and remains #8166's: accepted by the engine, dead at runtime. Its characterization pin is byte-identical.The
app.*sweep, re-runThe ruling rests on "in-tree
app.*usage is zero". That was measured 2026-09-06;mainhas since moved 23 commits and this branch merged them. Re-measured on the merged tree (packages/,apps/,examples/,content/— 5304 tracked text files):app.record.conditionalFormattingblock (the original shape)condition/visible/disabled/hidden/visibleWhen/readonlyWhen/requiredWhen)Every non-zero
app.cell is a false positive, and each was read individually:ConditionalFormattingEditor.test.tsx, theapp.name == 'crm'pin being flipped);appis a local variable —`app-card-${app.name}`,`/apps/${app._packageId ?? app.name}`— not authored metadata;"label": "app.tsx", a filename in a data-display example.Authored in-tree
app.*usage: zero. The controls fire. The ruling's premise holds.git grep -- 'packages/*/src'matches 0 files (verified) while'packages/*/src/*'matches 4013, and neither reachesapps/. This sweep uses plain directory prefixes. Also,git grep -ccounts lines, not entries — all counts above are line counts and are labelled as such.Metadata authored in real deployments is out of tree, and no sweep in this repository can see it. Dropping
appfrombuildExpressionScopeis breaking for anyone whose saved metadata spellsapp.*— a conditional-formatting condition, an actionvisible/disabled, a fieldvisibleWhen. Worse than an error: unresolvable visibility predicates fail open, so such a predicate starts reading as "yes" rather than raising. There is no replacement spelling, becauseappwas never in the protocol. That is the accepted cost of the ruling, not an oversight, and the changeset says so in those words rather than burying it.The
osmeasurement — and it went the other wayThe ruling: "advertise it or drop it in the same patch, whichever the dev measures as the used one." Measured on the same command shape, same corpus:
ospackages/core8,packages/components5,packages/plugin-grid3record(lit control)appOne of the 16 is a conditional-formatting
condition—core/src/evaluator/__tests__/listConditional.test.ts:472,{ condition: 'record.owner == os.user.id' }— i.e. the exact surface this editor authors.os.user.idis also the spec's canonical identity spelling per ADR-0068.So
osis advertised. It is the exact mirror ofappand settles the opposite way: bound bybuildExpressionScope, accepted by the engine, and merely never offered — the one root an author could legitimately write but would never be shown. This is additive; nothing that linted clean before stops doing so.The engine's accept set was re-measured directly rather than inherited (
scope: 'record'):zzzis the negative control and it fires.record.owner == os.user.idlints clean;app.name == 'crm'is refused with the verbatim message ending "Writerecord.app" — the nonsense remedy that started this card.Is
buildExpressionScopeon the published face? No.Checked, since it decides whether this is a published-surface break or an internal one.
packages/app-shell/src/index.tshas 0export *lines and names none ofbuildExpressionScope,createExpressionEvaluator,ExpressionScopeInput,ROW_PREDICATE_ROOTS, orConditionalFormattingEditor. From that module it re-exports onlyExpressionProvider,useExpressionContextandevaluateVisibility. The packageexportsmap is"."plus./styles.csswith no deep subpath, andfilesshipsdist.⇒ Internal change.
@object-ui/app-shell's published face is unchanged. The break is to authored metadata, not to any importable symbol.One thing deliberately not changed:
ExpressionProviderstill takes anappprop and still publishesappon its React context value —DashboardView.tsx:116reads it as a plain value. Only the expression scope loses it. Those are two different things and only the second was ever a CEL root.Proof the pins are lit
Every leg: mutated only after the implementation was committed; pre-patch code restored from the base blob with provenance proved by
git hash-object(not "I put the old code back"); mutation proved on disk in both directions (anchor counts before/after and blob hash not equal to the HEAD blob and a line-total gate); restore proved by state (git hash-objectequal to HEAD blob andgit diff HEAD --quietclean), never by an exit code; all undertrap … EXIT INT TERMon absolute paths. Per-test outcomes from the JSON reporter. Re-run against the final commit, so these numbers describe the tree that ships.Base blobs (recorded before the first edit):
ExpressionProvider.tsxc0dc6e21,ConditionalFormattingEditor.tsx5caa9119.apppin, theospin, the "every advertised root" pin, the closure assertion, and thebuildExpressionScopebag pin. Every pin this PR touches or adds was observed failing against pre-patch code.appback intobuildExpressionScopeonly (editor list proved clean vs HEAD)expected [ 'current_user', 'user', 'ctx', …(4) ] to not include 'app'appback intoROW_PREDICATE_ROOTSonly (scope proved clean vs HEAD)expected [ 'record', 'current_user', …(5) ] to not include 'app'datafrombuildExpressionScopedataRETIRED pin still can fail'data'back intoROW_PREDICATE_ROOTSscope="record"toscope="flattened"osfrombuildExpressionScopebuildExpressionScope⭐ The discriminating leg — the flipped pin is a whole pin, not half of one
The original defect was a disagreement between two producers, so a pin reading only one of them would be half a pin. Legs 2 and 3 put
appback into one producer at a time, with the other proved untouched against HEAD, and the same flipped pin reddens in both directions. It is deliberately three-sided:appback inROW_PREDICATE_ROOTS→ theROW_PREDICATE_ROOTSassertion fails (leg 3, observed);appback inbuildExpressionScope→ theObject.keys(buildExpressionScope(...))assertion fails (leg 2, observed);approot → the DOM assertion fails (leg C' observes this arm, via the flattened accept-anything path).The closure assertion alone would not have been enough: it compares the advertised list against the bound bag, so it only catches the pair moving together — precisely the state #8155 was filed about is the one it cannot see.
Which legs moved, and why
osis now advertised, so more assertions read it: the closure assertion and the newospin join the three that already reddened.buildExpressionScopebag pin in the sibling suite now catches it too — that pin istoStrictEqual, so a root added reddens as loudly as one removed.hostScopeliteral that no longer exists — the previous round replaced it with a read from the producer. The equivalent mutation is therefore at the producer, and it fires.celAuthoring.tsto make the record-scope lint refusedata.*; this patch touches neither that file nor thedatapin, both byte-identical.Verification
Repo root, paths relative to root, no
--, no--no-inline-config. Heavy runs through the shared verify lock; verdicts read from itsVERDICTline, never from a bare$?.pnpm exec vitest run packages/app-shell/— 665 files, 6434 tests, 6433 passed, 0 failed, 1 skipped,VERDICT command-exit 0. (The PR's previous full run read 635 / 6116;mainhas moved 23 commits.)turbo run type-check --filter=@object-ui/app-shell— 30 tasks successful, 30 total, exit 0.ConditionalFormattingEditor.test.tsx,ConditionalFormattingEditor.celGate.test.tsx,ExpressionProvider.predicateScope.test.ts.check-changeset-no-major.mjsOK ·check-changeset-presence.mjsOK (6 published source files, 1 changeset) ·check-governed-queue-guard.mjs --test— NOT GOVERNED, 10 paths checked.type-check earned its keep this round. It found two things vitest cannot see, because vitest does not type-check: the fallback annotation widening
ExpressionContextValuemembers to optional (TS2322), and the contract suite's fixture still passingapp: { name: 'crm' }intobuildExpressionScope— an argument that had become inert the moment the builder stopped reading it, so the suite stayed green while the fixture said something untrue.Declared narrowing. The full
packages/app-shell/run above was taken one commit before the final one. The follow-up commit changed exactly two things: a removed type annotation (erased at runtime — the emitted JS is identical) and one test file's fixture. That test file was re-run explicitly. Nothing else in the package can observe either change. CI runs the whole thing regardless.NOT MEASURED, stated rather than implied:
check:doc-snippets,check:readme-exportsand the repository-widepnpm lintwere not run locally. They are CI's, and on an unbuilt tree the first two fail withTS2307everywhere — a green from them here would have been worth nothing. The threecontent/docsfiles edited below carry no code fences that changed.Also in this patch — three docs sentences that this change made false
content/docs/core/enhanced-actions.mdx,content/docs/guide/console-architecture.mdandcontent/docs/plugins/plugin-form.mdxeach namedappas a readable expression root. After this patch that is false, and leaving it would teach authors to write metadata that now silently fails open — this card's own defect class. One-token corrections, all on the non-governedcontent/docssurface.⛔ Deliberately not fixed here:
skills/objectui/guides/auth-permissions.md:312andskills/objectui/rules/protocol.md:283carry the same false claim, butskills/**is a governed surface — editing it would move this PR onto the "agent drafts, human merges" path and change its merge route. That is not mine to decide unilaterally, so it is reported for a separate card. (Thebind: "app.settings.users"lines elsewhere inskills/are a different mechanism — data binding, not the predicate scope — and remain true.)The ASCII diagram at
console-architecture.md:44still showsExpressionProvider (user, app, evaluator)and is left alone on purpose: it describes the React context value, which does still carryapp.What remains open
data.*lints CLEAN atscope:'record'and resolves against the host's ambientdata, never the record —visibleWhen/readonlyWhen/requiredWhensilently constant-false today #8166 —dataaccepted by the engine, dead at runtime. Pinned here as a characterization test, untouched by this patch.ConditionBuilderpasses noscope, so an action'svisible/disabledguard lints bare field refs clean on a row surface — the #7727 defect, at five more mount sites #8167 —ConditionBuilderreaches the flattened default by passing noscopeat all, so an action guard's bare ref still lints clean and never matches.🤖 Generated with Claude Code
https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Generated by Claude Code