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

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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…me publish gate (objectstack-ai#7220) (objectstack-ai#7479)

* feat(lint): move the views[] visibility-predicate family to the runtime publish gate (objectstack-ai#7220)

The four rules that judge a `views[]` conditional-visibility predicate ran on
the three CLI commands only. A `view` written through the runtime publish door
— Studio's designer, REST `/meta`, MCP — was judged by NONE of them, and that
door is the only one most tenants have and the one AI authors use. A predicate
that cannot parse saved clean and then failed OPEN in the console: the element
renders unconditionally, pixel-identical to one carrying no predicate (objectstack-ai#5149).

Both registry entries move to `surfaces: CLI_AND_RUNTIME` with
`runtimeTypes: ['view']`, in ONE edit, on the maintainer's 2026-08-10 ruling
and sequenced after objectstack-ai#4717's `advisories` channel landed (PR objectstack-ai#7435). Six rule
ids cross together: `visibility-predicate-syntax`,
`visibility-predicate-over-budget`, `visibility-bare-identifier`,
`visibility-root-mislayered`, `predicate-path-unresolved` and
`predicate-path-unrooted`.

They move together on purpose. objectstack-ai#7214's implementer wired the path rule alone,
measured the result and reverted it: a `view` refused for an unresolvable
predicate PATH while a predicate that does not parse at all walks through the
same door is less predictable than refusing neither. A half-wired wall is worse
than an unwired one, so the family property is now PINNED in
`authoring-rule-wiring.test.ts` — every id on this surface is gated at the
runtime door, or none is — rather than trusted to a prose `surfaceReason` that
goes stale silently. `RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY`, the constant that
recorded the CLI-only state as a decision, is deleted with its last user.

Three things were measured rather than assumed:

- The gate needed no dispatch-side change. `runtimeGatedTypes()` is derived
  from this registry, `TYPE_TO_STACK_KEY` already mapped `view -> views`, and
  `runtime-authoring-gate.ts` passes the written type straight through — its
  two `'flow'` literals belong to the gate-local objectstack-ai#6285 rule, not to a type
  allowlist. Declaring is sufficient; `runtimeGatedTypes()` now returns
  `['flow', 'view']`.
- Neither rule needs a stack-wide snapshot. Both read `stack.views` and
  `stack.pages` and no other collection — never `objects` — so the previous
  `RUNTIME_NEEDS_FULL_SNAPSHOT` reason on `validateVisibilityPredicates` was
  describing nothing about this rule. It was the reason a rule got when nobody
  measured, which is the objectstack-ai#4409/objectstack-ai#4463 defect one layer in.
- The door and `os build` agree. `runtime-gate.test.ts` drives the real
  dispatch path over an eight-input corpus and asserts the finding sets are
  IDENTICAL — id, severity and path — so the move changes WHERE the rules run,
  never WHAT they say. Set equality, not "both non-empty": a half-wired wall is
  precisely the state where both sides are non-empty and disagree.

`visibility-root-mislayered` is `warning` on every surface, so it does not 422
— it reaches the author through the `advisories` channel on the 2xx save
response. That channel is why this move was allowed to happen at all: moving
the rules before findings could travel back would have run them and discarded
the verdicts, the exact shape objectstack-ai#4463 exists to close.

Behaviour change on a public door (a `view` publish carrying one of these
defects is now refused), so it ships a changeset rather than skip-changeset.
Drafts are still never gated and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains
the migration hatch.

Fixes objectstack-ai#7220

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

* docs(deployment): the runtime publish gate now judges `view` writes too (objectstack-ai#7220)

The surface table in `validating-metadata.mdx` was made wrong by the rule move
in this PR, in the two places a reader would actually rely on:

- it stated `flow` is the only metadata type any rule declares at the runtime
  publish door, and that "an object, view, page or dashboard save is checked by
  the schema parse and by nothing else". Both are now false for `view`;
- the `views[]` visibility-predicate family had no row at all, so the table
  could not say what that door does or does not judge about a predicate.

Adds the row with a `✓ᵛ` marker for `view` writes, corrects the footnote to name
both gated types, and records WHY the family crossed together — the property the
wiring guard now pins — since a reader who takes the table as a menu of
independently-wirable rules would draw exactly the conclusion objectstack-ai#7220 was filed to
prevent.

The rest of the page was re-read and is unchanged: the "held to the CLI's
verdicts" paragraph, the 422 envelope description and the objectstack-ai#6285 runtime-only
exception all remain accurate.

`content/docs/releases/v17.mdx` also references `@objectstack/lint` and is
release-owned — read, left untouched.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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