Skip to content

feat(lint): publish-time resolution of metadata-form predicate paths (#7010) - #7214

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-7010-predicate-path-publish-check
Aug 10, 2026
Merged

feat(lint): publish-time resolution of metadata-form predicate paths (#7010)#7214
os-project-manager merged 3 commits into
mainfrom
claude/issue-7010-predicate-path-publish-check

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #7010

The producer-side companion to #6936. That issue was ruled Option C — the objectui evaluator keeps failing OPEN with a warning — which settles the renderer's posture and deliberately leaves the producer side open. This is the check the #6936 filing itself named as the long-term answer: a predicate that references a path the target schema does not declare is refused at authoring time, not guessed at render time.

1. Predicate carriers, enumerated (step 1 of the deliverable)

Read off the schemas, not guessed. Every *When-family predicate slot declared with ExpressionInputSchema:

carrier key(s) file:line binding root
form field (view/metadata form) visibleWhen, visibleOn (deprecated) packages/spec/src/ui/view.zod.ts:1659 / :1661 record (runtime) / data (metadata form)
form section visibleWhen, visibleOn (deprecated) view.zod.ts:1760 / :1762 same
page component visibleWhen, visibility (deprecated) packages/spec/src/ui/page.zod.ts:150 / :152 record, current_user, page.< var >
data field visibleWhen, readonlyWhen, requiredWhen packages/spec/src/data/field.zod.ts:801-803 record
field option (per-option) visibleWhen field.zod.ts:162, packages/spec/src/ui/action.zod.ts:319 record + current_user
object user action visibleWhen, disabledWhen packages/spec/src/data/object.zod.ts:1079 / :1082 record
component prop spec visibleWhen packages/spec/src/ui/component.zod.ts:349 designer-side
flow builtin node config visibleWhen packages/spec/src/automation/builtin-node-config.zod.ts:404 designer-side
app area / action / bulk action / settings manifest visible (not a *When spelling) app.zod.ts:319, action.zod.ts:379, bulk-action.zod.ts:209, settings-manifest.zod.ts:234/:491 mixed

The metadata-admin conditional fields the card names are the *.form.ts family: 17 defineForm layouts registered in METADATA_FORM_REGISTRY (packages/spec/src/system/metadata-form-registry.ts), of which 6 carry predicates — object (20 sites incl. helpText refs), field (10), view (7), action (6), page (4), report (3). Post-parse the shipped population is 46 predicates, all visibleWhen, all data.-rooted (measured, not counted from source lines).

Note for a later reader: FormFieldBaseSchema carries no requiredWhen / readonlyWhen — those live on FieldSchema (the data field), which is a record.* surface. So on the form surface this card targets, visibleWhen is the only predicate key, and the card's "requiredWhen" belongs to the record.* layer that is out of scope below.

2. Landing point: packages/lint, and why the two candidates converge there

The card offered "the AUTHORING_RULES family in metadata-protocol, or packages/lint". They are the same place: AUTHORING_RULES lives in packages/lint/src/authoring-rules.ts; metadata-protocol is one consumer of it through @objectstack/lint/runtime.

Two hard constraints then decide it outright:

  1. The check must parse CEL through the canonical front end (parseCelToAstpackages/lint 绕过 @objectstack/formula 直接 parse CEL —— 两个解析入口对「什么能解析」会给出不同答案 #4812 forbids a private parser). packages/spec depends on zod and nothing else (packages/spec/package.json), so a spec-side home would need a new spec → formula dependency, on top of Prime Directive ✨ Set up Copilot instructions #2.
  2. The existing predicate-shape validations for this exact surface already live in packages/lint/src/validate-visibility-predicates.ts, and share the traversal (view-walk.ts formViewSites) this rule needs.

New file packages/lint/src/validate-predicate-path-refs.ts, registered in AUTHORING_RULES (tier: 'gating', all three commands), exported from the index barrel.

3. What the check does

Two rules, one question — "does this identifier name something the target schema declares?" — differing only in whether the author supplied the root:

Why the three existing gates all wave this through

validate-visibility-predicates.ts judges a predicate's shape — does it parse (visibility-predicate-syntax, #6253), is it rooted at all (visibility-bare-identifier, #6128), is that root right for the layer (visibility-root-mislayered). None of them opens the target schema. data.tpye == 'formula' passes all three and then fails open in the console, pixel-identical to no predicate at all (#5149).

And #6254 already measured the other half: its 16 bare type == … predicates were structurally uncatchable by #6248's gate, because type is an identifier CEL itself declares, so the strict checker reports a type overload rather than an unknown variable. This rule never asks CEL what resolves — it asks the schema, whose key set is closed and has no opinion about CEL's type vocabulary.

Scope is data.* only, as a decision

An error-level gate needs a closed oracle. The metadata-form layer has one: the row under edit is an instance of a metadata type, enumerable key by key through getMetadataTypeSchema. The runtime record.* layer does not — lookup traversal, system columns the authored fields map never lists, formula/rollup outputs are all legitimate paths, and an error gate over an open set manufactures false build errors. Recorded as an open question rather than guessed at.

Repeater rows rebind data, and the rule follows

Inside a type: 'record' / repeater / composite sub-field list, objectui's metadata SchemaForm evaluates { data: row } — so object.form.ts's data.type means FieldSchema.type, not the non-existent ObjectSchema.type (view.zod.ts:1647-1657 states both halves). The rule descends with the same rebinding. Without it the shipped corpus would read 16 false positives instead of 0.

4. ⚠️ Corpus count — the load-bearing number

Run through the rule's production entry point over the shipped METADATA_FORM_REGISTRY (17 forms, 46 predicates):

rule would-be-refused, origin/main@55da611e5
predicate-path-unresolved 0
predicate-path-unrooted 0

No STOP. Zero hits on the shipped tree, so the gate lands enforcing (error).

Two anti-vacuity guards ship with that number, because "0" is exactly what a gate that reads nothing also reports:

  • every registry key resolves a schema (the oracle is not absent);
  • corrupting every shipped predicate source to a path no schema declares — while keeping the real schemas, so descent still works — must produce exactly 46 findings. (The naive version of this guard, "resolve against an empty schema", is measurably wrong: an empty schema also fails to resolve the fields repeater, so the walk stops before the 16 sub-field predicates and the count comes out below the corpus while looking like proof of reach.)

Reverse verification — direction predicted before running: RED on restore

#6254 rewrote 16 predicates in object.form.ts from type … to data.type …. Restoring the bare spelling on a deep copy of the shipped object form must turn the count from 0 to exactly 16 predicate-path-unrooted. It does — and the same measurement was taken independently against the real pre-#6254 file (git show 643b7c76b^) during development, with the same 16 sites at the same paths.

That measurement also caught a real defect in this PR before it shipped: with a typeof s === 'object' guard in defOf, the callable lazySchema proxy read as "not a schema", every key set came out empty, and the gate reported clean over the corpus and over the deliberately corrupted copy alike. The guard now accepts functions, and the comment on it says why.

5. Tests

packages/lint/src/validate-predicate-path-refs.test.ts — 24 cases: resolvable paths, repeater rebinding, record-map keys, opaque scopes, comprehension variables, both rule limbs (severity + rule id + the named path + the suggestion), the deliberate boundaries (unparseable source, object-bound form, unknown schemaId, unresolvable row schema, a throwing resolver), traversal reach (form, formViews.<key>, name-keyed views map, the visibleOn alias value), registry wiring, and the corpus block above.

6. Runtime-publish surface: deliberately not widened

surfaces: ['cli'], with a reason that is a decision, not a limitation (RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY). This rule needs only the written item — its oracle is a static registry, not the tenant's other metadata — so none of the three existing P2 reasons is true for it, and stackKeyForType('view') already exists. It is not wired because a half-wired wall is worse than an unwired one: 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. The views[] visibility family should move to runtime-publish together, as one measured edit.

Verification

pnpm --filter @objectstack/lint test        # 69 files, 1795 passed | 4 skipped
pnpm --filter @objectstack/lint typecheck   # tsc --noEmit, clean
npx eslint packages/lint --no-inline-config # clean
node scripts/check-nul-bytes.mjs            # OK (6615 files, no raw control bytes)

Honest negative: packages/cli and packages/metadata-protocol were not run locally (their dependency chains are a full-repo build under a shared container). The registry change is additive and CLI-only, and authoring-rule-wiring.test.ts / rule-id-barrel-exports.test.ts / authoring-rule-input-tier.test.ts all pass in the lint suite; CI covers the rest.

Changeset

@objectstack/lint: minor, real (not skip-changeset) — this adds a functional, error-level authoring check to a published package, which can newly fail an author's build. zod joins @objectstack/lint's devDependencies so the unit tests can pin the schema traversal against a shape they fully control rather than against whatever FieldSchema declares this month.

Not in scope

The objectui evaluator change (#6936, ruled C) — its own card, another repo. No docs/adr/**, no content/docs/releases/**, no gate or ratchet weakened.


Generated by Claude Code

…7010)

New `error`-level rule family `validate-predicate-path-refs`: a
conditional-visibility predicate on a schema-bound metadata form must name
paths the target schema actually declares.

- `predicate-path-unresolved` — a `data.`-rooted path whose first
  unresolvable segment is not a key of the schema at that point.
- `predicate-path-unrooted` — a bare identifier that IS a key of the scope,
  i.e. #6254's shape (right name, dropped root). Immune to the CEL type-name
  blind spot that made #6248's gate structurally unable to catch it.

Scoped to the `data.*` layer: the metadata-type schema registry is a closed
key set, while an ObjectQL object's addressable path set is not.

Corpus-counted before enforcing: 0 findings over the shipped
METADATA_FORM_REGISTRY (17 forms, 46 predicates); 16 once #6254's pre-fix
`object.form.ts` spellings are restored.

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

vercel Bot commented Aug 10, 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 10, 2026 3:09am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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/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.

…helper

#7186 landed `packages/lint/src/collection-entries.ts` on main after this
branch was cut. The local 15-line duplicate documented as "folds into that
helper when it lands" now does — same semantics, same array/name-keyed-map
handling, same walk order, so no verdict or path changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
@os-project-manager
os-project-manager marked this pull request as ready for review August 10, 2026 03:32
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT — devx PM seat. Both required gates green by name on the reworked head b14ab11c0; flipping ready and arming auto-merge.

What made this delivery acceptable, in the order the dispatch demanded it:

  1. The carriers were enumerated, not assumed — read off the Zod schemas with file:line evidence, and the enumeration corrected the card: the form surface this check targets carries only visibleWhen (+ deprecated aliases); requiredWhen/readonlyWhen are record-layer keys and explicitly out of scope. An implementation that had "validated" the card's example keys would have been checking keys that don't exist on this surface.
  2. The landing point was argued from constraints, not preference: the check must parse CEL through the canonical front end (packages/lint 绕过 @objectstack/formula 直接 parse CEL —— 两个解析入口对「什么能解析」会给出不同答案 #4812), and packages/spec depends on zod and nothing else — so packages/lint is the only home that doesn't invent a dependency or a third place. It sits beside the existing predicate-shape rules and shares their traversal.
  3. The corpus-count STOP was respected and came back clean: all 46 shipped predicates walked through the rule's production entry point — 0 would-be-refused, so the gate lands at error legitimately. The anti-vacuity work is the strongest part: corrupting every predicate must yield exactly 46 findings, and the report documents why the naive version of that guard (resolve against an empty schema) is measurably wrong — an empty schema also kills the repeater descent, so the count reads below the corpus while looking like proof of reach. The reverse verification restored the real pre-object.form.ts 的 20 处 visibleWhen 写的是裸标识符(type == 'formula'),同仓 field.form.ts 写的是 data.type —— 同一面两种拼写,必有一种不生效 #6254 file and got exactly the 16 historical findings, direction predicted first.
  4. A false-green was caught by the discipline, disclosed, and fixed: the callable lazySchema proxy initially read as "not a schema", every key set came out empty, and the gate reported clean over good and corrupted corpora alike — precisely the failure the corrupted-corpus guard exists to catch. The fix is in, with the measurement written into the comment.
  5. Real changeset (@objectstack/lint minor) — correct call, and the mirror image of today's scripts/-only cards: this adds a functional error-level check to a published package.
  6. Rework completed cleanly: with refactor(lint): converge the triplicated collectionEntries and view binding ladder (#6662) #7186 merged, the local collectionEntries duplicate is gone (verified by git: no local definition, shared import in place, local isRec correctly retained since the shared module doesn't export it), corpus numbers unchanged, and cleanup ran without --force — the explicitly restated rule held, which is itself now part of os-dev cleanup prescribes git worktree remove --force, which disables the one guard protecting a parallel agent's uncommitted work #7055's evidence base.

Follow-ups already filed, not decided here: #7219 (the record.* layer — close the oracle before gating an open set) and #7220 (the four-rule visibility family moves to runtime-publish together or not at all — including the instructive implemented-then-reverted partial wiring).

Closes #7010 on merge.


Generated by Claude Code

Merged via the queue into main with commit 424c510 Aug 10, 2026
27 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7010-predicate-path-publish-check branch August 10, 2026 04:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants