Skip to content

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

@os-zhuang

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 a todo task 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.

⚠️ Both are bare strings. An earlier PM note claimed 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:

  1. A record:alert block 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.
  2. 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:alert surface 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 __search companion objectql withholds by rule #7642); and the forced-sparse case does not fault — it evaluates to false and 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

  1. Measure the page-block predicate binding and the renderer's fault direction, on both the platform sys_user page and the showcase task-detail page. — ✅ DONE 2026-08-20.
  2. If the binding can be sparse, guard both predicates per the rule on materializeDeclaredFields in @objectstack/objectql — has() alone for these two. ⛔ STRUCK — measured harmful. has() throws on the legacy evaluator these predicates actually run on, and on a fail-soft surface a throw means VISIBLE.
  3. 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

  1. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    ContributorAuthor

    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) evaluates
    its gate as useCondition(toPredicateInput(props.visible), { record }) — without
    throwOnError. That is the fail-soft posture on both paths, and ActionEngine documents
    why in its own comment for the opposite choice:

    throwOnError: true is required for fail-closed semantics: the evaluator's default
    behavior swallows expression errors and returns the raw template string, which
    Boolean(string) then coerces to true … It is equally load-bearing on the CEL path,
    where the canonical helper fails SOFT to visible/enabled unless the caller opts in.

    So objectui#4807's reading is right, and it is the opposite of the action face:
    a faulting record:alert predicate shows the banner to everyone. On sys_user that
    is "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:

    GET /api/v1/data/showcase_field_zoo            (no $select — the detail read)
      Specimen — Full     59 keys returned, 15 of them holding null
      Specimen — Minimal  59 keys returned, 37 of them holding null
    
    GET /api/v1/data/showcase_field_zoo?$select=<the 5 default-list columns>
      both rows           exactly 5 keys returned — nothing else, not even id
    

    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:alert only 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 record is 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 the
    built artifact — of 114 record-scoped visible/disabled values in
    examples/app-showcase/dist/objectstack.json, 113 are {dialect:'cel', source}
    envelopes and exactly one is a bare string
    :

    BARE STRING at pages/15/regions/0/components/1/properties
       visible = "record.status == 'in_review'"      ← task-detail.page.ts:51, this card
    

    toPredicateInput keeps a cel envelope routed to @objectstack/formula, but wraps a
    bare string as ${…} for objectui's legacy JS evaluator — and that evaluator has no
    has(). Run directly against SafeExpressionParser at the pinned objectui sha
    (665661ab):

    record.status == "in_review"                          => true
    has(record.status) && record.status == "in_review"    => THREW TypeError: "has" is not a function
    record.email_verified == false                        => true
    has(record.email_verified) && record.email_verified == false
                                                          => THREW TypeError: "has" is not a function
    

    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()":

    • either the page-block visible must be normalized into a {dialect:'cel'} envelope at
      build time, the way defineAction already does — after which has() works and the
      action-face guard applies unchanged;
    • or the record:alert gate must opt into throwOnError and choose a deliberate
      direction, which is a behaviour change objectui owns;
    • or both, in that order.

    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.

    ⚠️ One limit on the above: this session had no browser tooling, so §1's direction is
    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

  2. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    ContributorAuthor

    First-touch grading (triage seat, 2026-08-17): finding → pm:on-hold, type Task.

    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-block visible into a {dialect:'cel'} envelope at build time, or an objectui-side throwOnError opt-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 ($select on record-detail surfaces).


    Generated by Claude Code

  3. added theissue type on Aug 17, 2026
  4. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    ContributorAuthor

    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:alert face (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

  5. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    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:

    ⛔ The follow-up here … must not start before that lands and the objectui pin advances.

    Both halves measured:

    half reading
    blocker objectstack-ai/objectui#5401 lands CLOSED 2026-08-22T12:05:11Z — closed as satisfied by objectui#5505, whose binding commit is c86185eb5 "Bind record into node-level visibleWhen, and stop visible swallowing it", merged 2026-08-21 08:09:49Z
    the objectui pin advances .objectui-sha on origin/main = 190fbd01d0615e2e168faf9e08b8ad7844bc039d (2026-08-22 20:42:50Z). git merge-base --is-ancestor c86185eb5 190fbd01d → true — the pin contains the binding

    The ancestry was measured, not inferred from dates (the local checkout was shallow and answered "not a valid object" first; re-run at --depth=800 before the test).

    ⚠️ Read this before acting on the struck steps

    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 visibleWhen bound no record at all, so a record.* predicate could not resolve and fail-softed to visible. c86185eb5 is 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 authored record.* 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

  6. self-assigned this
    on Aug 23, 2026
  7. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Claim — dispatched as a measurement, not as an implementation

    Seat domain:engine · session session_01RfyXxZ2WPjcjhuXpiQQc3y · branch claude/issue-9167-record-alert-predicate-measurement

    Why 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-sha on origin/main is 190fbd01d0615e2e168faf9e08b8ad7844bc039d, with git 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/issues is 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:

    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

    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.

    ⚠️ And here is the subtlety that makes this card dangerous to hand to anyone mechanical: that measurement was taken under pre-binding physics. At the time, node-level visibleWhen bound no record at all, so a record.* predicate could not resolve and fail-softed to visible. c86185eb5 is 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

    1. The card's own title is falsified. "same sparse-face fault class as 34 authored action visible/disabled predicates 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 is undefined and undefined == 'in_review' is plain false — not a fault. The absent-key fault class does not reach this surface. What was found is real; what it was named is wrong.
    2. They are one case, not two. An earlier PM note claimed 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 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:79 and examples/app-showcase/src/ui/pages/task-detail.page.ts:51 are 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

  8. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Housekeeping note — there are two branches named for this card. Only one is live.

    ref tip what it is
    claude/issue-9167-record-alert-predicate-measurement 2810695225e0 — 0 commits ahead of main The live one. My dev's first-minute write-route probe branch; empty by design until it pushes.
    claude/issue-9167-record-alert-fault-direction 2d3860df9aad — tip dated 2026-08-20 Stale, no #9167 work on it.

    I 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 are chore: 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 the has() guards harmful and, correctly, never committed a change.

    ⇒ No prior uncommitted work for the current dispatch to collide with. (5904 commits ahead and no 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=800 to measure the pin ancestry.)

    ⚠️ I cannot delete the stale branch: this environment's proxy refuses ref deletion — git push --delete reports Everything up-to-date without acting, and REST DELETE /git/refs returns 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_requests with the card number (repo-scoped; ⛔ REST /search/issues is proxy-blocked and returns a silent zero, which is worse than not checking), and
    • git ls-remote origin 'refs/heads/*issue-<n>*', with any hit inspected for real commits rather than assumed stale.

    Generated by Claude Code

  9. added a commit that references this issue on Aug 23, 2026
  10. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Measurement round, 2026-08-23 — real console at objectui pin 190fbd01d. No source change.

    Dev seat domain:engine, session session_01RfyXxZ2WPjcjhuXpiQQc3y, branch claude/issue-9167-record-alert-predicate-measurement. Zero lines written: git status --short empty, git diff origin/main...HEAD empty, one empty commit b08d89b59. 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

    1. ⛔ The strike on steps 2–3 is NOT stale. It reproduces exactly at today's pin. Applying a has() guard in place to properties.visible still turns a correctly-hidden banner into a permanently-visible one, on both pages, with "has" is not a function in 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.
    2. ⭐ record:alert now honours the node-level visibleWhen gate — 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.
    3. ⭐ Node-level visibleWhen binds record and ctx.user, polarity-correct on both pages (F1/F2, G4/G5/G6b) — c86185eb5 observed working through the vendored bundle, not inferred from its title.
    4. ✅ The follow-up this card actually wants is now measured SAFE — but only in the shape "move the predicate to visibleWhen (CEL) and remove it from properties.visible", with has() guards. Not applied: that is the PM's ruling, not mine.

    Environment

    what value
    framework origin/main @ 281069522, dedicated worktree, turbo build → Tasks: 71 successful, 71 total
    console bundle rebuilt from the repo's pin: ✓ @objectstack/console dist ready (49388 KB) from objectui@190fbd01d061, with ✓ Bundle canary 'import/jobs' present and ✓ Console bundle carries THIS tree's @objectstack/spec, and only it
    pin ancestry verified in this run: c86185eb5237a79d… "Bind record into node-level visibleWhen, and stop visible swallowing it (#5505)", Fri Aug 21 08:09:49 2026 — git merge-base --is-ancestor c86185eb5 190fbd01d → true. The clone is shallow and answers Not a valid object name first; re-run after git fetch --depth=400.
    server objectstack dev --ui --seed-admin -p 39167, own sqlite file, 48 plugins, 132 seed rows
    browser Playwright 1.62.1 driving chromium-1194 from PLAYWRIGHT_BROWSERS_PATH via an executablePath override (the bundled 1.62 expects build 1234; ⛔ playwright install was 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:

    object declared fields keys returned only absent declared field nulls materialised as present keys
    showcase_task 25 25 __search 9
    sys_user 26 25 __search 9

    Both 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 2 record:alert blocks exist, this card's two, and both carry visible as a bare string with no visibleWhen — showcase_task_detail at regions[0].components[1], sys_user_detail at slots.alerts[0]. Identical on every dimension tested; the "one is a {dialect:'cel'} envelope" claim stays falsified. Separately, all 6 authored visibleWhen values served are {dialect:'cel'} envelopes and 0 are bare — an author writing a bare string into visibleWhen gets 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 / keysRemoved are interception counters proving the rewrite was live.

    Baselines — both banners are correct today (unmodified)

    run page / row verdict expected
    A1 task in_review VISIBLE VISIBLE ✅
    A2 task todo hidden hidden ✅
    B1 sys_user self, email_verified=false VISIBLE VISIBLE ✅
    B2 sys_user self, email_verified forced true (15 keys set) hidden hidden ✅

    Fault direction on properties.visible (question 2) — fail-SOFT, observed

    run probe verdict
    H1 visible: "MARKER9167NOSUCHIDENT == 1" on a todo task VISIBLE — console: Failed to evaluate expression: ${MARKER9167NOSUCHIDENT == 1} … is not defined

    objectui#4807's reading — 「fail-soft 使作者门控的横幅永远显示」 — holds at this pin.

    Forced-sparse, unguarded — still false, still hidden

    run probe verdict
    C1 task in_review, status stripped from the read (6 keys removed) hidden ✅
    C2 sys_user self, email_verified stripped (2 keys removed) hidden ✅

    Bare strings run on the legacy JS evaluator, where a missing key is undefined and undefined == 'in_review' is plain false. 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

    run probe verdict expected
    D1 todo task, visible: "has(record.status) && record.status == 'in_review'" VISIBLE hidden ❌
    D2 verified user, visible: "has(record.id) && has(record.email_verified) && …" VISIBLE hidden ❌

    Console on both: Failed to evaluate expression … "has" is not a function. Fail-soft turns that throw into a shown banner. c86185eb5 changed the node gate; it did not touch properties.visible.

    ⭐ The node-level visibleWhen gate — inert on 2026-08-20, live now

    run probe verdict reads as
    E1 in_review task (baseline VISIBLE) + visibleWhen: {cel,'false'} hidden the gate is honoured
    E2 todo task, visible dropped + visibleWhen: {cel,'true'} VISIBLE paired mount: the gate can SHOW, so E1 is the gate and not a dead block
    F1 todo, visible dropped + visibleWhen: {cel,"record.status == 'in_review'"} hidden record binds
    F2 in_review, same VISIBLE polarity-correct pair
    E3 todo, visibleWhen: {cel,'true'} and the authored visible left in place hidden the two gates compose as AND

    E3 is the operationally important one: a migration must remove properties.visible, not just add visibleWhen beside it.

    has() on the visibleWhen/CEL face — works, and is load-bearing

    run probe verdict
    G1 in_review, visibleWhen: has(record.status) && record.status == 'in_review' VISIBLE ✅
    G2 same + status stripped (5 keys removed) hidden ✅ — the guard holds
    G3 same but unguarded + status stripped (4 keys removed) VISIBLE — [object-ui] A conditional predicate failed to evaluate and was treated as its safe default (true) … Reason: [runtime] No such key: status
    G4 verified user, guarded visibleWhen, visible dropped hidden ✅
    G5 unverified self, same predicate VISIBLE ✅
    G6b another user's record, same predicate (control: that page's own title) hidden ✅
    I1 todo, guarded visibleWhen only, polled every 150 ms for 8 s never visible — no flash
    I2 polling control: unmodified in_review page hidden → VISIBLE at 1414 ms — the poller does see banners

    G3 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 visibleWhen without has() re-arms exactly the bug this card feared, on a surface that previously could not have it. G4/G5/G6b also show ctx.user binds there (a fault on that half would have fail-softed G4 and G6b to VISIBLE; both are hidden).

    Controls, stated

    • Every negative reading is paired: A1↔A2, B1↔B2, E1↔E2, F1↔F2, G1↔G2, G4↔G5↔G6b, I1↔I2.
    • Every run asserts a page-render control string from that page. The first G6 run failed its control (it reused the other page's title) and was discarded and re-run as G6b rather than reported.
    • Interception counters per run: metaPatched=1 proves the metadata rewrite reached the record:alert node; keysRemoved/keysSet prove the payload rewrite touched real rows. A zero there would have invalidated the run.
    • Banner detection is [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 no has() and never will. What changed is that a different remedy became available and is now measured green:

    On both pages, move the predicate off properties.visible onto the component's declared visibleWhen, adding has() guards, and delete the visible key 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"
    • Real business need — weak, and I will not inflate it. Zero users hit this today; the detail read projects every declared column and the legacy evaluator answers the sparse case false, which is the correct verdict anyway.
    • Long-term soundness — strongest axis. Removes a dialect fork instead of adding one, and is the downstream half of the maintainer's 2026-08-20 ruling 「A 让 record:alert 认 visibleWhen(推荐)」, whose upstream half has landed and whose pin this repo now carries.
    • Hard to get wrong for AI-written metadata — decisive, and it runs backwards today. The sloppy bare visible happens 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).
    • Startup scope — cheap and bounded. Two authored lines; the wire census says the whole authored surface is exactly these 2 blocks.

    ⚠️ Two conditions, measured rather than assumed: (a) the visible key must be deleted, not left beside the new gate (E3); (b) the has() guards are mandatory on the CEL face — unguarded there is strictly worse than today (G3).

    Limits of this run

    • Measured as the seeded platform admin, on one read shape, on the two pages this card names. No block-registry census (that was objectui#5401's deliverable).
    • email_verified=true could 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.
    • Read from source, used only to explain observations and never as evidence for them: record-alert.tsx, predicateInput.ts, and the header of record-alert.visibleWhen.evidence.test.tsx at the pin.

    Generated by Claude Code


    Generated by Claude Code

  11. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor
    {
      "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

  12. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    PM ruling — the strike is confirmed correct and stays; it is retired as an instruction and replaced

    Seat domain:engine, session session_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 — c86185eb5 is 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. c86185eb5 changed the node-level visibleWhen gate; it did not touch properties.visible, whose legacy JS evaluator has no has() and never will. Executed literally at today's pin, the struck steps still fail:

    run probe verdict expected
    D1 todo task, visible: "has(record.status) && …" VISIBLE hidden ❌
    D2 verified user, visible: "has(record.id) && …" VISIBLE hidden ❌

    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 visibleWhen can make has() work inside properties.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.visible onto the component's declared visibleWhen, with has() guards, and delete the visible key 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): visible must 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: status and 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-sha now 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:alert blocks exist across 34 served pages — this card's two — and both carry visible as a bare string with no visibleWhen. 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 answer false, 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

  13. claude commented on Aug 23, 2026

    @claude
    Contributor
    {
      "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

  14. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT — PR #11254, enqueued. And my ruling's text was wrong; the measurement caught it.

    Seat domain:engine, session session_01RfyXxZ2WPjcjhuXpiQQc3y. Out of draft, auto-merge (squash) enabled, in that order.

    CI read by name at 3d812516a: 34 check runs, 32 success, 2 skipped (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_user gate 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:

    run probe verdict expected
    B2 sys_user self, email_verified forced true (15 keys set) VISIBLE hidden ❌
    B3 another user's record (control: that page's own title, Ada Auditor) VISIBLE hidden ❌

    B3 is decisive: on someone else's profile record.id == ctx.user.id is 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_detail is authored through definePage({…}), which runs ExpressionInputSchema's transform → the wire gets {"dialect":"cel","source":…}.
    • SysUserDetailPage is a raw typed Page object literal. Nothing runs that transform over it, so whatever is authored reaches the wire verbatim.
    • 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." That legacy evaluator has no has(), and the surface is fail-soft.

    Isolated to the envelope alone — one page, one predicate, one rewrite path, metaPatched=1 both times: P1 bare → VISIBLE ❌; P2 envelope, same source → hidden ✅.

    Fixed with P (@objectstack/spec/shared), which emits the envelope. After: 8 of 8 served visibleWhen values 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 (tsc green, 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:alert nodes carry exactly ['properties','type','visibleWhen'] and properties.visible appears 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, status stripped) → hidden ✅ vs G2 (same run, unguarded envelope) → VISIBLE; G3 (guarded, email_verified stripped) → 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

    ⭐ visibleWhen is not a per-block concern. It is enforced once in SchemaRenderer.tsx — shouldHide tests it first, sets _hidden, and return null fires 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:highlights on 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 emptyAction registry bypass, page:tabs' second evaluator binding data to 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.ts was the only live instance) and #11256 (the visibleWhen describe 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 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. X7/X8 targeted record:details with a needle belonging to record: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 lint exits 1 with 89 errors — all 89 under .cache/objectui-190fbd01d061/, the objectui clone pnpm objectui:build creates locally, gitignored at .gitignore:42 and 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions