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.

#predicatebeforeafter
1FORM data.type == activewarning mislayered + warning rhs-path-shapedrhs-path-shaped only
2FORM data.type == labelwarning mislayered + warning rhs-path-shapedrhs-path-shaped only
3FORM status == activeerror bare-identifier → "write record.status" + warning rhs-path-shapedsame ids; hint → data.status
4FORM data.type == data.labelerror rhs-path-shaped + warning mislayeredrhs-path-shaped only
5FORM data.type == 'active'warning mislayeredSILENT
6FORM data.type == data.activeerror path-unresolved + error rhs-path-shaped + warning mislayeredthe two errors only
7FORM data.tpye == activeerror path-unresolved + warning mislayered + warning rhs-path-shapederror + rhs-path-shaped
8RTV record.status == activeerror bare-identifier → record.activeunchanged
9RTV record.status == 'active'SILENTunchanged
10FORM active == data.typeerror bare-identifier + error rhs-path-shaped + warning mislayeredsame errors; hint → data.active
11FORM data.name == active && activeerror bare-identifier + warning mislayered + warning rhs-path-shapedsame errors; hint → data.active
12FORM data.tags.all(t, t == active)error bare-identifier + error path-unresolved + warning mislayeredsame errors; hint → data.active
13FORM unresolvable schemaId, data.x == activewarning mislayered + warning rhs-path-shapedrhs-path-shaped only
14FORM unresolvable schemaId, data.x == data.yerror rhs-path-shaped + warning mislayeredrhs-path-shaped only
15FORM data.type == 'grid'correct metadatawarning mislayered → "write record."SILENT ← the card
16FORM record.type == 'grid' — never matchesSILENTwarning mislayered → "write data." ← D3's other direction
17RTV data.status == 'open'warning mislayeredunchanged — negative control
18RTV record.status == 'open'SILENTunchanged
19FORM status == 'x'error bare-identifier → record.statussame 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:

teston revertwhy
a correctly data.-rooted form is SILENTREDthe card
a data.-rooted predicate on a schema-bound form is CORRECTREDthe card, at the rule
a record.-rooted form now draws the advisory the OTHER way (×2)REDD3's second direction
prescribes the root the surface actually binds when it REFUSESREDthe hint root
derives per SITE, not per stackREDmixed formViews
an unresolvable schemaId is still a schema-bound SURFACEREDboundary follows the data source
proves the scanner still sees (the pre-existing pin)REDsee below
the runtime view one fixture-line away still draws itGREENnegative control — proves the rule is still live, so it must be green both ways
moves NO finding across the error/advisory boundary (×2)GREENinvariant pins: green before and after is exactly what "advisory-only" means
opts.layer still governs every site that declares no data sourceGREENthe caller contract is preserved, so it must not move
a non-schema provider is NOT a metadata formGREENprovider: '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

gateinvocationresult
dependency buildpnpm --filter '@objectstack/lint^...' buildclean (run first, per the stale-dist trap)
unit testspnpm --filter @objectstack/lint run test71 files, 1938 passed, 4 skipped (1924 → 1938: +14 new)
typecheckpnpm --filter @objectstack/lint run typecheckclean
eslintnpx eslint < 3 changed files > --no-inline-configclean
check:nul-bytesnode scripts/check-nul-bytes.mjsOK, 7331 files
changeset gatescheck-empty-changeset, check-changeset-no-major, check-adr-0087-registrationall ✓
direct gate consumers@objectstack/metadata-protocol (runtime-authoring-gate), @objectstack/objectql75 files / 1094 and 189 files / 3356, all passed
CLI (the os build / validate / compile path)pnpm --filter @objectstack/cli run test114 files, 1255 passed
real-app corpusstatic: 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

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 1:05pm

Request Review

@github-actionsgithub-actionsBot 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude