Skip to content

fix(lint): judge a schema-bound metadata form at its own binding layer (#7815) - #8041

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7815-runtime-gate-form-layer
Aug 12, 2026
Merged

fix(lint): judge a schema-bound metadata form at its own binding layer (#7815)#8041
os-zhuang merged 1 commit into
mainfrom
claude/issue-7815-runtime-gate-form-layer

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #7815

What was wrong, and where

authoring-rules.ts:859 calls validateVisibilityPredicates(stack) with no options, and validate-visibility-predicates.ts:849 defaults opts.layer ?? 'runtime'. So at the runtime publish gate every view was judged at the runtime layer — including schema-bound metadata forms, which bind the row under edit as data. No production caller ever passed the real layer (layer: 'metadata' hit only test files). All three line numbers re-verified on origin/main@e3c8ed0 before editing.

Two consequences, both measured below:

  1. a correctly data.-rooted form drew warning visibility-root-mislayered telling its author to write record. — an advisory that is wrong on correct metadata, at the publish door, for a whole surface class;
  2. visibility-bare-identifier's hint — the finding that blocks — prescribed record.< word > on a surface that binds no record at all. This is the issue's point 2, and it was held in place by a test assertion (see "The one pin that had to change").

Nothing went red, which is what let it survive.

The fix

opts.layer is now the layer for sites whose binding environment the metadata does not state. A form view declaring data: { provider: 'schema', schemaId } states it — that data source is what makes the console's metadata-admin evaluator the thing that renders the surface, and that evaluator binds data. Such a site is judged at metadata; every other site (a plain runtime view, every page component) still takes opts.layer, default 'runtime'.

Derived per site, from the same schemaIdOf call that already decides literalRhs (#7696's right-hand-literal-slot stand-down). One call, two consumers: the layer verdict and the stand-down can never disagree about which surface they are on, which is the reason schemaIdOf was shared in the first place. Per-site rather than per-stack because one views[] entry can carry both kinds — formViews.< key > sub-containers each declare their own data, and there is a test for exactly that.

⛔ The rule is untouched. visibility-root-mislayered was always correct for the layer it was told; this is plumbing at the invocation side, as the card scopes it.

Controls table — reproduced, not trusted

PR #7810's 14-row table re-measured at the runtime publish gate (runRuntimeAuthoringRules({ type: 'view', … })) on origin/main@e3c8ed0 vs this branch, plus 5 rows this card needs. FORM = schema-bound metadata form, RTV = runtime view. Rows 1-14 reproduce #7810's "after" column exactly, including every ¹ mis-layer advisory.

# predicate before after
1 FORM data.type == active warning mislayered + warning rhs-path-shaped rhs-path-shaped only
2 FORM data.type == label warning mislayered + warning rhs-path-shaped rhs-path-shaped only
3 FORM status == active error bare-identifier → "write record.status" + warning rhs-path-shaped same ids; hint → data.status
4 FORM data.type == data.label error rhs-path-shaped + warning mislayered rhs-path-shaped only
5 FORM data.type == 'active' warning mislayered SILENT
6 FORM data.type == data.active error path-unresolved + error rhs-path-shaped + warning mislayered the two errors only
7 FORM data.tpye == active error path-unresolved + warning mislayered + warning rhs-path-shaped error + rhs-path-shaped
8 RTV record.status == active error bare-identifier → record.active unchanged
9 RTV record.status == 'active' SILENT unchanged
10 FORM active == data.type error bare-identifier + error rhs-path-shaped + warning mislayered same errors; hint → data.active
11 FORM data.name == active && active error bare-identifier + warning mislayered + warning rhs-path-shaped same errors; hint → data.active
12 FORM data.tags.all(t, t == active) error bare-identifier + error path-unresolved + warning mislayered same errors; hint → data.active
13 FORM unresolvable schemaId, data.x == active warning mislayered + warning rhs-path-shaped rhs-path-shaped only
14 FORM unresolvable schemaId, data.x == data.y error rhs-path-shaped + warning mislayered rhs-path-shaped only
15 FORM data.type == 'grid'correct metadata warning mislayered → "write record." SILENT ← the card
16 FORM record.type == 'grid' — never matches SILENT warning mislayered → "write data." ← D3's other direction
17 RTV data.status == 'open' warning mislayered unchanged — negative control
18 RTV record.status == 'open' SILENT unchanged
19 FORM status == 'x' error bare-identifier → record.status same id/severity; hint → data.status

Every FORM row loses the wrong advisory; every non-form row (8, 9, 17, 18) is byte-identical; row 16 is the direction this door was blind to.

Acceptance is untouched, measured rather than asserted

The card's bound is that nothing accepted becomes rejected or vice versa. visibility-root-mislayered is warning in both directions, and no other rule's severity or firing condition depends on the layer — only the root quoted inside two hints does.

Mechanically compared, error-only, across all 19 rows above:

ERROR SETS IDENTICAL across all 19 rows

Pinned permanently as a property in two places (runtime-gate.test.ts and the rule's own suite) with exact expected error sets, so a later edit to the layer plumbing that promoted or demoted anything goes red in CI rather than at a tenant's publish door.

Reverse verification — and its honest direction

The fix was taken out with git checkout origin/main -- < the one source file > (never git stash), tests kept. 8 red, and 4 deliberately green:

test on revert why
a correctly data.-rooted form is SILENT RED the card
a data.-rooted predicate on a schema-bound form is CORRECT RED the card, at the rule
a record.-rooted form now draws the advisory the OTHER way (×2) RED D3's second direction
prescribes the root the surface actually binds when it REFUSES RED the hint root
derives per SITE, not per stack RED mixed formViews
an unresolvable schemaId is still a schema-bound SURFACE RED boundary follows the data source
proves the scanner still sees (the pre-existing pin) RED see below
the runtime view one fixture-line away still draws it GREEN negative control — proves the rule is still live, so it must be green both ways
moves NO finding across the error/advisory boundary (×2) GREEN invariant pins: green before and after is exactly what "advisory-only" means
opts.layer still governs every site that declares no data source GREEN the caller contract is preserved, so it must not move
a non-schema provider is NOT a metadata form GREEN provider: 'object' keeps the runtime direction

The four green-on-revert cases are not weak tests — a test whose job is to pin an invariant or a preserved contract is supposed to survive the revert, and reporting them as reds would have meant writing them wrong.

The one pin that had to change

validate-visibility-predicates.test.ts asserted, on a schema-bound fixture:

expect(bareFindings(metaForm('status == active')).map((f) => f.hint))
  .toEqual([expect.stringContaining('`record.status`')]);

That is the wrong-layer prescription the issue's point 2 names, held in place by a green assertion. It now reads `data.status`; the refusal is unchanged — same id, same error, same single finding — and only the root moved. It was the only red in the package's 1924-test suite when the fix landed, which is itself the measurement: nothing else in the repo depended on the runtime layer being assumed for these forms.

Gates

gate invocation result
dependency build pnpm --filter '@objectstack/lint^...' build clean (run first, per the stale-dist trap)
unit tests pnpm --filter @objectstack/lint run test 71 files, 1938 passed, 4 skipped (1924 → 1938: +14 new)
typecheck pnpm --filter @objectstack/lint run typecheck clean
eslint npx eslint < 3 changed files > --no-inline-config clean
check:nul-bytes node scripts/check-nul-bytes.mjs OK, 7331 files
changeset gates check-empty-changeset, check-changeset-no-major, check-adr-0087-registration all ✓
direct gate consumers @objectstack/metadata-protocol (runtime-authoring-gate), @objectstack/objectql 75 files / 1094 and 189 files / 3356, all passed
CLI (the os build / validate / compile path) pnpm --filter @objectstack/cli run test 114 files, 1255 passed
real-app corpus static: provider: 'schema' occurrences in examples/ 0 — neither showcase nor CRM has a schema-bound view, so no corpus row can move (stated as the grep it is, not as a re-run os build)

Note on the CLI suite: a first pass showed 2 failures in src/utils/schema-migrate.pending-render.test.ts. Both are 5 s test timeouts under this shared container's load, and both reproduce on clean origin/main with this change reverted; they pass with --testTimeout=60000, and the full suite is green at --testTimeout=30000. Unrelated to this change and not a defect — recorded so the next reader does not re-chase it.

CI has not been waited on — that is the PM's, per the dispatch contract.


Generated by Claude Code

#7815)

The runtime publish gate calls `validateVisibilityPredicates(stack)` with no
options, so every view was judged at the `'runtime'` layer default — including
schema-bound metadata forms, which bind the row under edit as `data`. Correct
metadata therefore drew a `visibility-root-mislayered` advisory telling its
author to write `record.`, and `visibility-bare-identifier`'s hint prescribed
`record.<word>`, on a surface that binds no `record` at all.

The layer is now read off the metadata where the metadata states it: a form view
declaring `data: { provider: 'schema', schemaId }` is judged at `metadata`, and
every other site — a plain runtime view, every page component — still takes
`opts.layer`. Derived from the same `schemaIdOf` call that decides the #7696
right-hand-literal-slot stand-down, so the two cannot disagree about which
surface they are on.

The rule itself is unchanged; this is layer plumbing at the invocation side.
`visibility-root-mislayered` is `warning` in both directions, so acceptance is
untouched — measured as identical error sets across the 19-row controls corpus,
and pinned as a property in `runtime-gate.test.ts`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WocN37om5bw81JDoEEMA2e
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 1:05pm

Request Review

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint)
  • content/docs/deployment/validating-metadata.mdx (via packages/lint)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v17.mdx (via @objectstack/lint)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

2 participants