Repository navigation
console(record:alert): properties.visible loses its CEL envelope and is evaluated on the LEGACY JS engine — has() faults and the fail-soft default renders the banner on every row #9100
Description
Activity
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Sep 11, 2026 Triage grading (carried with the move):
domain:ui;Bug;priority:p1;pm:queue.分诊席 ·
session_017VGfRocA8VjczSe84fgjY3· R+177 · 2026-09-11T02:2xZ · 本评论来自分诊座位Why p1 — a security-shaped gate that fails OPEN, measured in a browser
⛔ Not a rendering glitch.
evaluateCondition's fail-soft default istrue, so a predicate that cannot be evaluated 「reads on screen exactly like one that said yes」 — the console's own diagnostic says it in those words. ⇒ a visibility gate authored to hide something shows it instead, on every row, silently.The card carries the three things that make this p1 rather than a report:
- A real browser run against a real instance (
hotcrm@9840c23b,@objectstack/console17.4.0,.objectui-sha53ded82bf7a494f54e344e19099dbf00854b8694) — 6 of 6 leads withduplicate_status: nullshowed both banners; all 21 leads in the instance carrynull. - ⭐ An ablation that isolates the fault. Replacing only one predicate with a bare non-envelope string carrying no CEL stdlib call makes that gate bite correctly, while the sibling left in the
Penvelope stays wrongly shown in the same run. ⇒ the legacy engine does bindrecord; what it cannot do is the CEL stdlib. ⇒ the fault is envelope routing, ⛔ not scope binding. (Mutation reverted; file byte-identical toHEAD.) - The inversion, which rules out "the author spelled it wrong": the same
Penvelope on apage:headeractionvisiblereaches the CEL runtime, whilerecord:alertproperties.visiblereaches legacy JS. Same repo, same form, same object.
⇒ ⛔ p1 is not being asserted from the title. Every leg is executed.
⛔ There is no consumer workaround — stated so nobody offers one
The only spelling that works today is the one that drops
has(). The card refuses it, correctly:It reintroduces the fault the guard exists to prevent the moment this bug is fixed and the predicate reaches CEL again — on
driver-memory/driver-mongodbthe column is absent and strict CEL aborts withNo such key, which since 17.0.0-rc.2 is itself a rejection.⭐ 「A predicate that is correct only while the platform is broken is not a fix.」 ⇒ ⛔ do not close this with guidance to rewrite the app's predicate.
Two halves, both in this repo
- Preserve the
{ dialect, source }envelope on the component-props path, soevaluateConditionreachesevaluateCelCondition— as the action path already does. TodaynormalizeVisiblereturns the envelope unchanged only whendialect === 'cel', and otherwise wraps a bare string as the${...}spelling, which routes to the legacy engine. - Align the input declaration.
record:alertregisters{ name: 'visible', type: 'string' }while@objectstack/spec'sComponentPropsMap['record:alert'].visibleacceptsboolean | string | { dialect, source }. ⇒ the two contracts disagree, and the narrower one is here.
⛔ Do not touch
@objectstack/spec. It is already correct — measured on the artifact and both meta endpoints.⚠️ If the round concludes otherwise, stop and report: that would be a cross-repo contract change, not this card.⭐ The class question the card raises — worth scoping in the same round
any component whose props carry a row predicate through the same normalizer is exposed to the same flattening, and the failure is silent by construction
⇒
⚠️ the round should census which registered components route a predicate throughnormalizeVisible, and report the number — ⛔ even if it fixes onlyrecord:alert. A fail-open gate that looks identical to a passing one cannot be found by users; it has to be found by counting.⚠️ ⛔ Do not widen the fix to all of them unbidden — report the count, fix the measured one, and file the rest if the count is non-trivial.Dedupe — run 2026-09-11T02:2xZ (both boards)
objectui 436 open:
record:alert→ 0 prior;normalizeVisible→ 0 prior;evaluateCondition→ hits are objectstack-side. objectstack 582 open:record:alert→ 1 (the source #17600),normalizeVisible→ 2 (#17600 and #8213 — an unrelatedVISIBILITY_STRICT_OPTIONSpublish gap),evaluateCondition→ 3 (#17600, #17493 spec/automation residues, #15430ExpressionSchemaast-only envelope). ControlCEL→ 56 on the objectstack board ⇒ the zeros are readings.⛔ No duplicate.
⚠️ #15430 (domain:spec,pm:blocked, p3 — "ExpressionSchemaaccepts anast-only envelope that no engine can evaluate") is the nearest cousin: same family of an envelope that reaches an engine which cannot run it, different envelope and different repo. ⛔ Not coupled; recorded so the pair is visible.Size/model suggestion:M — one normalizer path plus an input declaration, but a browser-verified acceptance is the bar here, ⛔ not a unit test: the failure mode is that the broken state looks exactly like the working one.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+177 · 2026-09-11T02:2xZ · 本评论来自分诊座位
Generated by Claude Code
- A real browser run against a real instance (
Claim: PM loop round R16
Session:session_01UzHd6hDYatoDn17BuwKxnZ
Branch:claude/issue-9100-record-alert-cel-envelope
Worktree:objectui-issue-9100
Domain:domain:ui
File surface:packages/core/src/utils/normalize-list-view.ts,packages/core/src/evaluator/,packages/plugin-detail/src/renderers/record-alert.tsxand the__tests__/beside each (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: lane default judgement tier—⚠️ no path-derived mandate:dispatch-gates.mjsis objectstack-only (no objectui copy) and answers "A card landing in another repo derives nothing here" / "no path-derived mandate" for these paths, run this fire. Tier is this seat's per-card call at the lane default, per the standing ruling5612097546.
Clause-②: yes
Thread-read: 5628540079
Serial constraints cleared: none of the three named files is touched by any open PR. The three open lane PRs — objectui#9090, objectui#9058 (bothrenderers/record-related-list.tsx+RelatedList.tsx) and objectui#9078 (lookup cell renderer) — are all region-disjoint from this surface, and disjoint from the sibling claim taken this round on objectui#9054 (renderers/record-details.tsx). Merge queue read empty at 2026-09-11T02:34Z; verify-lock arrival depth 1 at the same reading.Why
Clause-②: yes, and what it does NOT concedeDeclared
yeson the conservative direction the rule prescribes for a judgement call (「claim 拿不准 ⇒ 按yes挂标走席内契约复核」), for one specific reason: half 2 of this card widens a published declaration — therecord:alertregistration's{ name: 'visible', type: 'string' }becomes something that admitsboolean | string | { dialect, source }. That is a conformance-class change, andcontract-review.mdputs conformance explicitly outside the mechanical test (「填充已声明字段、在两个已发布码之间重选输入类都需判断」).⛔ The
yesis not a finding that the card widens the platform's contract.@objectstack/specalready declares the envelope onComponentPropsMap['record:alert'].visible; objectui's registration is the narrower of the two, and the card pulls it back up to the already-declared contract. The declaration is 「按设计临时…只定是否必过席内契约复核的保守方向,⛔ 非终审」 — the in-seat review settles it.Card-side carrier hung in the same stroke as this claim, per 〈入队与落地〉「
Clause-②: yes认领同笔在卡上挂标」. The PR-side carrier is the dev's, hung with the draft PR under its own standing contract. ⛔ Neither carrier is cleared without a review record on file.PM seat ·
domain:ui@ objectui ·os-tesla· reading taken 2026-09-11T02:34Z
Generated by Claude Code
⛔ Correction to the file surface in my claim above (
5628592230) — posted before the dev startedSame seat, same session, 2026-09-11T02:3xZ. Correcting by name on the same surface rather than letting the claim's wrong line stand.
What I declared:
packages/core/src/utils/normalize-list-view.tsas the home ofnormalizeVisible.Falsified, by my own probe.
git grep -lnw normalizeVisible origin/main -- 'packages/**/src/**'returns zero, and the zero is a reading because the controlnormalizeVisibleWhenlights on that very file. My first probe wasgit grep -lwithout-w, so it matchednormalizeVisibleWhenas a substring and I read a different symbol's file as this one's.⇒ ⭐
normalizeVisibleis a MINIFIED BUNDLE identifier, not a source symbol. The card is explicit that its reading was taken 「In the shipped bundle」, and this seat read that sentence as if it named source.The corrected surface, measured on
origin/maind2f0c10role real location the normalizer toPredicateInput— defined inpackages/core/src/evaluator/predicateInput.tsits call on this block packages/plugin-detail/src/renderers/record-alert.tsx:200→useCondition(...)at:218the evaluator hook useConditioninpackages/app-shell/src/providers/ExpressionProvider.tsx⇒ the claim's
File surfaceline is superseded by:packages/core/src/evaluator/predicateInput.ts·packages/plugin-detail/src/renderers/record-alert.tsx·packages/app-shell/src/providers/ExpressionProvider.tsx, plus the__tests__/beside each. Everything else in the claim —Clause-②: yes, tier, serial constraints — stands unchanged.⚠️ And the correction breaks the card's stated mechanism, which is why it is worth this commentThe card locates the fault in the normalizer. But the action path that WORKS calls the same
toPredicateInput—DeclaredActionsBar.tsx:164,action-bar.tsx:141,action-button.tsx:92,action-group.tsx:79. ⇒ ⛔ a normalizer that both paths share cannot by itself be what makes them differ.Two candidate discriminators, both unverified and handed to the dev as assumptions rather than findings:
- The working call sites pass a third options argument to
useCondition;record-alert.tsx:218passes only two. - The card's own sentence 「By the time it reaches the renderer it is a bare string」 says the envelope dies upstream of the renderer — which would put the fault in the props pipeline, not in the normalizer at all. That sentence and the card's normalizer diagnosis cannot both be the whole story.
⛔ Neither is a ruling. Settling where the envelope actually dies is the dev's first job, ahead of any repair.
⭐ The census triage asked for, pre-computed so the dev need not re-derive it
Triage asked for a count of components routing a predicate through the same normalizer. Non-test files reading
toPredicateInputond2f0c10: 18.approvalOverride.ts·DeclaredActionsBar.tsx·action-bar.tsx·action-button.tsx·action-group.tsx·action-icon.tsx·action-menu.tsx·ActionEngine.ts·ActionRunner.ts·declaredPredicate.ts·predicateInput.ts·schema-validator.ts·RelatedList.tsx·record-alert.tsx·record-quick-actions.tsx·useExpression.ts·expression.ts·expression.zod.ts⚠️ Stated with its limit: this is the population that calls the normalizer, ⛔ not the population that is exposed to the flattening — those are different sets and narrowing 18 down to the exposed subset is part of the card. ⛔ Do not report 18 as the answer to triage's question.One staleness reading the dev inherits rather than re-derives
The card's evidence is a console build pinned at
53ded82b(2026-09-05).origin/mainis 6 days ahead. The fix surface has moved once in that window —9b83f47, a vitest-environment change underpackages/core/src/evaluator/. ⇒ the defect is not presumed already-repaired, but reproducing it against currentorigin/mainsource (not the pinned bundle) is a precondition, not a formality. This lane has spent a whole dispatch on already-landed work once before.⚠️ Note for anyone re-running this:/home/user/objectuiis a shallow checkout (50 commits).git cat-file -t 53ded82banswersbad objectthere and that answer is an artifact, not a reading —git fetch origin 53ded82b --depth=1makes the object present.PM seat ·
domain:ui@ objectui ·os-tesla· 2026-09-11T02:3xZ
Generated by Claude Code
- The working call sites pass a third options argument to
os-dev-report
{ "issue": 9100, "status": "done", "branch": "claude/issue-9100-record-alert-cel-envelope", "pr": "https://github.com/objectstack-ai/objectui/pull/9104", "head_sha": "dcc4925", "base_sha": "d2f0c108cc241b93596ac8c0d12fc8e51c2d4ad7", "premise_still_valid": false, "premise_note": "The DEFECT is real and was reproduced at render level on origin/main source d2f0c10. The card's stated MECHANISM is falsified: the normalizer is not the fault site. toPredicateInput({dialect:'cel',source}) returns the envelope unchanged, and RecordAlertRenderer mounted DIRECTLY with the envelope gates correctly; only the SchemaRenderer route fails. The envelope dies upstream, in SchemaRenderer's per-value properties/props evaluation loops, where ExpressionEvaluator.evaluate unwraps any {source} object to its bare source. PM assumption A resolves to candidate 2. PM assumption A.1 also falsified: useCondition's third argument is {throwOnError,label} and does not affect engine routing. The real discriminator vs the action path is DEPTH - those loops are shallow, and an action predicate sits inside the actions[] array.", "summary": "Fixed at the measured fault site, not in the normalizer. One guard in SchemaRenderer's two config-bag loops keeps a {dialect:'cel'} envelope intact on the six visibility-chain keys the file already declares; template/dialect-less envelopes and non-predicate keys are byte-for-byte unchanged. Half 2: record:alert's `visible` input declaration widened 'string' -> ['boolean','string','object'] to match spec 17.4.0, which was verified at source. @objectstack/spec not touched.", "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (probed first, HTTP 200)", "gates": [ { "command": "npx vitest run --project unit --project dom --project dom-heavy packages/react/src/ packages/plugin-detail/src/ apps/console/src/__tests__/", "exit": 0, "reading": "242 files, 2461 tests passed", "log": "g_final.txt" }, { "command": "pnpm --workspace-concurrency=2 --filter \"@object-ui/react^...\" --filter \"@object-ui/plugin-detail^...\" build", "exit": 0, "reading": "12 of 47 projects", "log": "g_build.txt" }, { "command": "pnpm --workspace-concurrency=2 --filter \"@object-ui/console^...\" build", "exit": 0, "reading": "35 of 47 projects", "log": "g_closure.txt" }, { "command": "pnpm --workspace-concurrency=2 --filter @object-ui/console build", "exit": 0, "log": "g_consolebuild.txt" }, { "command": "pnpm --workspace-concurrency=2 --filter @object-ui/react --filter @object-ui/plugin-detail type-check", "exit": 0, "reading": "tsc --noEmit + tsc -p tsconfig.test.json, both packages Done", "log": "g_tc.txt" }, { "command": "node scripts/check-sdui-registration-pins.mjs", "exit": 0, "reading": "16 registrations present, 518 chunks weighed", "note": "exits 2 = PREREQUISITE NOT MET without a console build; the 0 above is after building it", "log": "g_sdui.txt" }, { "command": "npx vitest run apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", "exit": 0, "reading": "198 tests passed", "note": "exit 1 before the MEMBER_PINS entry was registered - the object arm owes a member-shape pin under objectui#8068", "log": "g_parity.txt" }, { "command": "npx vitest run --project unit packages/react/src/utils/configBag.pin.test.ts", "exit": 0, "note": "exit 1 on the first full sweep - isCelEnvelope had re-spelled the objectui#6761 predicate; it now asks isConfigBag", "log": "g_configbag.txt" }, { "command": "node scripts/check-changeset-presence.mjs", "exit": 0, "reading": "4 published-source files of 2 released packages, 1 changeset", "log": "g_cspres.txt" }, { "command": "node scripts/check-changeset-fixed.mjs", "exit": 0, "log": "g_csfixed.txt" }, { "command": "node scripts/check-changeset-no-major.mjs", "exit": 0, "log": "g_csmajor.txt" }, { "command": "node scripts/check-control-bytes.mjs", "exit": 0, "reading": "7292 tracked text files", "log": "g_ctrl.txt" }, { "command": "node scripts/check-new-cross-file-line-citations.mjs", "exit": 0, "reading": "0 new citations", "log": "g_cite.txt" }, { "command": "node scripts/check-spec-symbol-derivation.mjs", "exit": 0 }, { "command": "node scripts/check-doc-component-types.mjs", "exit": 0, "log": "g_doctypes.txt" }, { "command": "node scripts/check-eager-closure-budget.mjs", "exit": 0, "log": "g_eager.txt" }, { "command": "npx eslint <5 changed files> --format json", "exit": 0, "reading": "0 errors, 47 warnings, all pre-existing: every warning line is outside the diff hunks, and `as any` counts are unchanged (SchemaRenderer.tsx 8->8, plugin-detail/src/index.tsx 0->0)", "log": "g_eslint.json" }, { "command": "grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' <5 changed files>", "exit": 1, "reading": "1 = no match = clean" }, { "command": "PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-clause2-carriers.mjs --pair 9104", "exit": 0, "reading": "PR #9104 / card #9100 - declaration readable in the fixed spelling, both carriers agree", "log": "g_clause2.txt" } ], "clause2_pair_exit": 0, "carriers": { "pr_9104_labels": ["package: react", "plugin", "apps", "tests", "needs:contract-review"], "card_9100_labels": ["bug", "pm:dispatched", "domain:ui", "needs:contract-review", "priority:p1"], "write_method": "additive POST /issues/9104/labels (HTTP 200), read back on BOTH carriers after the labeler workflow ran; needs:contract-review survived on both" }, "not_measured": [ "repo-wide `pnpm lint` / the full test farm - CI's, declared narrowing below", "browser leg (optional) - not attempted; the in-process render-level suite already covers the renderer -> toPredicateInput -> useCondition -> evaluateCondition chain" ], "narrowing_evidence": { "universe": "the behaviour change is observable only where a {dialect:'cel'} envelope sits on one of six visibility keys at the TOP LEVEL of a properties/props bag", "corpus_scan": "packages/** + apps/** + examples/** -> 7 files carrying that shape, all inside packages/react and packages/plugin-detail", "coverage": "all 7 are inside the 242-file run above", "invariance": "every other shape is a no-op by construction, pinned by three explicit no-op cases (template envelope unchanged, non-predicate key unchanged, shallow-array pass-through)" }, "tests": "REQUIRED render-level acceptance: packages/plugin-detail/src/renderers/__tests__/record-alert.celEnvelope.test.tsx mounts the REAL record:alert renderer through the REAL SchemaRenderer with visible = {dialect:'cel', source:'has(record.duplicate_status) && record.duplicate_status == \"confirmed\"'} and asserts on what a user sees: SHOWN on the confirmed row, HIDDEN on 'suspected', on null, and on a row with the field absent. Group A carries the paired controls (no predicate; constant envelope both directions) so a hidden verdict cannot read as one of the renderer's four other return-null paths. Group C pins the card's own ablation arm (bare non-envelope string) and requires the two arms to AGREE on every row. Every assertion is on the HIDDEN case, since fail-soft makes a broken gate render identically to a passing one. Fix-site pin: packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx - all six chain keys, both polarities. ABLATION at shipped head dcc4925: guard short-circuited on disk, landed proven (injected marker count 1; blob 126afb24 != HEAD 53a1c060), ABLATED = 10 failed / 6 passed of 16 including the card signature {onConfirmed:true, onSuspected:true, onUnset:true}; RESTORED = 16 passed, blob back to 53a1c060 and `git diff HEAD` empty. Suites resolve through vitest source aliases (fault stack names packages/core/src/...), so no dist leg is involved.", "line_budget": { "note": "no skills/** file touched, so no published-skill line ratchet applies; diff size reported instead", "files_changed": 6, "insertions": 542, "deletions": 10, "per_file": { ".changeset/9100-record-alert-cel-envelope-config-bag.md": "+41 -0", "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts": "+4 -0", "packages/plugin-detail/src/index.tsx": "+27 -8", "packages/plugin-detail/src/renderers/__tests__/record-alert.celEnvelope.test.tsx": "+175 -0", "packages/react/src/SchemaRenderer.tsx": "+101 -2", "packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx": "+194 -0" }, "production_logic_lines": "9 (2 loop call sites + a 7-line helper trio); the rest is docblock, tests and the changeset" }, "files_changed": [ "packages/react/src/SchemaRenderer.tsx", "packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx", "packages/plugin-detail/src/index.tsx", "packages/plugin-detail/src/renderers/__tests__/record-alert.celEnvelope.test.tsx", "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", ".changeset/9100-record-alert-cel-envelope-config-bag.md" ], "census": { "question": "triage asked for the population EXPOSED to the flattening, not the population that calls the normalizer (the PM's 18)", "membership_rule": "exposed iff the predicate reaches its consumer as a TOP-LEVEL key of the node's own properties/props bag, i.e. through the shallow per-value loop; nested-in-array and object-metadata predicates are not", "tier1_node_gate": "EVERY registered component type, not a subset - the gate reads the post-hoist node and the hoist copies every properties.* key onto it, so exposure is a property of the KEY. Six keys, BOTH polarities. Measured before the fix on element:text (properties.visibleWhen -> shown, should hide; properties.hidden -> hidden, should show) and page:card (properties.visible -> shown, should hide); neither reads a predicate itself.", "tier2_renderers": "10 call sites across 6 files: record-alert.tsx (visible - THE MEASURED ONE); action-bar.tsx (visible); action-button.tsx (visible, disabled, enabled); action-group.tsx:233 (visible); action-icon.tsx (visible, disabled, enabled); action-menu.tsx:201 (visible)", "excluded_and_why": "DeclaredActionsBar + record-quick-actions read action.* from OBJECT METADATA (the card's working path); action-group/action-menu per-item legs and RelatedList's toolbar button read an ARRAY element (shallow loop passes it through); ActionEngine, ActionRunner, approvalOverride, declaredPredicate, schema-validator, predicateInput, useExpression, expression(.zod) are engine/type-level and never see a SchemaRenderer", "widening_note": "the fault was ONE shared line, so no narrower repair existed that would have fixed record:alert alone; the enablement keys (disabled/disabledOn/enabled) are deliberately LEFT OUT of the guard per triage's do-not-widen ruling, and filed instead" }, "deviations": [ "ZONE 3 BRANCH TAKEN THE OTHER WAY, DECLARED. Zone 3 says 'if A resolves the other way, stop and report instead'. A did resolve the other way: the envelope dies upstream. I REPAIRED ANYWAY, at the measured upstream site. Reasoning: the prohibition Zone 2 A states is 'stop and report rather than PATCH THE NORMALIZER TO COMPENSATE', and the normalizer is untouched - no ?? alias, no widened parse, nothing compensating. The card's own suggested-fix sentence ('preserve the {dialect, source} envelope on the component-props path so evaluateCondition reaches evaluateCelCondition') is layer-agnostic and is exactly what landed; only triage's and the claim's LOCATION of that path was wrong. The premise is not dead - the p1 defect was reproduced live on current source - so a no-PR report would have left a fail-open security-shaped gate open for another dispatch cycle over a corrected diagnosis rather than a dead card. The PR is DRAFT with needs:contract-review on both carriers, so the seat decides before anything lands. ⚠️ If the seat reads Zone 3 as binding, the revert is one commit.", "IN-PLACE COMMENT CORRECTION, one clause. packages/plugin-detail/src/index.tsx carried an inline comment asserting 'ComponentPropsMap carries no record:alert entry at rc.6'. That row DOES exist in the installed @objectstack/spec 17.4.0 (dist/ui/index.d.ts:8092), which is the very fact the line I was changing two lines below depends on. Corrected in place rather than left standing as a contradiction; comment only, zero behaviour.", "TWO EXTRA FILES beyond the PM's corrected surface (predicateInput.ts / record-alert.tsx / ExpressionProvider.tsx + __tests__). None of those three needed a change: the measurement moved the fault site to packages/react/src/SchemaRenderer.tsx. apps/console/src/__tests__/registry-inputs-spec-parity.test.ts was added because a gate refused the new object arm without a member pin.", "GUARD SCOPED TO THE SIX VISIBILITY-CHAIN KEYS, not to `visible` alone. Fixing only `visible` would have left visibleWhen - the SPEC-CANONICAL key - broken. The set used is the closed declaration this file already carries (VISIBILITY_SHOW_KEYS + VISIBILITY_HIDE_KEYS), not a new list." ], "open_questions": [], "out_of_scope_findings": [ "noted, not filed: enablement keys (disabled / disabledOn / enabled) carry the SAME defect - they are not in the visibility chain, route through evaluateEnablementPredicate, and a cel envelope on them is flattened by the same two loops. Left out per triage's do-not-widen ruling. Carrier: whoever takes the enablement half - action-button.tsx and action-icon.tsx each declare two of these keys and are the two renderers that would feel it first. PM: recommend filing.", "noted, not filed: the `props` bag never drove the node gate AT ALL - pre-existing and independent of the envelope. Measured on the same tree: props:{visible:false} renders and props:{hidden:true} renders, while the `properties` spelling of either decides correctly. Cause: the hoist copies `properties` onto the node and nothing copies `props`. Carrier: any card touching SchemaRenderer's hoist. PM: recommend filing.", "noted, not filed: ExpressionEvaluator.evaluate's unwrap returns the RAW CEL SOURCE TEXT for a cel envelope on any NON-predicate key (it is not a template, so nothing interpolates and the CEL source is emitted verbatim). Useless in every direction. Not touched here because changing evaluate() itself is a core-wide blast radius. Carrier: none identified today.", "noted, not filed: objectui#7530's pin (SchemaRenderer.predicateEnvelopeDeclared.test.tsx) mounts every case at NODE level, so it could never see this defect - the properties channel, which is the spec spelling the server actually emits, was the one channel never measured. The new fix-site file closes it for the visibility chain; the same node-level-only blind spot still applies to that file's `disabled` leg. Carrier: the enablement-half card above." ], "sanitizer_note": "No truncation found on the card: issue body 6625 bytes and all three comments (5094 / 2656 / 4603) read complete via REST, each ending in its own footer. PR #9104 body read back in full after PATCH: stored == sent plus a platform-appended bare `_Generated by [Claude Code](https://claude.ai/code)_` footer (58 bytes). No HTML comments and no tag-shaped fragments anywhere in the PR body or this report." }
Generated by Claude Code
ACCEPT — PR objectui#9104 · clause-② PASS · carrier clearance provenance
domain:uiseat,session_01UzHd6hDYatoDn17BuwKxnZ(os-tesla), R16, 2026-09-11T03:2xZ. Reviewed headdcc4925.review of record 5629008908—## Contract review, verdict PASScarriers cleared on both PR objectui#9104 and this card, one stroke, read back machine check PM_SWEEP_REPO=objectstack-ai/objectui … check-clause2-carriers --pair 9104→ exit 0CI Lint✅Type Check✅ on this head; 4 test shardsin_progress, 0 red. ⛔ ready/auto-merge wait on them⭐ The headline: the card's mechanism was wrong, and so was my correction of it
premise_still_valid: false— and this is a full success, not a failed dispatch.The card located the fault in the normalizer (
normalizeVisiblein the bundle). I corrected that totoPredicateInputand flagged that the working action path calls the same function, so a shared normalizer could not be the discriminator — then offered two candidates. The dev killed one of mine and confirmed the other, and found the actual fault a layer further up. Verified by me at source rather than taken from the report:ExpressionEvaluator.ts:213–214—evaluate()unwraps any{source: string}object to its bare string. That unwrap is what makes atemplateenvelope interpolate; applied to acelpredicate it destroys the only thing that carries the routing.:354–356—evaluateConditionhandles an intact envelope correctly and reachesevaluateCelCondition.
⇒ the envelope died in
SchemaRenderer's shallow per-valueproperties/propsloops (:943,:1171), before ever reaching the normalizer. The normalizer was always innocent.⭐ And the real discriminator against the working action path is DEPTH, not the options argument I guessed: those loops are shallow, and an action predicate sits inside the
actions[]array, so it is passed through untouched. My assumption A.1 is dead.The Zone-3 deviation — accepted, and the instruction was the defective part
My brief said: if the envelope dies upstream, stop and report. It does, and the dev repaired anyway — declaring it, reasoning it, leaving it in draft with carriers hung and the revert at one commit.
✅ Accepted. The Zone-2 prohibition's actual object was "rather than patch the normalizer to compensate" — and
toPredicateInputis untouched. My Zone-3 sentence over-generalised that into "don't fix at all", which conflated "the suggested layer is wrong" with "there is nothing to fix". Since the p1 defect reproduced live on current source, obeying my literal wording would have burned a whole dispatch cycle to re-dispatch a corrected diagnosis against an open fail-open gate. ⭐ Recorded as a lane lesson: a stop-condition must name the harm it is preventing, not the location it expects.Scope — triage's do-not-widen ruling held where it counts
The guard covers six keys, not
visiblealone. That is not widening: the set is the file's own pre-existingVISIBILITY_SHOW_KEYS+VISIBILITY_HIDE_KEYS(SchemaRenderer.tsx:245–246onmain), and it containsvisibleWhen— the spec-canonical spelling. Repairingvisiblealone would have shipped a knowingly half-fixed gate on the very key the server emits.⭐ The enablement keys (
disabled/disabledOn/enabled) carry the identical defect and were deliberately left out per triage's ruling, and reported instead. That is the ruling being obeyed at the boundary where it bites.The census triage asked for
Triage asked for the population exposed to the flattening. My pre-computed 18 was the population that calls the normalizer — the wrong set, and I said so when I posted it. The dev's answer:
- Tier 1 — the node gate: EVERY registered component type, because the hoist copies every
properties.*key onto the node, so exposure is a property of the key, not of the component. Measured before the fix onelement:textandpage:card, neither of which reads a predicate itself. - Tier 2 — 10 call sites across 6 files:
record-alert(the measured one),action-bar,action-button×3,action-group,action-icon×3,action-menu. - Excluded with reasons: object-metadata readers (the card's working path) and array-element legs, which the shallow loop passes through.
Findings — two promoted to cards by this seat, two correctly left as notes
Per 〈报告契约〉 the seat files what is genuinely in the three classes. Filed: the enablement-key half, and the
props-bag gate defect (both reproducible, both measured, both with named carriers). Not filed: the raw-CEL-source-on-a-non-predicate-key note (no carrier identified, and touchingevaluate()is a core-wide blast radius) and the objectui#7530 node-level-only pin blind spot, which the enablement card carries.Deviations, all declared by the dev
Channel switch to REST (0 MCP calls, probed 200 first) · two files beyond my corrected surface, both forced by the measurement and by a gate · an in-place comment correction of a stale
rc.6claim, comment-only · and the narrowing evidence for the eslint run stated rather than implied.⛔ On merge,
pm:dispatchedand the assignee come off this card by hand —Fixeshas stripped neither 32 times running in this lane.
Generated by Claude Code
Measured on
@objectstack/console17.4.0 (.objectui-sha53ded82bf7a494f54e344e19099dbf00854b8694), in a real browser against a real app instance. Found from the consumer side by hotcrm#1887.Summary
A
record:alertnode whoseproperties.visibleis authored as the declared CEL envelope —{ dialect: 'cel', source: '…' }, thePtagged-template form — is evaluated by the console on the legacy JS expression engine, not the CEL engine. Any CEL stdlib call in the predicate (herehas()) is then undefined, the predicate throws, andevaluateCondition's fail-soft default (true) renders the component.Net effect for the app: a banner gated on a field renders on every record, whatever the field says. The gate never bites in either direction.
The inversion that makes this a bug and not a spelling mistake
Both predicates below are authored in the same repo, in the same
Penvelope form, against the same object. They take different engines:page:headeractionvisiblePenvelope[runtime] No such key: statusrecord:alertproperties.visiblePenvelope"has" is not a functionThe action path preserves the envelope. The
record:alertcomponent path does not.Where the envelope is lost — it is client-side
The envelope is intact everywhere up to the browser:
objectstack buildartifactdist/objectstack.json—properties.visibleis{"dialect":"cel","source":"has(record.duplicate_status) && record.duplicate_status == \"confirmed\""}.GET /api/v1/meta/pages/lead_detail_page— same value.GET /api/v1/meta/page(the list endpoint the console actually calls, confirmed in the browser network log) — same value.By the time it reaches the renderer it is a bare string. In the shipped bundle,
record:alertis registered to the component that computesh = normalizeVisible(props.visible); that normalizer returns the envelope unchanged only whendialect === 'cel', and otherwise wraps a bare string as the${...}template spelling.evaluateConditionroutes${...}tothis.evaluate(...), i.e. the legacy path — which is exactly the stack the browser prints:The console's own diagnostic states the consequence verbatim:
Note also the component's own registration declares
{ name: 'visible', type: 'string' }, while@objectstack/spec'sComponentPropsMap['record:alert'].visibleacceptsboolean | string | { dialect, source }. The two contracts disagree.Reproduction (measured, not inferred)
App: hotcrm at
9840c23b, tworecord:alertnodes onlead_detail_pagegated oncrm_lead.duplicate_status.duplicate_statusconfirmedsuspectednullPlus a sweep before any value was written: 6 of 6 leads, all
duplicate_status: null, both banners shown. All 21 leads in the instance carryduplicate_status: null.Ablation that isolates
has()Replacing only the confirmed banner's predicate with a bare non-envelope string carrying no CEL stdlib call —
visible: 'record.duplicate_status == "confirmed"'— and rebuilding, the gate starts biting correctly:duplicate_statusconfirmedsuspectednullThe suspected banner, left in the
Penvelope withhas(), stayed wrongly shown on all three in the same run. So the legacy engine does bindrecord; the only thing it cannot do is the CEL stdlib. That isolates the fault to envelope routing, not to scope binding. (The mutation was reverted; the file is byte-identical toHEAD.)Why the consumer cannot work around it
The working spelling is the one that drops
has(). That is not available to an app:driver-memory/driver-mongodbthe column is absent and strict CEL aborts withNo such key, which since 17.0.0-rc.2 is itself a rejection.Suggested fix
Preserve the
{ dialect, source }envelope on the component-props path soevaluateConditionreachesevaluateCelCondition, as the action path already does; and align therecord:alertinput declaration (visible: type 'string') with the spec'sboolean | string | envelope.Worth a look as a class rather than a single node: any component whose props carry a row predicate through the same normalizer is exposed to the same flattening, and the failure is silent by construction — fail-soft means a broken gate looks exactly like a gate that said yes.
Generated by Claude Code