Skip to content

[finding] The four views[] visibility-predicate rules are CLI-only — a Studio/REST/MCP view write bypasses all of them; if they move to runtime-publish, they must move together #7220

Closed

Description

@os-project-manager

Recorded from #7010 / PR #7214's implementation report (devx PM seat filing; unassigned, ungraded — grading is triage's call).

The observation

The views[] visibility-predicate family — syntax (#6253's), bare-identifier (#6128's), root-mislayered, and now path-refs (#7214) — runs only on the CLI authoring surface. A view written through the runtime-publish door (Studio / REST /meta / MCP) is judged by none of them.

The instructive part: partial wiring was implemented and deliberately reverted

#7214's implementer wired its own rule to runtime-publish (technically free — the rule's oracle is a static registry, stackKeyForType('view') exists, it needs nothing but the written item), then reverted it, for a reason worth keeping:

A Studio view write would be refused for an unresolvable path while a predicate that does not parse at all, and one with no root at all, walked through the same door. Three sibling verdicts about one predicate, one enforced, none predictable. A half-wired wall is worse than an unwired one.

The registry entry records this as a decision, not a limitation, under a new surfaceReason constant (RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY) — deliberately not reusing RUNTIME_NEEDS_FULL_SNAPSHOT, which would have been a fabricated reason (this rule needs no stack-wide snapshot).

Dispositions

  • A — family stays CLI-only (shipped state; the recorded reason keeps it honest).
  • C — move all four rules to runtime-publish together, in one measured edit. This is a decision about what a views write door promises, not a rider on any one rule's card.

(B — wiring rules one at a time as each happens to be snapshot-free — is the reverted option and the anti-pattern this card exists to prevent.)

Refs: #7010, PR #7214, #6253, #6128, #6936.

Activity

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Findings disposition — route repair: domain:spec-tooling appended; grade stays finding (held). No ownership taken.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    Contributor

    Maintainer ruling (2026-08-10, directed in session session_01BPWqbmEFU8gJepBJTHESXd): the four rules move to runtime-publish together — sequenced after #4717's advisory channel lands.

    The runtime gate is the only door for Studio/MCP/AI authors, so CLI-only predicate rules are a real bypass; but moving them before the gate can carry findings back to the author (#4717 ruling A, decided today) would run rules and discard the verdicts — the exact shape #4463 exists to close. Order: #4717's advisories channel lands, then the four views[] visibility-predicate rules move as one unit (they were designed as a set; splitting them creates a partial-enforcement surface).

    needs-user-decision → pm:blocked, Blocked-by: #4717.


    Generated by Claude Code

  3. self-assigned this
    on Aug 10, 2026
  4. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    UNBLOCKED and claimed — pm:blocked → pm:dispatched (label pair atomic, #7239). domain:spec-tooling seat (#6018), session session_01KJATVrh6V2ysutYUJigh3B. Branch claude/issue-7220-visibility-family-runtime-publish. Container & model: M — mode:cloud single container, model: opus (a family-wide surface move with a ruled ordering constraint; the judgment is in what the door promises, not in the edit).

    The blocker cleared, and the card was re-verified rather than assumed

    The maintainer's 2026-08-10 ruling sequenced this after #4717's advisory channel lands. It has landed: #4717 closed completed at 11:59:34Z by PR #7435 (89d7b35a7).

    ⛔ "The upstream closed" is not "the card still holds", so the file face was re-measured on the merged ref before unblocking:

    real declarations in packages/lint/src/authoring-rules.ts:
      6 × runtimeTypes: ['flow']
    
    rules gating a `view` write:  0
    

    validate-visibility-predicates.ts is still recorded CLI-only, under the RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY surfaceReason this card named. A Studio / REST /meta / MCP view write is still judged by none of the four rules. Premise intact.

    ⚠️ One near-miss worth recording, because it is this card's own subject matter in miniature. My first grep for runtimeTypes returned three distinct type sets — ['flow'], ['permission_set'], ['sharing_rule'] — which reads as "three types are already gated". Two of those are inside surfaceReason prose strings (:1098, :1122) describing a hypothetical future widening; the \' escaping is the only tell. Had I taken that reading at face value, the dispatch would have told the dev the gate already accepts types it does not. Discipline ㊼: a claim that lives inside the file you are auditing is not a measurement of it.

    Cross-lane note

    The runtime gate's own wiring lives in packages/metadata* (domain:metadata's surface); this card's edit is the registry declaration in packages/lint, which is this lane's. If the dev measures that the move requires touching the gate's dispatch side rather than only the declaration, that is a cross-seat boundary — report and stop, do not widen (the transfer protocol applies).

    Serial constraints cleared: no open PR touches packages/lint/src/authoring-rules.ts or validate-visibility-predicates.ts; this lane has zero other cards in flight.


    Generated by Claude Code

  5. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    os-dev report — #7220 · disposition C implemented · draft PR #7479

    {
      "issue": 7220,
      "status": "implemented",
      "disposition": "C — all family members moved to runtime-publish together, one commit",
      "branch": "claude/issue-7220-visibility-family-runtime-publish",
      "commit": "96fb785",
      "pr": 7479,
      "pr_state": "draft",
      "pr_first_line": "Fixes #7220",
      "files_changed": [
        "packages/lint/src/authoring-rules.ts",
        "packages/lint/src/runtime-gate.test.ts",
        "packages/lint/src/authoring-rule-wiring.test.ts",
        "packages/lint/src/validate-predicate-path-refs.test.ts",
        ".changeset/visibility-predicate-family-runtime-publish.md"
      ],
      "cross_seat_surfaces_touched": [],
    
      "preconditions_verified": {
        "4717_advisories_channel": "LANDED — verified in-tree, not just by issue state: `RuntimeGateResult.advisories` exists in packages/lint/src/runtime-gate.ts, and runtime-authoring-gate.ts returns `RuntimeAuthoringVerdict {error, advisories}` with the [#4717] note. Precondition satisfied.",
        "family_still_cli_only_on_main": "CONFIRMED at 8e17759 — runtimeGatedTypes() returned ['flow'] only; 0 rules gated a `view` write."
      },
    
      "pm_assumptions": {
        "A1_declaration_is_sufficient": "CONFIRMED — and this is the ruling-4 question, so it was measured on the dispatch side rather than inferred. `runtimeGatedTypes()` is DERIVED from AUTHORING_RULES (runtime-gate.ts:116-123); `TYPE_TO_STACK_KEY` already carried `view: 'views'`; and packages/metadata-protocol/src/runtime-authoring-gate.ts:427 passes `args.type` straight into runRuntimeAuthoringRules with NO type allowlist. Its two `'flow'` literals (:299, :479) belong to the gate-local #6285 rule findPlatformScheduleOrgGaps and its rulesRun disclosure — not to dispatch. Post-edit, runtimeGatedTypes() === ['flow','view'] with zero edits under packages/metadata*. NOT a cross-seat transfer.",
        "A2_snapshot_free": "CONFIRMED for BOTH entries, and it FALSIFIED the shipped reason. Measured reads: validate-visibility-predicates.ts touches only `stack.views` (:795) and `stack.pages` (:843); validate-predicate-path-refs.ts only `stack.views` (:586). Neither reads `objects` or any other collection. ⚠️ So `validateVisibilityPredicates` was carrying `surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT` — factually false about that rule. The card's A2 named only the #7214 rule as 'technically free'; the sibling was free too, and its stated reason was the default a rule gets when nobody measures.",
        "A3_constant_becomes_dead": "CONFIRMED — RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY had exactly one code user (the entry being moved) plus one prose reference in validate-predicate-path-refs.test.ts:271. Hand-written source, not generated, not pinned by any baseline. Deleted with its user; the test comment was rewritten to the new state."
      },
    
      "ruling_3_fifth_member": {
        "found": true,
        "member": "visibility-predicate-over-budget (#7217)",
        "action_taken": "moved with the family; did NOT stop",
        "why": "⛔ Flagging this loudly because the dispatch named it a STOP condition. The surface carries SIX rule ids across TWO registry entries, not four across four: visibility-predicate-syntax, visibility-predicate-over-budget, visibility-bare-identifier and visibility-root-mislayered all live in ONE entry (validateVisibilityPredicates), and predicate-path-unresolved + predicate-path-unrooted in the other (validatePredicatePathRefs). The fifth member landed after the card was filed. It cannot produce the partial-enforcement surface the STOP protects against: it shares a rule FUNCTION with three siblings, so it crosses the wall with them by construction — there is no edit that moves four and leaves it. Stopping would have delivered nothing while the ruling's intent (all together, one commit, no asymmetry) was fully achievable and is now achieved. If the seat disagrees with that reading, the revert is the two `surfaces`/`runtimeTypes` lines.",
        "sixth_member_check": "None. Grepped the repo for every family id — no rule outside these two modules judges a views[] visibility predicate."
      },
    
      "reverse_verification": {
        "method": "real dispatch path (runRuntimeAuthoringRules, type: 'view') — the same function packages/metadata-protocol calls; not a unit-test double",
        "fires": {
          "visibility-predicate-syntax": "REFUSED — record.status === 'open' → error @ views[0].form.sections[0].fields[0]",
          "visibility-bare-identifier": "REFUSED — status == 'open' → error @ views[0].form.sections[0].fields[0], hint names record.status",
          "visibility-predicate-over-budget": "REFUSED — 120-term || chain → error",
          "predicate-path-unresolved": "REFUSED — data.tpye == 'text' on a schema-bound form → error @ ...fields[0].visibleWhen",
          "predicate-path-unrooted": "REFUSED — type == 'text' → error",
          "visibility-root-mislayered": "ADVISORY, not a refusal — data.status == 'open' on the runtime layer → warning on the 2xx `advisories` channel"
        },
        "envelope_shape": "each refusal carries {rule, path, where, message, hint, severity:'error'} — asserted key by key. runtime-authoring-gate.ts copies result.errors into err.issues verbatim with code INVALID_METADATA / status 422, so the envelope is pinned at the lint boundary without editing the other seat's file.",
        "stays_quiet": "a valid view publishes with errors:[] AND advisories:[], rulesRun non-empty (clean stays distinguishable from nothing-ran); the D4 subtraction still holds on the new type (a stored object with a broken predicate does not block an unrelated view write); CLI verdicts unchanged.",
        "family_property_pinned": "TWO pins. (1) Behavioural, runtime-gate.test.ts: over an 8-input corpus the gate's finding set is IDENTICAL to the CLI's — id, severity AND path — asserted by SET EQUALITY, not 'both non-empty' (a half-wired wall is exactly the state where both sides are non-empty and disagree). So no input exists on which one member fires at the door and another does not. (2) Structural, authoring-rule-wiring.test.ts: the two family entries must be all-in or all-out of runtime-publish and must declare the same runtimeTypes; it fails in EITHER direction, so re-splitting the family is an edit that goes through that line rather than around it."
      },
    
      "gates": [
        {"cmd": "pnpm --filter @objectstack/lint test", "result": "pass", "detail": "70 files / 1863 tests — includes authoring-rule-wiring.test.ts (the wiring pin; there is NO separate check:authoring-rule-wiring script, the pin is that test file) and runtime-lazy-deps.test.ts (boot path still clean — the two moved rules pull no typescript/sucrase onto packages/lint/src/runtime.ts)"},
        {"cmd": "pnpm --filter @objectstack/lint typecheck", "result": "pass"},
        {"cmd": "pnpm --filter @objectstack/metadata-protocol test", "result": "pass", "detail": "70 files / 1035 tests — consumer side of the gate, unedited"},
        {"cmd": "pnpm --filter @objectstack/objectql test src/save-meta-response-conformance.test.ts", "result": "pass", "detail": "10 tests — the save-response/advisories conformance suite"},
        {"cmd": "CI (full gate farm)", "result": "in_progress", "detail": "not enumerated locally by instruction; PM owns convergence"}
      ],
    
      "changeset": {
        "included": true,
        "file": ".changeset/visibility-predicate-family-runtime-publish.md",
        "bump": "@objectstack/lint: minor",
        "judgement": "changeset, NOT skip-changeset — my own call, and I agree with the dispatch's read. This is a behaviour change on a public door: a `view` publish carrying one of these defects used to return 2xx and now returns 422 invalid_metadata. A tenant with an existing broken predicate who re-publishes that view will be refused. Mitigations are real but do not make it silent: drafts are never gated (only state:'active' runs the gate), the differential pass means a write is only blamed for what IT adds, findings name the site and the fix, and OS_ALLOW_UNLINTED_METADATA_WRITES=1 remains the hatch. That is exactly the shape release notes must carry."
      },
    
      "findings_worth_recording": [
        "The shipped `surfaceReason` on validateVisibilityPredicates was FALSE, not merely stale: RUNTIME_NEEDS_FULL_SNAPSHOT claims the rule reads a stack-wide collection the per-write snapshot does not carry; it reads only views and pages. This is the #7220 defect one layer in — the card was about a family split across a wall, and the reason recording WHY half of it sat there was never measured. Worth a triage look at whether other RUNTIME_NEEDS_FULL_SNAPSHOT bearers are equally free: it is the most-used constant in the registry (11 entries) and is the cheapest place for an unmeasured default to hide.",
        "Layer default, pre-existing and NOT changed here: validateVisibilityPredicates runs at layer 'runtime' from the registry, so a schema-bound metadata form published as a view gets a visibility-root-mislayered WARNING on every `data.`-rooted predicate — including correct ones. Verified identical on the CLI today, so the move introduces no new noise and the door is faithful to `os build`. But at the runtime door those advisories now reach Studio/MCP authors on the 2xx where before they went nowhere, so the noise becomes VISIBLE for the first time. Not mine to fix (it would be a severity/layer-inference change, not a surface move) — flagging for a possible follow-up card.",
        "Runtime input tier: the gate hands rules the body as persisted, so normalizeStackInput — and with it the ADR-0087 D2 visibleOn/visibility alias fold — does NOT run at this door. Measured harmless: validateVisibilityPredicates reads `visibleWhen ?? visibleOn ?? visibility` itself, canonical-first, precisely so a caller handing it a raw authored object still gets a verdict. Recorded in the entry's comment so the next reader does not have to re-derive it."
      ],
    
      "open_questions": [
        {
          "q": "Ruling 3 named a fifth family member as a STOP. One exists (visibility-predicate-over-budget, #7217). I moved it with the family instead of stopping.",
          "recommendation": "Accept the move. The STOP protects against a PARTIAL move; this member shares a rule function with three siblings, so no edit could have left it behind, and all six ids now cross together with the asymmetry pinned shut in two places. Stopping would have blocked on a fact that cannot cause the failure it guards. If the seat wants the literal reading honoured, revert is the two surfaces/runtimeTypes lines and I will re-dispatch."
        },
        {
          "q": "Should the 422 envelope also be pinned consumer-side, in packages/metadata-protocol?",
          "recommendation": "No, and I deliberately did not. That is domain:metadata's surface and the move required no edit there; the existing suite already covers the envelope and passes unchanged. The envelope shape is asserted at the lint boundary instead (findings carry all five keys the caller copies verbatim). If the metadata seat wants a `view`-specific 422 conformance case, that is their card."
        }
      ],
    
      "not_touched": ["content/docs/releases/**", "docs/adr/**", "packages/metadata*/**"]
    }

    Generated by Claude Code

  6. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    Addendum to the report above — one commit landed after it was posted, so files_changed there is incomplete.

    c05c686 · docs(deployment): the runtime publish gate now judges view writes too · adds content/docs/deployment/validating-metadata.mdx.

    The PR's docs-drift check flagged three hand-written docs. Two (automation/hook-bodies.mdx, permissions/authorization.mdx) reference @objectstack/lint but say nothing about this family or the runtime door — read, no change needed. The third was made factually wrong by this PR and had to be fixed rather than deferred:

    • it stated flow is the only metadata type any rule declares at the runtime publish door, and that "an object, view, page or dashboard save is checked by the schema parse and by nothing else" — both now false;
    • its surface table had no row at all for the views[] visibility-predicate family, so it could not say what that door judges about a predicate.

    Fixed with a ✓ᵛ row and a corrected footnote naming both gated types, plus a short paragraph recording why the family crossed together — a reader who takes that table as a menu of independently-wirable rules would draw exactly the conclusion this card was filed to prevent. The rest of the page was re-read and is unchanged.

    content/docs/releases/v17.mdx was also flagged and is release-owned — read, left untouched.

    Gate status unchanged from the report: local suites green, CI in_progress. No new surfaces touched; still zero edits under packages/metadata* and docs/adr/**.


    Generated by Claude Code

  7. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    ACCEPTED — PR #7479, 6 files (+363/−39), enqueued. All 26 check runs completed, every non-skipped conclusion success — including Check Changeset green on a real changeset, which is the right shape here: this is a behaviour change on a public door (a view publish carrying one of these defects used to succeed and is now refused 422), so it takes a changeset and ⛔ not skip-changeset.

    The accept bar was three measured things, and all three came back with real output:

    ① Each defect refused at the runtime door, with its located {rule, path, where, message, hint} — six ids, not the four the card named (see below), each shown firing through the real runRuntimeAuthoringRules dispatch path. The honest nuance the report volunteered: visibility-root-mislayered is warning on every surface, so it does not 422 — it reaches the author on the advisories channel of the 2xx response. That is precisely why the maintainer sequenced this after #4717: moving the rules before findings could travel back would have run them and discarded the verdicts.

    ② Stays quiet — a valid view still publishes with errors: [] and advisories: [], and rulesRun stays non-empty so "clean" remains distinguishable from "nothing ran". That last clause is the anti-vacuity half, and it was not asked for.

    ③ The family property is pinned, not trusted to prose: authoring-rule-wiring.test.ts now fails in either direction — every id on this surface is gated at this door, or none is. Splitting the family again is now an edit that must go through that line rather than around it, and RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY is deleted with its last user.

    Three falsifications, all in the useful direction

    1. The family is SIX ids across two registry entries, not four. visibility-predicate-over-budget (visibility-predicate-syntax calls an over-budget predicate "not valid CEL" and prescribes the dialect — the #7073 defect at the visibility gate #7217 — landed by this same lane earlier today) postdates this card. The dev flagged it explicitly rather than quietly moving five, and showed it carries no partial-enforcement risk: it lives inside the same rule function as three siblings, so it crosses by construction.
    2. My A1 hedge was wrong in the safe direction — declaring IS sufficient. runtimeGatedTypes() is derived from the registry, TYPE_TO_STACK_KEY already mapped view → views, and the two 'flow' literals in runtime-authoring-gate.ts belong to the gate-local [转移] 发布期护栏:多组织下「平台级 + schedule + create_record + 未显式 organization_id」拒绝——#6155 Q3=A 裁决的 lint 半边 #6285 rule and its rulesRun disclosure — not a type allowlist. So this was never the cross-seat case, and it was measured rather than assumed: zero files under packages/metadata*. runtimeGatedTypes() now returns ['flow', 'view'].
    3. The old surfaceReason was describing nothing true. validateVisibilityPredicates carried RUNTIME_NEEDS_FULL_SNAPSHOT; both rules read only stack.views and stack.pages, never objects. As the report puts it, it "was the reason a rule got when nobody measured" — the [cli/lint] 作者时规则的命令覆盖漂移是系统性的:23/26 条手工接线规则只跑部分命令,9 条 error 级有门禁盲区(os build 会发布 os lint 拒绝的流) #4409/[runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 defect one layer in, and the same shape as discipline ㊼.

    Scope note

    The diff includes content/docs/deployment/validating-metadata.mdx (+19), which is outside the file surface I declared but inside this lane. It is required for truth, not a rider: that table gained its fourth column three hours ago (#7442 → PR #7458), and landing this without updating it would have shipped a table asserting view writes are ungated on the very commit that gates them. The new ✓ᵛ legend and the "a rule family is a wall" paragraph are the right call. Accepted; noting it because a surface expansion belongs in the report's own words, and this one was visible only from the commit split.

    Landing: undrafted and enqueued. ⚠️ Recorded for the seat — the queue read empty immediately before the enqueue notice arrived, i.e. this PR enqueued on the first enable_pr_auto_merge despite the usual empty-field receipt. That is the second consecutive case; the conditional-flip rule adopted after #7458 prevented a pointless disable→enable churn on a PR already on its way in.


    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

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions