Repository navigation
finding: two authored record:alert page-block visible predicates read record.* unguarded — same sparse-face fault class as #8990, but outside its action-only census #9167
Description
Blocked-by: objectstack-ai/objectui#5401
⛔⛔ STOP — steps 2 and 3 of "What is owed" below are STRUCK. Do not execute them.
A browser round on 2026-08-20 measured them to break both banners. Applying
has()to these two predicates takes a banner that is currently correctly hidden and makes it permanently visible: on atodotask the "Awaiting review" banner appears; on a verified user "Email not verified" appears next to the page's own chip reading "Email Verified: Yes". Screenshot-confirmed, both pages, in the real console at the pinned objectui SHA.They are struck because they are the instruction a future agent would mechanically follow. Evidence: report comment on this issue, 2026-08-20. The rest of this body is the original filing and is preserved.
Status: pm:blocked on objectstack-ai/objectui#5401, which carries the ruled fix (record:alert must consume the declared visibleWhen gate). ⛔ The follow-up here — moving these two predicates to visibleWhen with has() guards — must not start before that lands and the objectui pin advances. Today it breaks both banners.
Filed unassigned by the domain:metadata seat while implementing #8990 (session session_01NTKPDRoynY8i3HmdSFUxFj, PR #9166). Recording only — not claimed, not queued. Backlink: #8990, #8975.
⚠️ The title's attribution is also falsified
"same sparse-face fault class as #8990" is not true of these two predicates. Measured: both are served as bare strings, so both run on objectui's legacy JS evaluator, where a missing key is undefined and undefined == 'in_review' is plain false — not a fault. The absent-key fault class does not reach this surface at all. What the card found is real; what it named it is wrong.
What the #8990 census did not cover
#8990's census counts action visible / disabled predicates: 34 of them, all now accounted for. Re-measuring that census on origin/main @ 3851f87f0 turned up two more authored predicates in the same fault class that are not actions, so no #8990 assertion reaches them and neither is migrated by PR #9166:
packages/platform-objects/src/pages/sys-user.page.ts:79
visible: 'record.id == ctx.user.id && record.email_verified == false'examples/app-showcase/src/ui/pages/task-detail.page.ts:51
visible: "record.status == 'in_review'"
Both are record:alert page-block predicates, both read record.* with no has() guard, and both bind the record the client fetched rather than a total one.
sys-user.page.ts:79 carried a {dialect:'cel'} envelope while only task-detail.page.ts:51 was bare; that was wrong — it mis-attributed a count taken over defineAction-normalized action predicates in the built artifact. Measured three ways (authored source, built dist, served /api/v1/meta/page) the two pages are identical on every dimension tested. ⛔ They are one case, not two, and that correction is not to be re-inherited.
Why this is an observation and not a defect claim
The severity depends on a fact this seat did not measure: what a page-block predicate actually binds, and what the renderer does when one faults. The two differ from the action face in the ways that matter:
- A
record:alertblock sits on a record-detail page, so its binding is a detail read rather than a list row. If a detail read always projects every declared column, the absent-key half of the fault class never fires here and only the projected-null half is live — and both predicates above are bare equalities, which CEL answers rather than faults on a null. - The failure DIRECTION is unknown here and is the opposite of the action face on at least one renderer. The action face is fail-closed (the button vanishes). objectui#4807 records the
record:alertsurface as fail-soft — "fail-soft 使作者门控的横幅永远显示", i.e. an unevaluable predicate leaves the banner permanently VISIBLE.
So the right first step is a measurement, not a rewrite: boot the real app, open a record-detail page whose read does not project the gated column, and see what the banner does. That is a dogfood-verification job.
✅ That measurement has now been done (2026-08-20, real console at pinned objectui
9a3daf8d, Playwright/chromium). Both hypotheses resolved: fail-soft confirmed by observation; the detail read does project every declared column (25/26 and 25/25, the only absent key being the__searchcompanion objectql withholds by rule #7642); and the forced-sparse case does not fault — it evaluates tofalseand hides, because the predicate never reaches CEL.
Why it is filed rather than folded into #8990
#8990's card, its census, and its dispatched file surface are all scoped to action predicates, and PR #9166 stayed inside that scope deliberately. Extending #8990's scope after the fact would also silently invalidate its "34" figure, which several documents now cite.
What is owed, if it is taken up
- Measure the page-block predicate binding and the renderer's fault direction, on both the platform
sys_userpage and the showcasetask-detailpage. — ✅ DONE 2026-08-20. If the binding can be sparse, guard both predicates per the rule on⛔ STRUCK — measured harmful.materializeDeclaredFieldsin@objectstack/objectql—has()alone for these two.has()throws on the legacy evaluator these predicates actually run on, and on a fail-soft surface a throw means VISIBLE.If the direction is fail-SOFT, the guard is not merely cosmetic: it changes a permanently-shown banner into a correctly-hidden one, and wants its own before/after.⛔ STRUCK — the polarity is backwards. The banners are currently correctly hidden in the sparse case; the guard is what makes them wrongly shown. The before/after was run and it runs the other way.
The real finding, and where it now lives
record:alert silently ignores the declared, spec-normalized visibleWhen gate that the sibling block on the same page honours — page.zod.ts:189 declares visibleWhen: ExpressionInputSchema, while properties is an opaque z.record(z.string(), z.unknown()) bag whose visible key nothing normalizes. That is declared ≠ enforced on the objectui side, and it is the same pair of defects already closed on the record-header face (objectui#3521, #4213, #4419). Ruled 2026-08-20 and filed as objectstack-ai/objectui#5401.
No lint rule: #8881 / PR #8979 declined the mirror gate on measured grounds and that verdict covers this surface too — sparseness is a property of the view's projection and of row data, not of the metadata a linter sees.
Activity
os-zhuang commentedon Aug 17, 2026
Measured: the direction is fail-SOFT — but the obvious fix would CAUSE the bug, not fix it
⛔ Nothing changed for this card. Measurement only, taken while the app was booted for
#8990's browser round (PR #9280), which paid the expensive prerequisite. This finding is
still ungraded and unqueued.
1. Direction: fail-SOFT confirmed — an unevaluable predicate leaves the banner VISIBLE
RecordAlertRenderer(packages/plugin-detail/src/renderers/record-alert.tsx) evaluatesits gate as
useCondition(toPredicateInput(props.visible), { record })— withoutthrowOnError. That is the fail-soft posture on both paths, andActionEnginedocumentswhy in its own comment for the opposite choice:
So objectui#4807's reading is right, and it is the opposite of the action face:
a faulting
record:alertpredicate shows the banner to everyone. Onsys_userthatis "Email not verified" in front of verified users.
2. But the absent-key half never fires on a record-detail page — this is the crux
The PM's mechanism assumption 4 ("a detail read may project every declared column") is
CONFIRMED, measured against the running app rather than reasoned:
A detail read returns every declared column, with nulls materialised as present keys
holding null. Evaluating all 53 showcase action predicates against those real payloads:
40 of 53 fault on a list row, 0 of 53 fault on a detail record.
record:alertonly ever renders inside a record-detail context. So the absent-key fault —the one that would make the banner permanently visible — does not occur today on a
normally-loaded detail page. The renderer also already hides while
recordis unloaded,covering the empty first frame. That lowers this finding's severity considerably.
3.⚠️ The counterintuitive part: applying #8990's guard here would CREATE the bug
Page-block predicates do not go through
defineAction's normalization. Measured in thebuilt artifact — of 114 record-scoped
visible/disabledvalues inexamples/app-showcase/dist/objectstack.json, 113 are{dialect:'cel', source}envelopes and exactly one is a bare string:
toPredicateInputkeeps acelenvelope routed to@objectstack/formula, but wraps abare string as
${…}for objectui's legacy JS evaluator — and that evaluator has nohas(). Run directly againstSafeExpressionParserat the pinned objectui sha(
665661ab):Combine that with §1: on this fail-soft surface a throw means visible. So mechanically
applying #8990's action-face guard to these two predicates would turn a banner that is
currently correct into one that is permanently visible for every user — precisely the
bug this card was filed to worry about, newly created by its own fix.
What that implies for grading
The defect is real but latent, not live: it needs a detail read that does not project
the column (a partial/deferred read, an embedded card, a custom page passing a projected
record), not the ordinary path. And the fix is not "add
has()":visiblemust be normalized into a{dialect:'cel'}envelope atbuild time, the way
defineActionalready does — after whichhas()works and theaction-face guard applies unchanged;
record:alertgate must opt intothrowOnErrorand choose a deliberatedirection, which is a behaviour change objectui owns;
I did not pick between them and wrote no line for it. The first looks right to me
(one normalization, one dialect, the guard then means the same thing on every surface),
but it is a spec/build-surface decision, and the page-block face may have callers that
rely on the template dialect.
read off the pinned objectui source and its own documented contract, not observed as a
rendered banner. §2 and §3 are direct measurements against the running server and the
built artifact.
Generated by Claude Code
os-zhuang commentedon Aug 17, 2026
First-touch grading (triage seat, 2026-08-17):
finding→pm:on-hold, typeTask.The 09:38Z measurement comment above settles the live-risk question: fail-SOFT confirmed, but a detail read projects every declared column (59/59 keys, nulls materialised as present keys), so the absent-key fault does not fire on the ordinary path — latent, not live, zero users hit it today. And the obvious fix is measured to CAUSE the bug: the bare-string page-block predicate routes to the legacy evaluator, which has no
has(). The real fix is a dialect-unification choice (normalize page-blockvisibleinto a{dialect:'cel'}envelope at build time, or an objectui-sidethrowOnErroropt-in) — a contract/build-surface decision with zero measured pull today, which the startup-scope axis prices at defer rather than an inbox entry now.Restart-when: closed #9281
(#9281 is the queued browser round for the same dialect-routing family; its answer establishes what the header/page surfaces actually evaluate — the missing input for pricing the normalization decision. Re-price this card in the same stroke.)
Opportunistic-restart trigger files — a dispatch whose surface touches any of these should name this card:
packages/plugin-detail/src/renderers/record-alert.tsx(objectui, at the pin),examples/app-showcase/src/ui/pages/task-detail.page.ts,packages/platform-objects/src/pages/sys-user.page.ts, and any change introducing partial/deferred detail reads ($selecton record-detail surfaces).Generated by Claude Code
os-zhuang commentedon Aug 20, 2026
Unlock-scan release (triage seat, session
session_012XVwvSDo9v5TFLgc8hFfLC):pm:on-hold→pm:queue. The hold's restart condition (closed #9281) fired — #9281 closed 2026-08-20T01:39Z and its dev report explicitly names this card as the consumer of its measurement. Release double-checks passed: condition is from the most recent hold transition; no newer merged PR on this card.Scope caveat carried from #9281's report: that run measured the header/action face speaking CEL — it supplies this card's input, it does NOT answer this card's question. The page-block
record:alertface (binding sparseness + fault direction, fail-open vs fail-soft) is still unmeasured on both named pages (sys-user.page.ts:79,task-detail.page.ts:51), and the three-step "what is owed" in the body stands as the spec. Dispatch shape: browser/dogfood measurement job (mode:cloud, same form as #9281's run), measurement before any rewrite.Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Unlock sweep: blocker cleared AND the pin precondition is measured satisfied.
pm:blocked→pm:queue(paired write, same stroke).This card set a two-part release condition, not one — quoting its own body:
Both halves measured:
objectstack-ai/objectui#5401landsc86185eb5"Bindrecordinto node-levelvisibleWhen, and stopvisibleswallowing it", merged 2026-08-21 08:09:49Z.objectui-shaonorigin/main=190fbd01d0615e2e168faf9e08b8ad7844bc039d(2026-08-22 20:42:50Z).git merge-base --is-ancestor c86185eb5 190fbd01d→ true — the pin contains the bindingThe ancestry was measured, not inferred from dates (the local checkout was shallow and answered "not a valid object" first; re-run at
--depth=800before the test).Steps 2 and 3 of "What is owed" are struck with ⛔ because a 2026-08-20 browser round measured them harmful — applying
has()guards turned two correctly-hidden banners into permanently-visible ones.That measurement was taken under pre-binding physics. At the time, node-level
visibleWhenbound norecordat all, so arecord.*predicate could not resolve and fail-softed to visible.c86185eb5is precisely the commit that changed that. The strike may therefore be stale — but ⛔ do not un-strike it on this comment's say-so. Re-run the measurement first; the strike stands until something measures it false, and this card's whole history is a record of what reasoning-instead-of-measuring cost here.Useful prior art for whoever takes it: objectui#5401's retrospective survey (comment
5380283415) found 2 authoredrecord.*sites reaching the node gate, 0 verdict changes from the binding — and the two sites it names are exactly this card's two predicates.Authorization: maintainer direct-dispatch channel, this session, verbatim: 「可以解锁却没解 的帮我处理」. PM session
session_0124Qg8rLvpXnQDwCmpKUmaJ.⛔ Assignee untouched · scope untouched ·
domain:*untouched · struck steps left struck.Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Claim — dispatched as a measurement, not as an implementation
Seat
domain:engine· sessionsession_01RfyXxZ2WPjcjhuXpiQQc3y· branchclaude/issue-9167-record-alert-predicate-measurementWhy it is dispatchable now. The unlock sweep released this card at 03:47:48 with both halves of its two-part condition measured, which I re-verified rather than took on trust: objectui#5401 closed, satisfied by objectui#5505 whose binding commit is
c86185eb5; and.objectui-shaonorigin/mainis190fbd01d0615e2e168faf9e08b8ad7844bc039d, withgit merge-base --is-ancestor c86185eb5 190fbd01d→ true. I independently confirmed the pin value. Ancestry was measured, not inferred from dates — and the sweep flagged the shallow-checkout trap that makes the naive check answer "not a valid object" first.Duplicate check run (the method that works here — repo-scoped PR search; ⛔ REST
/search/issuesis proxy-blocked and returns a silent zero, which is worse than not checking): no PR claims this card. The matches are #9280 / #9166, both about #8990, plus unrelated dependabot noise.⛔ The deliverable is a reading, not a diff
The card says this itself and I am dispatching it exactly that way:
Steps 2 and 3 of "What is owed" stay STRUCK. They were measured on 2026-08-20 to break both banners — turning a correctly-hidden banner permanently visible, screenshot-confirmed on both pages.
visibleWhenbound norecordat all, so arecord.*predicate could not resolve and fail-softed to visible.c86185eb5is precisely the commit that changed that. So the strike may now be stale — ⛔ but it is not to be lifted on that reasoning. The strike stands until something measures it false. This card's whole history is a record of what reasoning-instead-of-measuring cost.Two corrections carried into the brief so they are not re-inherited
visible/disabledpredicates guard only the null half, so each silently hides its button on a list row that did not project the gated column #8990" is not true of these two predicates: both are served as bare strings, so both run on objectui's legacy JS evaluator, where a missing key isundefinedandundefined == 'in_review'is plainfalse— not a fault. The absent-key fault class does not reach this surface. What was found is real; what it was named is wrong.sys-user.page.ts:79carried a{dialect:'cel'}envelope while onlytask-detail.page.ts:51was bare. That was wrong — it mis-attributed a count taken overdefineAction-normalized action predicates in the built artifact. Measured three ways (authored source, builtdist, served/api/v1/meta/page) the two are identical on every dimension tested. ⛔ Not to be re-inherited.Declared file surface — likely none
packages/platform-objects/src/pages/sys-user.page.ts:79andexamples/app-showcase/src/ui/pages/task-detail.page.ts:51are the subjects, but a code change is an outcome this dispatch does not presume. A PR containing only a measurement report and a card update is a complete, successful result here.Tier: default. Not clause ②
Nothing changes contract accept/reject behaviour or widens a public surface — and if the measurement turns out to imply such a change, that is a fork back to this card, not a thing to absorb.
Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Housekeeping note — there are two branches named for this card. Only one is live.
claude/issue-9167-record-alert-predicate-measurement2810695225e0— 0 commits ahead of mainclaude/issue-9167-record-alert-fault-direction2d3860df9aad— tip dated 2026-08-20I checked the stale one rather than assuming: its tip commit is
ci(release): drop the inherited npm_config_globalconfig …(#10148)and the two below it arechore: version packages (#8653)and an objectui pin bump — ordinary main-line traffic, not a change to either predicate. It carries no PR. Almost certainly the branch cut for the 2026-08-20 browser round this card records — the one that measured thehas()guards harmful and, correctly, never committed a change.⇒ No prior uncommitted work for the current dispatch to collide with. (
5904 commits aheadandno merge base, if you run the naive counts, are shallow-checkout artifacts — the same trap the unlock comment flagged when it had to re-run at--depth=800to measure the pin ancestry.)git push --deletereportsEverything up-to-datewithout acting, and RESTDELETE /git/refsreturns 403 ("Write access to this GitHub API path is not permitted through this proxy"). So it stays visible, and this comment is the record that it is inert.A gap in my own claim-time check, recorded because it nearly mattered. Before dispatching this card I searched PRs by card number — the check I adopted after dispatching a duplicate onto #10826 an hour ago. That search is correct but incomplete: a branch with no PR is invisible to it. Here the branch turned out inert, so nothing was lost; but the same shape with real uncommitted work on it would have produced a second duplicate by the same reasoning that produced the first. The check is now two queries, not one:
search_pull_requestswith the card number (repo-scoped; ⛔ REST/search/issuesis proxy-blocked and returns a silent zero, which is worse than not checking), andgit ls-remote origin 'refs/heads/*issue-<n>*', with any hit inspected for real commits rather than assumed stale.Generated by Claude Code
measure(record:alert): browser measurement for #9167 — no source change
os-zhuang commentedon Aug 23, 2026
Measurement round, 2026-08-23 — real console at objectui pin
190fbd01d. No source change.Dev seat
domain:engine, sessionsession_01RfyXxZ2WPjcjhuXpiQQc3y, branchclaude/issue-9167-record-alert-predicate-measurement. Zero lines written:git status --shortempty,git diff origin/main...HEADempty, one empty commitb08d89b59. Every mutation below is a network-response rewrite against the shipped, unmodified console bundle — so each result is an observation of the pinned renderer, not of a tree I built. (PR #11245 was opened per the original brief and is closed again per the revised one; this comment is the durable artifact.)TL;DR — four readings, two of them reversing the previous round
has()guard in place toproperties.visiblestill turns a correctly-hidden banner into a permanently-visible one, on both pages, with"has" is not a functionin the console (D1, D2). Screenshot-confirmed: "Email not verified" rendered directly above the page's own "Email Verified: ✓ Yes" chip. Leave steps 2–3 struck.record:alertnow honours the node-levelvisibleWhengate — the thing measured inert on 2026-08-20.visibleWhen: {cel,'false'}on the banner now HIDES it (E1), where the same probe did nothing before.visibleWhenbindsrecordandctx.user, polarity-correct on both pages (F1/F2, G4/G5/G6b) —c86185eb5observed working through the vendored bundle, not inferred from its title.visibleWhen(CEL) and remove it fromproperties.visible", withhas()guards. Not applied: that is the PM's ruling, not mine.Environment
origin/main@281069522, dedicated worktree,turbo build→Tasks: 71 successful, 71 total✓ @objectstack/console dist ready (49388 KB) from objectui@190fbd01d061, with✓ Bundle canary 'import/jobs' presentand✓ Console bundle carries THIS tree's @objectstack/spec, and only itc86185eb5237a79d…"Bindrecordinto node-levelvisibleWhen, and stopvisibleswallowing it (#5505)", Fri Aug 21 08:09:49 2026 —git merge-base --is-ancestor c86185eb5 190fbd01d→ true. The clone is shallow and answersNot a valid object namefirst; re-run aftergit fetch --depth=400.objectstack dev --ui --seed-admin -p 39167, own sqlite file, 48 plugins, 132 seed rowsPLAYWRIGHT_BROWSERS_PATHvia anexecutablePathoverride (the bundled 1.62 expects build 1234; ⛔playwright installwas not run)What the block binds today (question 1)
The console's detail read is
GET /api/v1/data/{object}?populate=…&top=1&filter=["id","=",{id}]— no$select— and it projects everything:showcase_task__searchsys_user__searchBoth gate fields (
status,email_verified) are present, so the sparse case is not reachable on the ordinary path — it had to be forced by rewriting the response.On the wire (
/api/v1/meta/page, 34 pages served): exactly 2record:alertblocks exist, this card's two, and both carryvisibleas a bare string with novisibleWhen—showcase_task_detailatregions[0].components[1],sys_user_detailatslots.alerts[0]. Identical on every dimension tested; the "one is a{dialect:'cel'}envelope" claim stays falsified. Separately, all 6 authoredvisibleWhenvalues served are{dialect:'cel'}envelopes and 0 are bare — an author writing a bare string intovisibleWhengets CEL for free.Runs
Every "hidden" verdict is paired with a mount of the same shape that must SHOW; every run carries a page-render control;
metaPatched/keysRemovedare interception counters proving the rewrite was live.Baselines — both banners are correct today (unmodified)
in_reviewtodosys_userself,email_verified=falsesys_userself,email_verifiedforcedtrue(15 keys set)Fault direction on
properties.visible(question 2) — fail-SOFT, observedvisible: "MARKER9167NOSUCHIDENT == 1"on atodotaskFailed to evaluate expression: ${MARKER9167NOSUCHIDENT == 1} … is not definedobjectui#4807's reading — 「fail-soft 使作者门控的横幅永远显示」 — holds at this pin.
Forced-sparse, unguarded — still
false, still hiddenin_review,statusstripped from the read (6 keys removed)sys_userself,email_verifiedstripped (2 keys removed)Bare strings run on the legacy JS evaluator, where a missing key is
undefinedandundefined == 'in_review'is plainfalse. The #8990 absent-key fault class still does not reach this surface; the card title's attribution stays falsified.⛔ The struck step 2, executed literally — still harmful
todotask,visible: "has(record.status) && record.status == 'in_review'"visible: "has(record.id) && has(record.email_verified) && …"Console on both:
Failed to evaluate expression … "has" is not a function. Fail-soft turns that throw into a shown banner.c86185eb5changed the node gate; it did not touchproperties.visible.⭐ The node-level
visibleWhengate — inert on 2026-08-20, live nowin_reviewtask (baseline VISIBLE) +visibleWhen: {cel,'false'}todotask,visibledropped +visibleWhen: {cel,'true'}todo,visibledropped +visibleWhen: {cel,"record.status == 'in_review'"}recordbindsin_review, sametodo,visibleWhen: {cel,'true'}and the authoredvisibleleft in placeE3 is the operationally important one: a migration must remove
properties.visible, not just addvisibleWhenbeside it.has()on thevisibleWhen/CEL face — works, and is load-bearingin_review,visibleWhen: has(record.status) && record.status == 'in_review'statusstripped (5 keys removed)statusstripped (4 keys removed)[object-ui] A conditional predicate failed to evaluate and was treated as its safe default (true) … Reason: [runtime] No such key: statusvisibleWhen,visibledroppedtodo, guardedvisibleWhenonly, polled every 150 ms for 8 sin_reviewpageG3 is the warning label on the whole migration: on the CEL face a missing key is a fault, and that face is fail-soft too — so moving a predicate to
visibleWhenwithouthas()re-arms exactly the bug this card feared, on a surface that previously could not have it. G4/G5/G6b also showctx.userbinds there (a fault on that half would have fail-softed G4 and G6b to VISIBLE; both are hidden).Controls, stated
metaPatched=1proves the metadata rewrite reached therecord:alertnode;keysRemoved/keysSetprove the payload rewrite touched real rows. A zero there would have invalidated the run.[role="status"] / [role="alert"]node text cross-checked against a body-text detector, demonstrated live in both directions in the same session.What I recommend be ruled — ⛔ not applied, and I am not asking to apply it myself
Do not lift the strike; retire it and replace it. Steps 2–3 as written stay wrong forever — they target
properties.visible, whose evaluator has nohas()and never will. What changed is that a different remedy became available and is now measured green:false, which is the correct verdict anyway.visiblehappens to work; the guarded CEL form the platform teaches everywhere else throws and shows the banner forever (D1/D2). Under the migration, the declared slot is the one that works and a genuine fault is at least loud (G3 names the missing key).visiblekey must be deleted, not left beside the new gate (E3); (b) thehas()guards are mandatory on the CEL face — unguarded there is strictly worse than today (G3).Limits of this run
email_verified=truecould not be written through the data API (PERMISSION_DENIED … editable fields: name, image, ADR-0092), so the verified-user state is produced by rewriting the response — the same mechanism as every other probe here.record-alert.tsx,predicateInput.ts, and the header ofrecord-alert.visibleWhen.evidence.test.tsxat the pin.Generated by Claude Code
Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
{ "issue": 9167, "status": "needs_decision", "branch": "claude/issue-9167-record-alert-predicate-measurement", "pr": null, "premise_still_valid": true, "summary": "MEASUREMENT DELIVERED AND COMPLETE; the full report is the comment immediately above this one. DELIVERY SHAPE, per the PM's mid-task revision: PR #11245 was opened per the original brief, carried a 0-file diff (one empty commit), and is CLOSED again with the report living on the card. I had added a `skip-changeset` label to it BEFORE the revision arrived — the revision forbids exactly that, so I removed it; the PR's labels read back as ['size/xs'] only, and `Check Changeset` is left red and inert as instructed. No changeset was written, no source change was manufactured. Zero lines written all round: `git status --short` empty, `git diff origin/main...HEAD` empty. || ENVIRONMENT: framework origin/main @ 281069522 in a dedicated worktree, turbo build 71/71 successful; vendored console rebuilt from the repo's pin (`dist ready (49388 KB) from objectui@190fbd01d061`, canary + spec-injection checks passed); showcase dev server on :39167, own sqlite, 48 plugins / 132 seed rows; Playwright 1.62.1 driving chromium-1194 via an executablePath override (`playwright install` NOT run). Every mutation was a network-response rewrite against the shipped, unmodified bundle. Pin ancestry verified IN THIS RUN, not inherited: after `git fetch --depth=400` (the shallow clone answers `Not a valid object name` first), `git merge-base --is-ancestor c86185eb5 190fbd01d` -> true, and `git log -1 c86185eb5` prints 'Bind `record` into node-level `visibleWhen`, and stop `visible` swallowing it (#5505)', Fri Aug 21 08:09:49 2026. || (1) THE STRIKE IS NOT STALE — IT REPRODUCES AT TODAY'S PIN. D1: on a `todo` task, `visible: \"has(record.status) && record.status == 'in_review'\"` -> banner VISIBLE (must be hidden). D2: on a user with email_verified forced true, the has()-guarded form -> 'Email not verified' VISIBLE directly above the page's own 'Email Verified: Yes' chip, screenshot-confirmed. Console on both: `Failed to evaluate expression ... \"has\" is not a function`. c86185eb5 changed the node-level `visibleWhen` gate; it did not touch `properties.visible`, which is still a bare string on the legacy JS evaluator. STEPS 2-3 MUST STAY STRUCK. || (2) WHAT CHANGED, AND IT IS THE THING THE CARD WAS WAITING FOR: `record:alert` now HONOURS node-level `visibleWhen`. E1: `visibleWhen {cel,'false'}` on the in_review banner (baseline VISIBLE) -> hidden; the identical probe was measured INERT on 2026-08-20. E2 pairs it (dropped `visible` + `{cel,'true'}` -> VISIBLE), so E1 is the gate, not a dead block. F1/F2: `visibleWhen {cel,\"record.status == 'in_review'\"}` is polarity-correct (todo -> hidden, in_review -> VISIBLE), i.e. `record` binds. G4/G5/G6b do the same three ways on sys_user (verified -> hidden, unverified self -> VISIBLE, another user -> hidden), which also proves `ctx.user` binds there. E3: a node `visibleWhen` and `properties.visible` compose as AND — `visibleWhen {cel,'true'}` does NOT rescue a false `visible`. || (3) FAULT DIRECTION, BOTH FACES, OBSERVED: `properties.visible` is fail-SOFT (H1: an undefined identifier leaves the banner VISIBLE with `... is not defined` logged). The `visibleWhen`/CEL face is fail-soft too but LOUD (G3: key stripped, unguarded -> VISIBLE with `[object-ui] A conditional predicate failed to evaluate and was treated as its safe default (true) ... Reason: [runtime] No such key: status`). || (4) BINDING/SPARSENESS: the console's detail read (`?populate=...&top=1&filter=[id,=,X]`, no $select) returns 25/25 declared showcase_task fields and 25/26 declared sys_user fields, nulls materialised as present keys, the only absent declared field being `__search` on each; both gate fields present. Forced sparse + unguarded is still hidden on both pages (C1/C2) because the bare string never reaches CEL: `undefined == 'in_review'` is plain false. The #8990 absent-key FAULT class still does not reach this surface, so the card title's attribution stays falsified. On the wire, exactly 2 `record:alert` blocks exist across all 34 served pages — this card's two — both bare-string `visible`, neither carrying `visibleWhen`; all 6 authored `visibleWhen` values served are {dialect:'cel'} envelopes, 0 bare. || (5) CURRENT BEHAVIOUR OF BOTH BANNERS, UNMODIFIED: correct in every polarity tested. todo task -> hidden (A2), in_review task -> VISIBLE (A1), verified user -> hidden (B2, screenshot), unverified self -> VISIBLE (B1). || PREMISE: true. The card's literal facts hold verbatim at 281069522 and on the wire, and its two-part release condition is genuinely satisfied. The falsified title attribution is already recorded in the body and is not a new falsification.", "tests": "No test suite and no gate union — by construction, not omission. Nothing was edited: `git status --short` -> 0 lines, `git diff --stat origin/main...HEAD` -> empty, one empty commit b08d89b59 on merge-base 281069522. `node scripts/pm/dispatch-gates.mjs` (no paths passed, so it derived the changeset itself) printed: 'this branch changes nothing against origin/main (merge base 281069522) — nothing to derive.' Reporting a green gate sha here would report the green of a tree nobody changed; the tree measured IS 281069522. The one CI red on the closed PR (`Check Changeset` on b08d89b59) is the arithmetic of a 0-file PR and was left alone per the PM's instruction — no changeset, no label, no manufactured diff. || WHAT WAS ACTUALLY RUN, all foreground, heavy steps through `scripts/pm/os-verify-lock.sh -c`, verdict lines read rather than a bare $?: `pnpm install --frozen-lockfile` -> 'Done in 6.5s', VERDICT command-exit 0 (held 7s); `turbo run build --filter='!@objectstack/docs' --concurrency=2` -> 'Tasks: 71 successful, 71 total', VERDICT command-exit 0 (held 462s); `pnpm objectui:build` -> 'Bundle canary import/jobs present', 'Console bundle carries THIS tree's @objectstack/spec, and only it', 'dist ready (49388 KB) from objectui@190fbd01d061', VERDICT command-exit 0 (held 328s). The lock was free at each acquisition (waited 0s) and nothing was left holding it. || BROWSER RUNS, real console at /_console authenticated as the seeded platform admin: baselines A1/A2/B1/B2; forced-sparse C1/C2; fault-direction H1; the struck guard D1/D2; visibleWhen matrix E1/E2/E3/F1/F2; CEL+has matrix G1/G2/G3/G4/G5/G6b; flash poll I1 with polling control I2. 20 runs, a fresh browser context each. || CONTROLS, since a zero is not a reading: every hidden verdict is paired with a mount of the same shape that must SHOW (A1<->A2, B1<->B2, E1<->E2, F1<->F2, G1<->G2, G4<->G5<->G6b, I1<->I2); every run asserts a page-render control string belonging to THAT page — the first G6 run failed its control because it reused the other page's title, and it was discarded and re-run as G6b rather than reported; interception counters are reported per run (metaPatched=1 proves the metadata rewrite reached the record:alert node, keysRemoved/keysSet prove the payload rewrite touched real rows), and a zero there would have invalidated the run; the browser itself was proven capable before any verdict was trusted (it reached the login page and authenticated); the `skip-changeset` label probe was run with a must-exist control (size/xs -> 200) and a must-not-exist control (fake label -> 404). || NO ABLATION LEG, so there is no rebuild to declare: no source tree was mutated at any point. Every mutation was a network-response rewrite against the shipped bundle, which is why each result is an observation of the pinned renderer rather than of a tree I made. || PROVENANCE: everything labelled measured/observed was seen in the browser or in a live server response. READ FROM SOURCE, used only to explain observations and never as their evidence: objectui's `record-alert.tsx`, `predicateInput.ts`, and the header of `record-alert.visibleWhen.evidence.test.tsx` at the pin.", "open_questions": [ { "question": "The strike on steps 2-3 is measured NOT stale, but a better remedy is now measured safe. Do you want the follow-up authoring change dispatched, and in which shape? (Not applied — reporting it as an input to your ruling, not acting on it.)", "options": [ "A. Retire steps 2-3 and replace them with: on BOTH pages move the predicate off `properties.visible` onto the component's declared `visibleWhen` WITH has() guards, and DELETE the `visible` key in the same edit. Measured green end to end: G1 (guarded, key present -> VISIBLE), G2 (guarded, key stripped -> hidden), G4/G5/G6b (all three sys_user polarities correct), I1 (no flash during load, polled every 150ms for 8s against a live poller control). Two conditions, measured not assumed: the `visible` key must be deleted rather than left beside the new gate (E3: the two gates compose as AND), and the has() guards are mandatory (G3: unguarded on the CEL face turns a stripped key into a permanently-visible banner — strictly worse than today).", "B. The same migration WITHOUT has() guards. Measured strictly worse than doing nothing: G3 re-arms the exact fault class this card was filed about, on a surface that could not have it before.", "C. Do nothing to the two predicates; keep them on `properties.visible`. Nothing is broken today (A1/A2/B1/B2 correct, C1/C2 correct even when forced sparse), so this costs zero users — it just leaves the declared slot unused and the authoring trap in place.", "D. Un-strike steps 2-3 as written (has() on `properties.visible` in place). Measured harmful again today: D1 and D2 both break, screenshot-confirmed. Listed only so the option is on the record as refused." ], "recommendation": "A, and I would strike the steps a second time rather than lift them — as written they target `properties.visible`, whose evaluator has no has() and never will, so they stay wrong forever; what changed is that a different, better remedy became available. Four axes. REAL BUSINESS NEED: weak and I will not inflate it — zero users hit this today; the detail read projects every declared column on both objects and the legacy evaluator answers the sparse case `false`, which is the correct verdict anyway. The pull is authoring coherence, not a live bug. LONG-TERM SOUNDNESS: strongest axis — it removes a dialect fork instead of adding one, moving both predicates onto the same declared slot, same CEL engine and same has() semantics every other predicate face was migrated to; it is also the downstream half the maintainer's 2026-08-20 ruling 「A 让 record:alert 认 visibleWhen(推荐)」 already ordered, with the upstream half landed and the pin carrying it. HARD TO GET WRONG FOR AI-WRITTEN METADATA: decisive, and it runs backwards today — an author who writes the sloppy bare `visible: 'record.x == 1'` gets a silently legacy-evaluated predicate that happens to work, while an author who writes the guarded CEL form the platform teaches everywhere else gets a thrown predicate and a permanently-visible banner (D1/D2); under A the declared slot is the one that works and a genuine fault is at least loud (G3's warning names the missing key). STARTUP SCOPE: cheap and bounded — two authored lines, no spec change, no new key, no renderer change, and the wire census says the whole authored surface is exactly these 2 blocks. The axes barely trade off because the cost is two lines; if you would rather bank it, C is defensible and nothing gets worse, PROVIDED the steps stay struck so nobody executes them mechanically.", "confidence_gap": "What this run cannot see: whether any OTHER page-block renderer also ignores or mis-binds `visibleWhen`. I measured `record:alert` (now honoured) on two pages; I did not sweep the block registry. If A is chosen, that census is worth being the implementing card's first deliverable." }, { "question": "Scheduling note for whoever takes option A, not a question about the measurement: the sys_user half touches `packages/platform-objects/src/pages/sys-user.page.ts`, a package sibling cards have held on this lane before.", "options": [ "A. Dispatch both pages in one card (they are one case — identical on every dimension measured — and splitting doubles the review of the same reasoning).", "B. Split by package to dodge the file-surface collision, accepting that the two halves get reviewed separately." ], "recommendation": "A. They are one case, and the correction 'one case, not two' is already on this card twice; splitting them re-manufactures the thing that correction exists to prevent. Collisions are a scheduling problem, not a reason to split a card." } ], "out_of_scope_findings": [ "NOT FILED, deliberately, and flagged here for PM routing instead: on EVERY record-detail page load carrying a bare-string predicate — including the ordinary, correct ones — the console logs a warning for the loading frame, e.g. `Failed to evaluate expression: ${record.status == 'in_review'} Error: ... record is not defined`. Observed on A1 (banner correctly shown), A2 (banner correctly hidden) and B1/B2 alike, so it is not a symptom of anything being wrong. It is a permanently-misleading diagnostic on the happy path: an author debugging a page-block predicate sees 'record is not defined' on a page where the predicate is in fact working. It belongs to objectui (the evaluator/renderer at the pin), and this dispatch fences me out of that repo, so I did not file it there — routing it to you rather than minting a cross-repo card unasked.", "NOT a finding, recorded so the next reader does not re-derive it: on sys_user the sibling `page:header` action predicate for `resend_verification_email` is ALREADY authored as guarded CEL (`has(record.id) && record.id == ctx.user.id && has(record.email_verified) && record.email_verified == false`) and correctly emits the sparse-predicate warning naming `email_verified` when the key is stripped. That is the #8990-migrated action face behaving as designed, sitting on the same page as the un-migrated alert — the clearest single illustration of why option A above is the coherent direction." ] }Generated by Claude Code
Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
PM ruling — the strike is confirmed correct and stays; it is retired as an instruction and replaced
Seat
domain:engine, sessionsession_01RfyXxZ2WPjcjhuXpiQQc3y, on the measurement round of 2026-08-23 above.1. The strike was not stale. Measuring instead of reasoning is what saved this.
I dispatched this card with the explicit warning that the strike might be stale —
c86185eb5is exactly the commit that changed the physics the 2026-08-20 round measured under — and with the equally explicit instruction that it could only be lifted by a new measurement, never by that inference.The inference was wrong.
c86185eb5changed the node-levelvisibleWhengate; it did not touchproperties.visible, whose legacy JS evaluator has nohas()and never will. Executed literally at today's pin, the struck steps still fail:todotask,visible: "has(record.status) && …"visible: "has(record.id) && …"Console on both:
"has" is not a function, and the surface is fail-soft (H1), so the throw becomes a permanently-shown banner — "Email not verified" rendered directly above the page's own "Email Verified: ✓ Yes" chip.⭐ Had this card been worked by reasoning from the commit title, it would have shipped precisely the defect the strike records. That is the whole argument for the rule, and it now has a measured instance behind it rather than a principle.
2. Steps 2–3 are retired, not lifted
They stay wrong forever, because they name the wrong slot — no future change to
visibleWhencan makehas()work insideproperties.visible. Marking them "stale" would invite exactly the mechanical re-execution the strike was written to stop. They are replaced by the remedy below, which is measured green.3. The replacement, ruled — with two conditions that are measured, not stylistic
On both pages, move the predicate off
properties.visibleonto the component's declaredvisibleWhen, withhas()guards, and delete thevisiblekey in the same edit:examples/app-showcase/src/ui/pages/task-detail.page.ts:51→visibleWhen: "has(record.status) && record.status == 'in_review'"packages/platform-objects/src/pages/sys-user.page.ts:79→visibleWhen: "has(record.id) && has(record.email_verified) && record.id == ctx.user.id && record.email_verified == false"⛔ Condition (a):
visiblemust be DELETED, not left beside the new gate. E3 measured that the two gates compose as AND — leaving both would silently keep the legacy predicate load-bearing.⛔ Condition (b): the
has()guards are MANDATORY on the CEL face. G3 measured that a missing key is a fault there, and that face is fail-soft too: unguarded, a stripped key produced[runtime] No such key: statusand a VISIBLE banner. So an unguarded migration is strictly worse than today — it arms, on a surface that currently cannot have it, the very fault class this card was misfiled under.That inversion is the single most important sentence in the round: the sloppy legacy form happens to work, and the form the platform teaches everywhere else is the one that breaks — until both conditions are met together.
4. Why this is mine to rule and not the maintainer's
The direction was already ruled by the maintainer on 2026-08-20 — 「A 让 record:alert 认 visibleWhen(推荐)」. Its upstream half landed as objectui#5401 → #5505 (
c86185eb5), and this repo's.objectui-shanow contains it (verified in-run). This card is the downstream half of an existing ruling, so executing it is implementation, not adjudication. Nothing here expands an accept set or a public surface: measured, both banners are correct today and stay correct after, with genuine faults becoming loud instead of silent.I am also recording what I am not claiming: the business pull is weak, and the dev said so rather than inflating it — zero users hit this today, because the detail read projects every declared column and the legacy evaluator answers the sparse case
false, which is the right verdict anyway. This is bought for long-term soundness (removing a dialect fork) and for AI-authored-metadata safety, not for a live incident.5. On the measurement itself
Every negative reading is paired with a mount that must show (A1↔A2, E1↔E2, F1↔F2, G1↔G2, G4↔G5↔G6b, I1↔I2); every run asserts a render control from that page; interception counters prove each rewrite was live. The first G6 run failed its own control — it reused the other page's title — and was discarded and re-run as G6b rather than reported. A run that fails its control is not a result, and it was not presented as one.
Also settled on the wire: exactly 2
record:alertblocks exist across 34 served pages — this card's two — and both carryvisibleas a bare string with novisibleWhen. The "one is a{dialect:'cel'}envelope" claim stays falsified, and the card's title attribution to the #8990 absent-key fault class stays falsified too (C1/C2: bare strings answerfalse, they do not fault).Dispatching the migration as a second round on this card. The card stays open until it lands.
Generated by Claude Code
fix(platform-objects,example-showcase): move both record:alert gates …
fix(platform-objects): serve the sys_user alert gate as a CEL envelop…
claude commentedon Aug 23, 2026
{ "issue": 9167, "status": "done", "branch": "claude/issue-9167-visiblewhen-migration", "pr": "https://github.com/objectstack-ai/objectstack/pull/11254", "premise_still_valid": true, "summary": "RULED MIGRATION LANDED ON BOTH PAGES, and both ruled conditions are verifiable on the wire, not just in the source. task-detail.page.ts and sys-user.page.ts now gate their record:alert on the component-NODE `visibleWhen` with has() guards, and the `properties.visible` key is DELETED on both (condition a). Across the 34 pages this app serves, the two record:alert nodes carry exactly ['properties','type','visibleWhen'] and `properties.visible` appears on ZERO served nodes repo-wide. || ⚠️ ONE DEVIATION FROM THE RULED TEXT, forced by measurement and the reason this round was worth running in a browser: the ruled strings authored LITERALLY (as bare strings) BROKE the sys_user banner outright. `SysUserDetailPage` is a raw `Page` object literal, so nothing runs ExpressionInputSchema's transform over it and the predicate reached the wire BARE, while the showcase page (definePage) served a {dialect:'cel'} envelope. objectui routes by shape on purpose -- ExpressionEvaluator.evaluateCondition at the pin: 'Bare strings and ${...} templates stay on the legacy path (back-compat deprecation window); only an explicit { dialect: cel } envelope is rerouted' -- and that legacy evaluator has no has(), so the guard threw and the fail-soft surface showed the banner. Measured on my own first draft: B2 (self, email_verified forced true) VISIBLE (must hide) and B3 (ANOTHER USER's profile) VISIBLE (must hide) -- i.e. the gate was not being consulted at all. Isolated to the envelope alone on one page/one predicate/one rewrite path: P1 bare -> VISIBLE, P2 envelope -> hidden. FIX: the sys_user gate is authored through `P` (@objectstack/spec/shared), which emits the {dialect:'cel',source} envelope. This is condition (b) of the ruling being actually met rather than textually met -- an unguarded-in-effect CEL predicate is exactly what the ruling forbids. After the fix 8 of 8 served `visibleWhen` values are envelopes, 0 bare. The showcase page is deliberately left on definePage's normalization (that transform IS the contract, and its node measurably serves the envelope). || BEHAVIOUR FOR REAL USERS IS UNCHANGED in every polarity measured; the only difference is that a genuine fault is now loud (CEL names the missing key) instead of silently answering false.", "tests": "BUILD (all foreground, heavy steps through scripts/pm/os-verify-lock.sh, verdict lines read rather than a bare $?): `turbo run build --filter='!@objectstack/docs' --concurrency=2` -> 'Tasks: 71 successful, 71 total', VERDICT command-exit 0 (held 256s); `pnpm objectui:build` -> '✓ Bundle canary import/jobs present', '✓ Console bundle carries THIS tree's @objectstack/spec, and only it', '✓ @objectstack/console dist ready (49388 KB) from objectui@190fbd01d061', VERDICT command-exit 0 (held 323s); `pnpm --filter @objectstack/platform-objects build` after the P fix -> VERDICT command-exit 0 (held 29s). || UNIT: `pnpm --workspace-concurrency=2 --filter @objectstack/platform-objects --filter @objectstack/example-showcase run test -- --maxWorkers=2` -> both script names echoed ('packages/platform-objects test$ vitest run --passWithNoTests', 'examples/app-showcase test:'), 'Test Files 27 passed (27)' / 'Tests 432 passed (432)' and 'Test Files 25 passed (25)' / 'Tests 367 passed (367)'; VERDICT command-exit 0. Same filter `run typecheck` -> 'packages/platform-objects typecheck: Done', 'examples/app-showcase typecheck: Done'; VERDICT command-exit 0. (Filter direction stated: these are the two EDITED packages, not a downstream sweep -- the change is authored page metadata, and the only type surface it touches is ExpressionInputSchema's input arm, which platform-objects' own tsc covers.) || GATES: `node scripts/pm/dispatch-gates.mjs` run with NO paths passed, so it derived the change set itself: '3 path(s) vs merge base 824a996c0', 15 path-matched families + 1 convention-triggered (check:i18n, because platform-objects owns an i18n-extract.config.ts). All 16 run plus check:nul-bytes, exit captured BEFORE any pipe, all EXIT=0. Verdict lines the gates printed themselves: check:changeset-gate-self-tests '✓ check-empty-changeset --self-test: 118 assertions over real temp git repos'; check:cross-package-test-inputs 'OK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'; check:examples-live-imports 'examples/** live-import inventory OK -- 0 invisible (declared), 6 inputs-declared, 72 graph-visible.'; check:objectui-changeset '✓ objectui-changeset-digest --self-test: all checks passed'; check:published-files '✓ check:published-files -- 69 publishable package(s) of 78 workspace member(s) declare a `files` whitelist ...'; check:slot-lookup '✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new, and every file in the population parsed.'; check:test-source-alias 'check-test-source-alias OK -- 72 packages with tests scanned'; check:type-source-resolution EXIT=0; check-adr-0087-registration '✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).'; check-changeset-no-major '✓ This diff introduces no `major` bump.'; check-ci-filter-parity 'OK: all 88 declared cross-package glob(s) (76 unique) are covered ...'; check-empty-changeset '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).'; check-plugin-teardown-shape '✓ 63 Plugin implementation(s) across 4506 source(s) ... baseline fully burned down'; check-affected-docs '✓ affected-docs self-test: 339 cases pass.'; check:i18n 'check-i18n-bundles: OK (9 package(s) -- all bundles in sync, no undeclared authoring keys).'; check:nul-bytes 'check-nul-bytes: OK (scanned 6525 text file(s) -- 6525 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes).' || ESLINT, repo-wide and NOT a narrowing: `pnpm lint` (eslint . --no-inline-config) -> VERDICT command-exit 1, '10782 problems (89 errors, 10693 warnings)'. ATTRIBUTED BY MEASUREMENT, not by argument: parsing the log by file, ALL 89 errors sit under `.cache/objectui-190fbd01d061/` -- the objectui clone `pnpm objectui:build` creates locally, gitignored at .gitignore:42 and absent from a CI checkout -- and 0 errors sit outside it. Re-run as `pnpm exec eslint . --no-inline-config --ignore-pattern '.cache/**'` -> VERDICT command-exit 0, no output at all. || GATE UNION RAN AT THE FINAL COMMIT `3d812516a` (git status --short empty at that point; nothing was committed after the gates). || BROWSER, the bar this card sets: framework built from this branch, console rebuilt from the repo's pin, `objectstack dev --ui --seed-admin -p 39167` (48 plugins, 132 seed rows), Playwright 1.62.1 driving chromium-1194 from PLAYWRIGHT_BROWSERS_PATH via an executablePath override (playwright install NOT run). Every mutation is a network-response rewrite against the shipped bundle; nothing in the source tree was mutated at any point, so THERE IS NO ABLATION LEG AND NO REBUILD TO DECLARE -- the one source change (the P fix) was a real edit, rebuilt (`pnpm --filter @objectstack/platform-objects build`, VERDICT 0) and re-served by a restarted dev server before it was measured, and its landing was confirmed on the WIRE (sys_user_detail's visibleWhen went from a bare string to {dialect:'cel',source:...}) before any verdict was read. || AFTER-STATE: A1 task in_review -> VISIBLE (expected VISIBLE); A2 task todo -> hidden; B1 sys_user self unverified -> VISIBLE; B2 sys_user self with email_verified forced true (keysSet=15) -> hidden; B3 another user's record -> hidden. All five as expected. GUARD HOLDS AND IS LOAD-BEARING: G1 in_review with `status` STRIPPED (keysRemoved=6), landed guarded gate -> hidden; G2 same run with the gate swapped for the UNGUARDED envelope (keysRemoved=5, metaPatched=1) -> VISIBLE; G3 self with `email_verified` stripped (keysRemoved=2) -> hidden; G4 same run unguarded (keysRemoved=2) -> VISIBLE. G1<->G2 and G3<->G4 are the same page, same record, same strip -- only the has() guard differs. NO FLASH: I1 todo task polled every 150ms for 8s (52 samples) -> never visible; I2 polling control, unmodified in_review task -> hidden then VISIBLE at 1458ms, so the poller demonstrably sees banners. || CONTROLS, since a zero is not a reading: every hidden verdict is paired with a mount of the same shape that must SHOW (A1<->A2, B1<->B2<->B3, P1<->P2, G1<->G2, G3<->G4, I1<->I2, X1<->X2, X3<->X4, X5b<->X6b); every run asserts a page-render control string belonging to THAT page ('Warehouse schema', 'PII access review', 'Dev Admin', 'Ada Auditor'), and all reported runs passed theirs; interception counters are reported per run (metaPatched proves the metadata rewrite reached the target node, keysRemoved/keysSet prove the payload rewrite touched real rows) and a zero there would have invalidated the run. TWO RUNS WERE DISCARDED RATHER THAN REPORTED, both for failing their own control: X6 used the needle 'Two Factor' where the page renders 'Two-Factor Enabled', so its must-SHOW mount showed nothing and the pair proved nothing -- re-run as X5b/X6b; and X7/X8 targeted record:details with the needle 'Platform Role', which on that page belongs to record:highlights, not to the gated block, so the must-VANISH mount could not vanish. Neither is presented as a census result.", "open_questions": [ { "question": "Three census findings are objectui-side and this dispatch fences me out of that repo, so I did not mint cross-repo cards unasked (round 1's precedent on this card). Routing them to you: (1) `renderers/complex/data-table.tsx:1974` renders the `emptyAction` slot by direct registry resolution rather than through SchemaRenderer, so a `visibleWhen` on that node is never evaluated -- the ONLY authored-node bypass of the central gate in the tree; (2) `containers.tsx:449`'s page:tabs item-level `visibleWhen` is a SECOND evaluator with a wider binding (row spread flat, and `data` bound to the ROW where the node gate binds it to the data-source ADAPTER -- same key, opposite meanings) and no dev throwOnError probe, so a faulting tab predicate is silent where a faulting node predicate warns; (3) in a PRODUCTION console bundle the node gate's fault is entirely silent -- `evaluateVisibilityPredicate` short-circuits on `if (!__DEV__)`, which is why the bare-string breakage I measured produced no console line at all (round 1's `\"has\" is not a function` lines came from the other face, record-alert.tsx's useCondition path, which logs regardless).", "options": [ "A. File all three in objectstack-ai/objectui yourself (or authorize a seat that is not fenced out of it).", "B. File only (1) -- it is a concrete declared-not-enforced defect; hold (2) and (3) as observations until something depends on them.", "C. Record them on this card only and let them ride until a card touches those surfaces." ], "recommendation": "B. (1) is the same defect class objectui#5401/#5505 just closed for record:alert, one level down, and it is bounded and nameable in one line. (2) and (3) are real but nobody is currently hurt by either, and (3) in particular is a deliberate performance short-circuit whose fix is a design question (a production diagnostic budget), not a bug report." } ], "out_of_scope_findings": [ "filed as #11255: a page authored as a raw `Page` object literal serves every ExpressionInputSchema key BARE (nothing parses it), and the console silently routes bare predicates to its legacy evaluator -- the class behind the deviation above. Swept every *.page.ts in the repo: sys-user.page.ts was the ONLY live instance (fixed in this PR via `P`); sys-organization / sys-position / qa welcome are raw literals carrying zero predicates, and all 7 authored visibleWhen values under examples/** go through definePage() and normalize. Latent, invisible at authoring time (tsc green, tests green, gate farm green), and the next predicate added to a raw-literal platform page reproduces it exactly.", "filed as #11256 (labelled `finding`): PageComponentSchema.visibleWhen's describe at page.zod.ts:189 names `record`, `current_user`, `page.<var>`; the renderer binds those plus `user`, `ctx.user`, `os.user`, `app`, `features` and `data` (ExpressionProvider's ADR-0068 aliases). The platform's own ruled predicate uses `ctx.user.id`. Filed WITHOUT a prescription, because contract-first makes narrowing the renderer as legitimate an answer as widening the describe.", "NOT FILED, deliberately -- objectui-side, and this dispatch fences me out of that repo; routed to the PM as the open question above instead of minting cross-repo cards unasked: data-table.tsx:1974's emptyAction registry bypass; containers.tsx:449's second page:tabs evaluator with a divergent `data` root and no diagnostic; and the production bundle's total silence on a node-gate fault (`if (!__DEV__)`)." ] }Generated by Claude Code
Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
ACCEPT — PR #11254, enqueued. And my ruling's text was wrong; the measurement caught it.
Seat
domain:engine, sessionsession_01RfyXxZ2WPjcjhuXpiQQc3y. Out of draft, auto-merge (squash) enabled, in that order.CI read by name at
3d812516a: 34 check runs, 32success, 2skipped(Console Pin Gate,Build Docs— both conditional, neither triggered). Zero non-green, zero running.⛔ The correction that matters most: I ruled a defect into the text
My ruling specified the
sys_usergate as a bare string. Authored literally, that broke the banner outright — and it is worth being exact about how badly, because "the migration is cosmetic" was not the failure mode:sys_userself,email_verifiedforcedtrue(15 keys set)Ada Auditor)B3 is decisive: on someone else's profile
record.id == ctx.user.idis plainly false, and the banner showed anyway. The predicate was not being consulted at all.The mechanism, which I did not know and did not ask about:
showcase_task_detailis authored throughdefinePage({…}), which runsExpressionInputSchema's transform → the wire gets{"dialect":"cel","source":…}.SysUserDetailPageis a raw typedPageobject literal. Nothing runs that transform over it, so whatever is authored reaches the wire verbatim.ExpressionEvaluator.evaluateConditionat the pin: "Bare strings and${…}templates stay on the legacy path (back-compat deprecation window); only an explicit{ dialect: 'cel' }envelope is rerouted." That legacy evaluator has nohas(), and the surface is fail-soft.Isolated to the envelope alone — one page, one predicate, one rewrite path,
metaPatched=1both times: P1 bare → VISIBLE ❌; P2 envelope, same source → hidden ✅.Fixed with
P(@objectstack/spec/shared), which emits the envelope. After: 8 of 8 servedvisibleWhenvalues are envelopes, 0 bare.⭐ This is condition (b) of my own ruling being met in effect rather than in text. An unguarded-in-effect CEL predicate is exactly what the ruling forbade — my wording would have produced one. Had this been verified by test suite alone (
tscgreen, 799 tests green, 16 gates green — all true of the broken draft), my ruling would have shipped the defect this card exists to prevent.That is now the third instance on this one card of the same lesson: the 2026-08-20 round that produced the strike; my round-1 refusal to lift it on inference; and now my own ruled text failing identically. The card's title was falsified, its "one is a CEL envelope" claim was falsified, its struck steps were confirmed harmful, and its PM's remedy needed correcting — every one by measurement.
Both ruled conditions hold, verified on the wire rather than in source
Across the 34 pages served, the two
record:alertnodes carry exactly['properties','type','visibleWhen']andproperties.visibleappears on ZERO served nodes repo-wide — condition (a), the deletion, confirmed where it counts.Condition (b) measured on the landed gate, not inherited from round 1: G1 (guarded,
statusstripped) → hidden ✅ vs G2 (same run, unguarded envelope) → VISIBLE; G3 (guarded,email_verifiedstripped) → hidden ✅ vs G4 (unguarded) → VISIBLE. Same page, same record, same strip — only the guard differs. Plus no flash (I1, 52 samples over 8 s) against a live poller control (I2 sees a banner appear at 1458 ms).The census closed round 1's gap, and cleanly
⭐
visibleWhenis not a per-block concern. It is enforced once inSchemaRenderer.tsx—shouldHidetests it first, sets_hidden, andreturn nullfires before the registry dispatches. A block renderer cannot ignore the gate because it never sees the node. Cross-checked behaviourally on three untouched block types (record:path,record:highlightson both a showcase and a platform page), each with a paired must-show mount.Three exceptions found and routed, not acted on — #11258 (objectui-side: the
emptyActionregistry bypass,page:tabs' second evaluator bindingdatato the opposite thing, and the!__DEV__production silence that made the bare-string breakage invisible). Two objectstack-side findings filed by the dev: #11255 (the raw-object-literal authoring class behind the deviation — swept,sys-user.page.tswas the only live instance) and #11256 (thevisibleWhendescribe naming three roots where the renderer binds nine — filed without a prescription, because contract-first makes narrowing the renderer as legitimate an answer as widening the describe).Two runs discarded rather than reported
X6 used the needle
Two Factorwhere the page rendersTwo-Factor Enabled, so its must-show mount showed nothing and the pair proved nothing — re-run as X5b/X6b. X7/X8 targetedrecord:detailswith a needle belonging torecord:highlights, so the must-vanish mount could not vanish. Neither was presented as a census result. A run that fails its own control is not a result.Also honest: the repo-wide lint number
pnpm lintexits 1 with 89 errors — all 89 under.cache/objectui-190fbd01d061/, the objectui clonepnpm objectui:buildcreates locally, gitignored at.gitignore:42and absent from a CI checkout. Attributed by parsing the log per-file, not by argument, and demonstrated:--ignore-pattern '.cache/**'exits 0 with no output.Generated by Claude Code
Move both `record:alert` gates onto the `has()`-guarded node `visible…