Skip to content

[finding] Two publish-time rules give an author contradictory fixes for the same bare unquoted word — visibility-bare-identifier (error) says data.active, predicate-rhs-path-shaped (warning) says 'active' #7696

Description

@os-help

Surfaced by the dev implementing #7659 (PR #7691), disclosed in that PR's body rather than papered over, and pinned in runtime-gate.test.ts so the interaction is recorded in the suite rather than only in prose. Filed separately because reconciling two rules' messages is not a rider on the PR that introduced one of them.

Filed unassigned, observation class. ⛔ Grading is the triage seat's single channel.

The fact

For one token — a bare unquoted word on the right of ==, e.g. status == active — two publish-time rules both fire, and they prescribe different fixes:

rule severity reads the token as prescribes
visibility-bare-identifier error a dropped binding root data.active
predicate-rhs-path-shaped (#7659, PR #7691) warning a literal missing its quotes 'active'

Both statements are true about the token. They are not true about the same intent, and nothing in either message tells the author which intent the linter thinks they had — because neither rule can know.

Why this is worth a card rather than a shrug

The two fixes are not merely different, they are semantically opposite: one turns the token into a path lookup, the other into a string literal. An author following the error (which is louder, and blocks) when they meant a literal gets a predicate that compares against data.active — a path that probably does not resolve, so #7214's predicate-path-unresolved then fires and the author is walked around a triangle.

⚠️ Note also that the bare-word spelling works today by accident — that is exactly why #7659 landed it as warning rather than error, per objectui#4049's ruling ("it would flip data.type == text, the unquoted-string spelling that works today by accident, into a fail-open true"). So the author is being told, at error severity, to change something that currently works, in a direction that may break it.

What is NOT being claimed

Shapes a fix could take (none obviously right — hence a finding)

  1. Suppress one when the other fires. Cheapest. Requires deciding which reading wins for a bare word on an RHS, which is the actual open question.
  2. Merge the two messages for this one position — a single finding naming both spellings and letting the author choose. Keeps both diagnoses, costs a special case in two rules.
  3. Accept it and make the messages cross-reference each other, so an author seeing both is told they are two readings of one ambiguity rather than two independent errors.
  4. Accept as-is. Defensible: both fire, the author reads both, and the pin stops it drifting. Weakest, because the error is the one that blocks and it is the one asserting the less likely intent for an RHS position.

Pointers

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage (first grade): HOLD — stays finding.

    Why held rather than promoted: the interaction is pinned (runtime-gate.test.ts, per the card) so it cannot drift silently; nothing breaks at runtime; and the card itself is honest that none of the three fix shapes is obviously right — the real open question ("which reading wins for a bare RHS word") is precisely what a queued card would have to have answered to be dispatchable. Promoting it now would hand a dev an unscoped decision, not a fix.

    Why not needs-user-decision: this is diagnostics-UX inside the spec-tooling lane, below the escalation bar (no public-contract or product-semantics fork; the #7659 severity split already carries its ruling from objectui#4049 and the card explicitly does not reopen it).

    Premise re-checked this round on origin/main @ a3c0865: both rules exist and fire independently; nothing has landed that reconciles the messages. Re-check trigger for the lane: the next card that touches either rule's message text should reconcile the pair in the same PR (shapes 1-3 on this card are the menu).

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


    Generated by Claude Code

  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage round, 2026-08-11 (PM session, maintainer-directed: 「跑一轮集中定级」).

    Graded: promote. Two publish-time rules prescribing opposite fixes for one token is direct author harm (an AI author will oscillate between the two prescriptions). The interaction is already pinned in runtime-gate.test.ts, so the reconciliation has a test anchor to land on. S-M.

    finding → pm:queue (domain:spec-tooling).


    Generated by Claude Code

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

    @os-help
    CollaboratorAuthor

    Claim: domain:spec-tooling seat (#6018) · session session_01KJATVrh6V2ysutYUJigh3B · branch claude/issue-7696-reconcile-bare-word-prescriptions · round 16 · model opus · mode:cloud

    pm:queue → pm:dispatched as one write, read back. (First claim on this seat using the Claim: spelling ruled in #7553 — the previous **Claimed by …** form is unreadable to patrol predicate H2.)

    What must be decided, and the default to beat

    ⚠️ Triage promoted this without picking among the card's four options, and the card itself says none is obviously right. So the choice is the dev's — make it, argue it, and do not silently pick the cheapest.

    Default to beat: option 2 — one merged finding for this position naming both sanctioned spellings and letting the author choose. Reason it is the default: options 1 and 3 both require settling which reading wins for a bare word on an RHS, and that is the open question itself, not a step toward answering it. Option 2 delivers a correct message without pretending to know the author's intent.

    ⛔ Option 4 (accept as-is) is not available — it would make this an empty dispatch, and the card was promoted precisely because oscillating prescriptions are direct author harm.

    If you deviate, say which reading you concluded wins and on what evidence.

    The facts, restated so this comment stands alone

    For status == active, two publish-time rules both fire:

    rule severity reads it as prescribes
    visibility-bare-identifier error a dropped binding root data.active
    predicate-rhs-path-shaped warning a literal missing quotes 'active'

    Both true about the token, opposite about the intent, and the louder one blocks. ⚠️ Note the asymmetry that makes this harmful rather than merely untidy: the bare-word spelling works today (objectui#4049 ruled exactly that), so the error tells an author to change something that currently renders correctly, in a direction that may break it — and if they comply, the path probably does not resolve and #7214's predicate-path-unresolved fires next. A three-corner walk.

    The pair is already pinned in runtime-gate.test.ts, so your reconciliation has a test anchor and the pin must end up asserting the reconciled behaviour rather than the contradiction.

    ⚠️ Serialization — read before you touch the test file

    runtime-gate.test.ts is a hot file in this lane. #7576 (block-level surfaces for validateSecurityPosture — 13 rules in one field) is deliberately held behind this card and will be dispatched once this lands, because it also rewrites expectations in that file. ⛔ Do not restructure runtime-gate.test.ts beyond the region carrying this pair; a broad reformat there will collide.

    Scope

    Accept bar

    • A controls table showing what an author sees for status == active before and after — that is the deliverable's whole point, and prose about it is not evidence.
    • The three neighbours stay unchanged: a dotted chain still error; a quoted literal still silent; a left-side path still silent. ⛔ If the message tells authors to do X, X must not itself be reported.
    • A scanner must prove it can see before any silence is believed — pair every silence with a control that fires.
    • ⚠️ #7484 applies if you touch any doc anchor: lychee.toml sets include_fragments = "none", so a link to a non-existent anchor returns [200] ✅ OK. A green link gate is never evidence an anchor resolves.

    Counts: grep -c / wc -l, ⛔ never grep | head; reconcile against any in-repo pin.

    Report at draft-PR time (#6644 L2) — ⛔ no idle-polling CI; convergence, ready-flip and landing are the PM's. Issue comment carrying the os-dev-report marker first, return message second.


    Generated by Claude Code

  5. os-help commented on Aug 11, 2026

    @os-help
    CollaboratorAuthor

    os-dev report · session session_01VazgUHoW88FzFkGykVzJLM · branch claude/issue-7696-reconcile-bare-word-prescriptions · draft PR #7810

    Option taken: 2 — one merged finding, both spellings named

    I took the default rather than beating it, but the argument for it turned out stronger than the card assumed, and it did not require answering the open question ("which reading wins for a bare RHS word"). What settles the mechanics is narrower and measurable:

    The rooted reading has no spelling in this position. visibility-bare-identifier told the author to write data.active on the right of ==. That is a dotted chain on the RIGHT — predicate-rhs-path-shaped's own error arm. The same gate refuses the fix it prescribes. Measured: row 6 of the table below.

    So standing the rooted prescription down is not choosing a reading; it is deleting a spelling the surface does not have. And the error's own bar — "there is no reading of the metadata under which it was going to work" — is false at this position, because the literal reading works today (objectui#4049). The surviving advisory names both readings and lets the author pick, which is what option 2 asks for.

    This is option 2 including the stand-down that option 2 implies: "a single finding" is not achievable while a second rule keeps emitting a contradictory one. I am not claiming it is option 2 with no cost — the cost is a special case in two rules, exactly as the card priced it.

    Controls table — status == active and its neighbours, before and after

    Measured at the runtime publish gate (runRuntimeAuthoringRules({ type: 'view', … })) on origin/main@098b629 vs the branch. FORM = schema-bound metadata form; RTV = runtime view.

    # predicate before after
    1 FORM data.type == active error visibility-bare-identifier → "write record.active"
    warning predicate-rhs-path-shaped → "write 'active'"
    warning predicate-rhs-path-shaped — only finding, names both spellings
    2 FORM data.type == label (label is a schema key) error visibility-bare-identifier → record.label
    error predicate-path-unrooted → data.label
    warning predicate-rhs-path-shaped → 'label' — three prescriptions
    warning predicate-rhs-path-shaped — only finding
    3 FORM status == active error visibility-bare-identifier (on status) + warning rhs unchanged
    4 FORM data.type == data.label (dotted chain) error predicate-rhs-path-shaped unchanged
    5 FORM data.type == 'active' (quoted literal) silent¹ unchanged
    6 FORM data.type == data.active — the old error's own prescription error predicate-rhs-path-shaped + error predicate-path-unresolved unchanged — the proof behind row 1
    7 FORM data.tpye == active error visibility-bare-identifier + error predicate-path-unresolved error predicate-path-unresolved only
    8 RTV record.status == active error visibility-bare-identifier → record.active unchanged
    9 RTV record.status == 'active' silent unchanged
    10 FORM active == data.type (bare on the left) error visibility-bare-identifier unchanged
    11 FORM data.name == active && active error visibility-bare-identifier unchanged
    12 FORM data.tags.all(t, t == active) (macro body) error visibility-bare-identifier unchanged
    13 FORM unresolvable schemaId, data.x == active error visibility-bare-identifier warning predicate-rhs-path-shaped
    14 FORM unresolvable schemaId, data.x == data.y silent¹ error predicate-rhs-path-shaped

    ¹ every FORM row also carries a pre-existing warning visibility-root-mislayered — unrelated, unchanged, disclosed below.

    Every control and its outcome

    • Silence paired with a firing control, per the bar. Row 1's new silence from visibility-bare-identifier is paired with rows 3, 10, 11, 12 — the same rule, the same schema-bound form, still firing. Row 2's silence from predicate-path-unrooted is paired with a unit control (type == 'formula' → predicate-path-unrooted) and data.name == type && type → both rules. Row 7 pairs feat(lint): publish-time resolution of metadata-form predicate paths (#7010) #7214's silence-on-the-right with its firing on the left.
    • Accept bar "if your message tells authors to do X, X must not itself be reported": the new hint's two recommendations are data.type == 'active' (row 5, silent) and data.active == 'yes' (silent when the field exists). When the field does not exist, predicate-path-unresolved fires — a new statement about a different mistake, not the old ring-around; pinned as its own test. The spelling the hint now warns against is row 6, reported at error — so the warning is not noise.
    • Three neighbours held, as required: dotted chain still error (row 4), quoted literal still silent (row 5), left-side path still silent (row 5 / the data.type == 'text' corpus entry).

    What changed

    • packages/lint/src/predicate-rhs-position.ts (new) — the shared answer to "which identifiers occur only as the bare right operand of == / !=", plus the shared schemaIdOf test for "the metadata-admin evaluator renders this". Shared so the rule that reports the position and the rules that stand down for it cannot drift about where it applies.
    • validate-predicate-path-refs.ts — predicate-path-unrooted stands down for that set; the bare arm of predicate-rhs-path-shaped carries the merged message; a site whose schemaId resolves to nothing is walked with no scope instead of skipped.
    • validate-visibility-predicates.ts — visibility-bare-identifier stands down for that set, on schema-bound form sites only, fed through the declared list (the same conservative mechanism namespaceRoots already uses — a name added there can only remove a finding, never invent one).
    • runtime-gate.test.ts — the pin now asserts the reconciled behaviour, two tests added beside it, one line added to the property corpus. ⛔ Nothing else in the file is touched, per the [finding] validateSecurityPosture's CLI_ONLY surfaceReason claims coverage the ADR-0094 object authoring gate does not give it — it covers 1 of the block's 13 rules #7576 serialization hold.
    • Unit tests in both rules' own suites; changeset predicate-bare-word-rhs-reconciled.md (@objectstack/lint: patch).

    Scope expansions — disclosed, not absorbed (㉕)

    1. validate-visibility-predicates.ts was edited, though scope named only validate-predicate-path-refs.ts. Unavoidable: visibility-bare-identifier lives there and is the error this card is about; option 2's stated cost is "a special case in two rules".
    2. A new file to hold the one condition both files must agree on.
    3. The walk widening (row 14) — a coverage increase, and what keeps the stand-down from becoming a silence on a form whose schemaId this package cannot resolve.

    Found while measuring, NOT fixed — worth its own card

    At the runtime gate, validateVisibilityPredicates runs with opts.layer at its 'runtime' default for every view, including schema-bound metadata forms. Two consequences, both pre-existing:

    • a correctly-data.-rooted metadata form draws a visibility-root-mislayered advisory telling the author to use record. (the ¹ rows);
    • before this PR the bare-identifier error prescribed record.active, not the data.active this issue's table quotes — the contradiction was real but one notch worse than filed.

    That is layer plumbing in the gate, not the bare-word pair this card reopened. Left untouched.

    Gates

    gate invocation result
    unit tests pnpm --filter @objectstack/lint run test 70 files, 1921 passed
    typecheck pnpm --filter @objectstack/lint run typecheck clean
    eslint npx eslint <6 changed files> --no-inline-config clean
    changeset gates node scripts/check-empty-changeset.mjs / check-adr-0087-registration.mjs / check-changeset-no-major.mjs all ✓
    real-app corpus os build in examples/app-showcase, examples/app-crm grep -c over the four rule ids = 0 on both, unchanged

    Counts via grep -c; no grep | head in any counting pipeline.

    Not verified


    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

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions