Skip to content

fix(lint): a field-level app predicate is told the root binds nowhere, not that the renderer mounts it - #18176

Merged
claude[bot] merged 4 commits into
mainfrom
claude/issue-17330-ambient-roots-app
Sep 14, 2026
Merged

claude[bot] merged 4 commits into
mainfrom
claude/issue-17330-ambient-roots-app

Conversation

@claude

@claude claude Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #17330

Clause-②: no
The diff narrows nothing and widens no published or accept surface. It renames a package-internal constant (measured absent from this package’s published entry), re-founds a docblock on decision batch #67, and rewrites diagnostic prose. The engine’s declared scope list is untouched, no schema moves, no error code is added and no export changes; the one package the diff moves under the source tree is graded patch, which is the shape a yes would refuse. Recorded twice already — the PM’s claim comment on #17330 declares the same value, and so does the dev report.

FIELD_RULE_AMBIENT_ROOTS is renamed FIELD_RULE_NOWHERE_BOUND_ROOTS and keeps its single member app. The membership was never the false part — the name, the docblock and one clause of the message were.

What was wrong

#13935 added app to this locally-assembled vocabulary on the premise that objectui's app-shell bound it at the renderer, so the honest verdict was "bound somewhere, just not here". Decision batch #67 (2026-09-07) ruled option B — the engine's SCOPE_ROOTS is the contract and ObjectUI aligns to it. ObjectUI shipped that, its scope builder no longer binds app, and the producer-side option-A card was closed not_planned under the same ruling. So the constant's name asserts a fact that stopped being true, and its docblock cites a source that no longer supports it.

Which version of the card's strongest claim this PR asserts

The card and triage say the cited spec docblock was deleted. Measured on origin/main and confirmed independently by the dispatching seat, that is slightly wrong and the accurate version is stronger:

  • The ## Ambient roots — renderer behaviour, NOT contract-guaranteed section still exists, at packages/spec/src/ui/page.zod.ts:365.
  • What changed is its content: it now names only features, os.user and data. Backticked app occurs 0 times in that section (lit control: backticked features occurs 1); the only two app hits in it are app-shell.

⇒ The constant is not sourceless — it is contradicted: its cited source of truth still exists and no longer supports its membership of app. This PR asserts that version. No commit naming the PR the card blames appears in the recent history of main, so this PR does not assert that it landed.

Why the obvious fix is the wrong one

Emptying the constant is the card's literal ask. Measured, it makes a worse message reachable, so it is not what shipped.

With the root gone from the judged vocabulary, both app spellings fall through to @objectstack/formula's generic bare-reference check, whose prescription is to rewrite the root as a member of the record. Following that earns unknown field `app` from the field-existence pass one line up — the exact two-step wrong correction #13935 existed to remove, on the exact root, and the exact spelling this rule's own surviving message refuses by name. It also turns the suppression and the whole message tier into unreachable branches: with an empty array isBareReferenceToAny can never return true.

So the membership stays and only its grounds move.

The before and after an author reads

Same instrument for both readings: a driver calling validateStackExpressions on the fixture shape the test helper uses, run against this package's source. The shared leading clause is elided with …; the differing tail is verbatim.

Bare app (sources app and app == 'x') and dotted app.theme (and app.locale) produce byte-identical text on each side, because the tier is chosen by ROOT — so both spellings are quoted once and both are pinned separately.

Before:

… `app` is NOT declared platform-wide — it is an AMBIENT root, mounted only by the renderer (objectui app-shell's `ExpressionProvider` binds it beside `current_user` / `user` / `ctx` / `os` / `data` / `features`, and the spec's page-component schema records that ambient set as renderer behaviour, explicitly NOT contract-guaranteed). So it resolves in a form VIEW's own field predicate and on no server path at all, while a field-level object rule is server-enforced. ⛔ Do NOT write `record.app`: … Rewrite the predicate against `record` (…), **or leave the `app`-dependent decision on the view's own field predicate where `app` IS bound — renderer-only, enforcing nothing server-side.**

After:

… `app` is NOT declared platform-wide, and no evaluation site binds it — not this one, and not any other: it is absent from the engine's declared scope, and decision batch #67 ruled that declared scope to be the contract, so the renderer that once mounted it beside `current_user` / `user` / `ctx` / `os` / `data` / `features` was aligned to the same set and no longer binds it either. The predicate therefore faults wherever it is written, **and there is no surface to move it to.** ⛔ Do NOT write `record.app`: `app` is not a field on this object, so that spelling only trades this diagnostic for an `unknown field` error on `app`. Rewrite the predicate against `record` (plus `previous`, and `parent` on a master-detail line item) — gate on record state, not on this root.

The bolded tail is the whole repair: the old text ended by offering a destination that no longer binds the root, which is the same class of error the tier exists to stop.

What does not change: before and after, both spellings yield exactly 1 issue at severity: 'error'. No verdict moves and nothing that used to lint clean stops doing so.

Acceptance bar

⛔ On no path may an author read the record-rewrite prescription for this root. Probed over seven cases:

  • occurrences of that prescription on any app predicate: 0
  • lit control, the same construct for an ordinary bare identifier (nope): fires
  • control record.app == 1 still earns unknown field \app` on `showcase_deal`` — which is why the refusal is kept in the message rather than dropped

Four new pins assert this on the bare and the dotted spelling. Ablating the fix (emptying the constant) turns all four red, alongside the five pre-existing #13935 pins — mutation proved on disk by blob hash before the run was believed, restored with git checkout HEAD -- path and re-proved byte-identical after.

Blast radius

An app.-rooted field-rule predicate occurs exactly once repo-wide and it is this package's own per-OPTION test fixture, not shipped app metadata (lit control: 396 *When slots in the same corpus). app.(theme|locale|id|name) across content + examples: 0. So no shipped metadata is mis-advised today, either way — which is what kept this change small rather than a redesign of the tier.

Not a published break

FIELD_RULE_AMBIENT_ROOTS was never re-exported from this package's entry — src/index.ts exports validateStackExpressions, fieldRuleRootIssue and FIELD_RULE_BOUND_ROOTS only, with zero star-exports, and the built dist/index.d.ts carries 0 occurrences of the old name (lit control FIELD_RULE_BOUND_ROOTS: 3, including the export statement). The rename is internal and no consumer import moves.

Changeset

A real patch, not skip-changeset, judged on the publish surface: packages/lint ships dist, and the diagnostic text an author observes is part of what it publishes. A user-visible behaviour change in a released package takes a changeset. Nothing removed or renamed is authorable or exported, so no migration mapping and no ADR-0087 disposition is owed.

Verification

  • pnpm --filter @objectstack/lint test — 103 files / 3826 tests passed
  • pnpm --filter @objectstack/lint typecheck — pass, including check:test-typecheck
  • pnpm --filter @objectstack/lint check:doc-formula-expressions — pass (the second consumer of fieldRuleRootIssue)
  • Gate sweep derived from the real diff via scripts/pm/dispatch-gates.mjs --commands: 61 families, 61 run, 0 NOT-MEASURED, 0 UNRUN, reconciled with --ran carrying each exit code captured before any pipe. Two families first answered exit 3 (PREREQUISITE NOT MET — unbuilt dist); a full pnpm build fixed the prerequisite and the whole sweep was re-run rather than patched.
  • eslint . --no-inline-config over the whole repo — 6752 files, 0 errors, 0 warnings

Out of scope, filed separately

packages/lint/CHANGELOG.md claims FIELD_RULE_AMBIENT_ROOTS and FIELD_RULE_JUDGED_ROOTS are "exported beside" FIELD_RULE_BOUND_ROOTS; neither is on the published entry. That is released text and per AGENTS.md is amended in a dedicated docs-only PR, ⛔ never as a rider here. Reported on the card and taken by the triage seat.


Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 5 documentable anchor(s).

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 689d606f80d27f1ab8364aa2e1585b809abc1956 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c87e6085734ec23a7cb0a9b3cd99bc1b5b6227e8 — the merge of head a78bfd682feb157869aaf43f2dace2f01d148f6a into base 689d606f80d27f1ab8364aa2e1585b809abc1956, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c87e6085734ec23a7cb0a9b3cd99bc1b5b6227e8 && git checkout c87e6085734ec23a7cb0a9b3cd99bc1b5b6227e8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 689d606f80d27f1ab8364aa2e1585b809abc1956 a78bfd682feb157869aaf43f2dace2f01d148f6a && git checkout -B drift-repro 689d606f80d27f1ab8364aa2e1585b809abc1956 && git merge --no-ff a78bfd682feb157869aaf43f2dace2f01d148f6a

node scripts/docs-audit/affected-docs.mjs --json 689d606f80d27f1ab8364aa2e1585b809abc1956

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 689d606f80d27f1ab8364aa2e1585b809abc1956 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant