Skip to content

fix(lint,metadata-protocol): a Studio publish refuses an object whose highlightFields (or any field-name list) names a missing field — the same rule family as code (#15254) - #15493

Merged
baozhoutao merged 6 commits into
mainfrom
claude/issue-15254-studio-highlight-fields-refusal
Sep 4, 2026
Merged

fix(lint,metadata-protocol): a Studio publish refuses an object whose highlightFields (or any field-name list) names a missing field — the same rule family as code (#15254)#15493
baozhoutao merged 6 commits into
mainfrom
claude/issue-15254-studio-highlight-fields-refusal

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #15254

A Studio publish now refuses an object whose highlightFields — or any object-level field-name list — names a field that does not exist on it, with the rule id and the offending path on the receipt. Error-level, on the runtime publish door as well as on os validate / os build / os lint.

Measured first, on origin/main (f01adfa5c), not on the card's installed 17.2.0 pin

(a) What objectstack validate says today about a code-authored dangling highlightFields

It is not silent — but it does not refuse, which turns out to be the same gap wearing a different face. Driving the whole AUTHORING_RULES table over a code-authored object with highlightFields: ['name', 'field_10']:

reporter severity rule id tier surfaces
validateSemanticRoles warning semantic-role-field-unknown advisory ['cli']

So os validate exits 0 and reports the stack valid; an author who reads the verdict rather than the warning list ships it. And because the entry is surfaces: ['cli'], the runtime publish door never saw it at all. ⇒ The rule family needed the object-level clause for both surfaces, exactly as the card's fallback branch anticipated.

(b) Every object-level field-name key in packages/spec, and who owned it

Each row is one mutation carrying a name that resolves to nothing, run through the whole authoring-rule table. listViews.*.columns is the control that proves the harness sees findings at all.

object key schema location reported by, before verdict
highlightFields[i] object.zod.ts:2092 warning / semantic-role-field-unknown joins the rule
compactLayout[i] (retired alias) object.zod.ts:1495 warning / same joins, same position
publicSharing.redactFields[i] object.zod.ts:2258 nothing joins the rule
searchableFields[i] object.zod.ts:2159 error / searchable-field-unknown already owned — excluded
listViews.*.columns[i] (control) object.zod.ts:2154 error / list-view-field-unknown already owned — excluded
stageField object.zod.ts:2107 warning / semantic-role-field-unknown scalar, not a list — excluded, see open question
nameField / displayNameField object.zod.ts:2075 / :2082 nothing scalar; validateRecordTitle's axis — excluded
indexes[].fields[i] object.zod.ts:457 nothing a list, but a STORAGE question against physical columns — excluded
tenancy.tenantField / .organizationField object.zod.ts:663 / :671 nothing scalars that habitually name injected columns (#5378 trap) — excluded
lifecycle.ttl.field object.zod.ts:947 nothing scalar, same trap (created_at) — excluded
activityMilestones[].field object.zod.ts:2052 nothing scalar — excluded
external.columnMap / .ignoreColumns object.zod.ts:1300 / :1304 n/a REMOTE column names by schema — out by definition
systemFields object.zod.ts:1864 n/a system-column registry names, not this object's field map
titleFormat object.zod.ts:2083 n/a a template expression; the expression rules own it

The rule takes the object-level lists that nothing already resolves. Which keys joined is the first open question below.

(c) The publish probe's shape, and what it can and cannot do

probes.checked was { seeds, views, widgets } with no objects key, and probes never fail the publish they verify (protocol.ts: "probes are health surfaces; probes never fail the publish itself"). So the probe is not where a refusal can come from — it is the receipt. The refusal has to come from the #4463 runtime authoring gate, and that is where the decisive measurement was:

runtimeAuthoringRulesFor('object') -> 7 rules, none of them reference-integrity:
  validateFunctionalCompleteness, validateManagedApiMethods, validatePresetComparands,
  lintAutonumberFormats, validateSecurityPosture, validateRuleCompilability,
  validateRuleSchemaFormats

Driving a real object publish through the gate with three dangling references — highlightFields, searchableFields and listViews.all.columns — returned exactly one unrelated error (security-owd-unset) and not one reference finding. The reference-integrity suite entry declared runtimeTypes: ['flow', 'view'], so an object write dispatched none of it, and validateSemanticRoles is held off that door by the #4716 advisory-volume fence. ⇒ The only door a Studio tenant, a REST /meta author or an MCP/AI author has ran no reference-integrity rule at all on the artifact Studio actually authors.

What changed

  • New rule object-field-ref-unknown (error)packages/lint/src/validate-object-field-refs.ts. Resolves through the shared object-graph seam, so the family's three skips hold: an object outside the stack, an object with no readable field map (ADR-0015 external), and a registry-injected system column resolved per object (highlightFields: ['owner_id'] is live on an owned object, a real miss under ownership: 'none').
  • Registered in the reference-integrity suite with runtimeTypes: ['flow', 'object']. object is the point of the member; flow holds the roster-wide floor that every member judges flow snapshots (there the objects are context in both passes, so it adds a pass and no verdict). view is argued out — a view write cannot change an object's own lists.
  • The suite entry's runtimeTypes gains object in authoring-rules.ts, so the suite reaches that door; its per-member axis keeps the crossing to this one member.
  • validateSemanticRoles keeps provenance, drops existence at the highlightFields position — one finding per path, at one tier, instead of two tiers disagreeing about one fact.
  • probes.checked.objects — a real plane that re-runs the judgement over each published object's ACTIVE body. Non-differential on purpose: the gate judges only what a write added ([runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 D4), so an object that arrived dangling before the rule existed would otherwise stay invisible forever.

The click path, reproduced as a test

packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts drives the card's steps in the natural order through the real publishPackageDrafts (nothing on the gate path stubbed): place a field (Studio mints field_10) → add it to highlightFields → label it "Health Score" (the API name derives to health_score; the list is not rewritten) → publish.

outcome         != 'published'   (success: false, publishedCount: 0, failedCount: 1)
failed[0].code  == 'INVALID_METADATA'
                   object-field-ref-unknown
                   objects.proj_task.highlightFields[1]

…and the same draft publishes cleanly once the reference is repaired. A draft save is still never gated (#4463 D1) — the author is stopped when they claim it is ready, not while they work.

File surface, and the #15395 overlap statement

Declared in the claim: packages/lint/src/validate-list-view-field-refs.ts, packages/lint/src/authoring-rules.ts, packages/metadata-protocol/src/protocol.ts, plus tests.

packages/metadata-protocol/src/protocol.ts is NOT touched by this PR — zero lines. The object plane needed nothing from it: runBuildProbes already receives the published set and an active-body reader, and protocol.ts refers to BuildProbeReport by import type, so the shape change follows automatically. The disjointness from draft PR #15395 is therefore total rather than argued: #15395's protocol.ts hunks span old lines 78 – 12163, and this PR's span of that file is empty. (#15395 had not merged as of the origin/main merge in this branch, 0db29473c.) No other file overlaps either: #15395 edits validate-readonly-action-writes.ts / validate-readonly-hook-writes.ts in @objectstack/lint; this PR touches neither.

validate-list-view-field-refs.ts is not edited: the object-level check is a sibling rule, not a clause inside the list-view walker, so the list-view file keeps its single subject.

Additions to the declared surface, each because a measurement forced it:

file why
packages/lint/src/validate-object-field-refs{,.test}.ts the new rule + its tests
packages/lint/src/reference-integrity-suite{,.test}.ts where validateListViewFieldRefs is actually registered — authoring-rules.ts holds the suite as ONE entry
packages/lint/src/validate-semantic-roles{,.test}.ts the existence clause moved out of it; leaving it would double-report one path at two tiers
packages/lint/src/runtime-gate.object-writes.test.ts pins the exact rule list at the object door — the list this PR changes
packages/lint/src/index.ts rule-id-barrel-exports.test.ts requires every rule id to be reachable from a published barrel
packages/metadata-protocol/src/build-probes.ts where probes.checked and the probe planes actually live
packages/objectql/src/build-probes.test.ts, packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts, packages/spec/src/api/protocol.test.ts the three existing pins on the probes.checked shape
scripts/engine-double-contract.pinned.json check:engine-double-contract prescribes --write for a new file's pinned doubles

No content/docs/** change: the highlightFields entries there are generated from the spec's own .describe() and document the key, not the old lint behaviour. Swept docs/qa/platform-checklist/ too — the publish-lifecycle and diagnostics items it holds say "invalid drafts refused", which this strengthens; none pins the old acceptance as expected.

Verification

Exit codes captured before any pipe; verdict lines quoted.

what result
pnpm --filter @objectstack/lint test Test Files 95 passed (95), VERDICT command-exit 0
pnpm --filter @objectstack/lint typecheck clean (same run)
@objectstack/metadata-protocol — 8 publish-drafts / authoring-gate suites Test Files 8 passed (8), VERDICT command-exit 0
pnpm --filter @objectstack/metadata-protocol typecheck clean (same run)
derived family, node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack 69 commands: 67 exit 0, 2 exit 3
pnpm check:declared-population-live exit 0 — ✓ 203 of 254 famil(ies) … reach this tree's 7500 tracked file(s)
pnpm check:nul-bytes exit 0 (in the derived family)
whole-repo pnpm lint (eslint . --no-inline-config) exit 0 — the full farm, not a narrowed run
packages/objectql src/build-probes.test.ts Test Files 1 passed (1)
packages/runtime src/domains/packages-publish-drafts-response-conformance.test.ts Test Files 1 passed (1)
packages/spec src/api/protocol.test.ts Test Files 1 passed (1)

The gate family was derived after merging origin/main (0db29473c) into this branch — the first derivation carried a ⚠️ STALE TREE banner naming 10 files the families derive from as changed, so it was discarded rather than reported. The re-derivation reports --repo 'objectstack-ai/objectstack' checked against this checkout's 'origin' remote — it holds.

Re-run on the final commit 9678d3418, after the last two commits moved the tree: check:engine-double-contract, check:objectql-double-limit, check:nul-bytes, check:declared-population-live, check:cross-package-test-inputs, check:test-source-alias — all exit 0.

The two exit-3s are NOT MEASURED, stated as such and not read as green: check:dual-build-cjs-loads (PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured) and check:type-check-debt (⛔ This is NOT a pass and NOT a finding: nothing was measured). Both require a full monorepo build closure, which CI performs.

Two gates found real problems in this PR's new test file's copied harness and are now green: check:engine-double-contract (OK — 777 pinned, 133 in the DEBT ledger, 3 exempt) after the prescribed --write, and check:objectql-double-limit (OK … 123 apply the caller's bound or refuse it loudly, baseline key set verified against 0db2947: no files added) after the harness's find double was made to honour the caller's limit by presence.

Declared narrowings (CI runs the full farm regardless):

  • One narrowing only: the full @objectstack/metadata-protocol suite (152 files) exceeds this container's 10-minute foreground cap under shared-lock contention. The 8 suites run above are its publish-drafts, authoring-gate and probe families — the ones this change can reach. CI runs the rest.
  • The two build-dependent gates (check:dual-build-cjs-loads, check:type-check-debt) need a full monorepo build closure and are left to CI, reported above as NOT MEASURED rather than as passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

os-try2026 and others added 6 commits September 4, 2026 16:04
…name a missing field

Studio's app builder mints no `view` items, so `list-view-field-unknown` had
nothing to inspect on the only artifacts the click path authors, and
`highlightFields` is an object-level list no gating rule covered:
`runtimeAuthoringRulesFor('object')` dispatched seven rules and no
reference-integrity rule among them, while the object-level existence check
that did exist (`semantic-role-field-unknown`) is warning-tier and CLI-only.

Adds `object-field-ref-unknown` (error) over the object-level field-name LISTS
nothing owns — `highlightFields` and `publicSharing.redactFields` — registers
it in the reference-integrity suite with `runtimeTypes` including `object`,
and crosses the suite entry onto the object write door so its one
object-judging member runs there. The existence half moves out of
`validateSemanticRoles`, which keeps the provenance question at that position.

Adds an `objects` plane to the ADR-0038 L3 build probes so `probes.checked`
reports how many objects were inspected — the absent key was the tell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…r to object writes

The #5378 injected-column counter-examples and the #8116 withheld-anchor case
move to the rule that now owns the position, at `error` rather than `warning`.
`validateSemanticRoles` keeps its own stageField copies and the provenance
question, and gains the controls that keep its new silence non-vacuous.

The suite member declares `runtimeTypes: ['object']` only: on a flow or view
snapshot the objects are context, present in baseline and candidate alike, so
every finding it could raise there cancels in the gate's differential.

Adds the changeset (minor/minor, with the Migration section).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Every member of the reference-integrity suite judges flow snapshots, pinned
as a roster-wide invariant rather than a per-member preference. The member
declares 'flow' to hold that floor; on a flow snapshot the objects are context
in both passes, so it adds a pass and no verdict. 'view' stays argued out —
a view write cannot change an object's own field-name lists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…pied harness

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
… limit

check:objectql-double-limit refuses a NEW limit-blind double ('the baseline
never grows'): the copied harness answered more rows than the real engine
would, so every assertion downstream of it measured a shape production never
produces. Applies the bound after the filter, by presence.

Records the new file's engine doubles in the pinned ledger, as
check:engine-double-contract prescribes for new pinned coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/metadata-protocol, touching 24 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx (via compactLayout (literal, a string literal in retiredAlias), highlightFields (literal, a string literal in LIST_POSITIONS; a string literal in validateSemanticRoles))
  • content/docs/data-modeling/schema-design.mdx (via compactLayout (literal, a string literal in retiredAlias), highlightFields (literal, a string literal in LIST_POSITIONS; a string literal in validateSemanticRoles))
  • content/docs/deployment/cli.mdx (via highlightFields (literal, a string literal in LIST_POSITIONS; a string literal in validateSemanticRoles))
  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES))
  • content/docs/permissions/system-context.mdx (via publicSharing (literal, a string literal in block))
  • content/docs/protocol/objectql/security.mdx (via publicSharing (literal, a string literal in block), redactFields (literal, a string literal in LIST_POSITIONS))
  • content/docs/ui/forms.mdx (via highlightFields (literal, a string literal in LIST_POSITIONS; a string literal in validateSemanticRoles))

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

  • content/docs/releases/v12.mdx (via highlightFields (literal, a string literal in LIST_POSITIONS; a string literal in validateSemanticRoles))
  • content/docs/releases/v15.mdx (via highlightFields (literal, a string literal in LIST_POSITIONS; a string literal in validateSemanticRoles))
  • content/docs/releases/v17.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES), publicSharing (literal, a string literal in block))

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
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: owner_id (literal, 31 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 14 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 6c9f34f203b89a588b237ee7d6d3ceeec6d2d3bapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0fda3eabfb45ae6348b930eb72c8300687222d22 — the merge of head 9678d3418b812bc378dc5826fda33ff380526468 into base 6c9f34f203b89a588b237ee7d6d3ceeec6d2d3ba, 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 0fda3eabfb45ae6348b930eb72c8300687222d22 && git checkout 0fda3eabfb45ae6348b930eb72c8300687222d22
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6c9f34f203b89a588b237ee7d6d3ceeec6d2d3ba 9678d3418b812bc378dc5826fda33ff380526468 && git checkout -B drift-repro 6c9f34f203b89a588b237ee7d6d3ceeec6d2d3ba && git merge --no-ff 9678d3418b812bc378dc5826fda33ff380526468

node scripts/docs-audit/affected-docs.mjs --json 6c9f34f203b89a588b237ee7d6d3ceeec6d2d3ba

⚠️ 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 6c9f34f203b89a588b237ee7d6d3ceeec6d2d3ba → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT + ruling — PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk.

Reviewed against the three-dot diff origin/main...claude/issue-15254-studio-highlight-fields-refusal (merge base 0db29473c, head 9678d3418): 16 files, +1195/−73, matching the PR file list. protocol.ts is untouched, so the feared overlap with draft #15395 does not exist; the only shared file with other open PRs is the generated scripts/engine-double-contract.pinned.json (#15474 / #15436 / #15395 / #15235), which the merge queue re-validates and check:engine-double-contract --write regenerates.

  • New error-tier rule object-field-ref-unknown (validate-object-field-refs.ts) over the object-level lists nothing owned — highlightFields (+ the retired compactLayout alias, read-only) and publicSharing.redactFields — resolving through the shared object-graph seam so the three family skips hold, registry-injected columns resolved per object. Registered in the reference-integrity suite with runtimeTypes ['flow','object']; the suite entry gains object and every other member keeps its narrow declaration. validateSemanticRoles keeps only the provenance finding at the same position (the removed block is the existence half moving, not disappearing — the 82-line test diff follows it). probes.checked.objects added; build-probes.ts imports the rule dynamically.
  • Measured on main first: os validate was warning/advisory/CLI-only, and runtimeAuthoringRulesFor('object') ran no reference-integrity rule at all — the card's fallback branch.
  • Changesets @objectstack/lint + @objectstack/metadata-protocol minor with a Migration section naming the 422, the rule id, the wire path and both fixes — right for a refusal widening.
  • Tests: lint 95/95, metadata-protocol publish-drafts/authoring-gate/probe suites 8/8 (declared narrowing: the full 152-file suite exceeds the container cap; CI runs it), cross-package pins green, whole-repo lint green, two ratchet gates (engine-double-contract, objectql-double-limit) caught real defects in the new test file and are green after the prescribed repair.

Rulings on the open questions: Q1 = A (lists only: highlightFields + publicSharing.redactFields; redactFields is the one candidate whose silent failure is a disclosure; the scalars are #15495-shaped follow-ups each owing a blast-radius measurement — stageField's documented fallback is the argument against refusing it). Q2 = A (ship the refusal on the door: it is the card's own bar, a stored sibling never blocks another write under #4463 D4, and the changeset migration is self-describing). One note for the follow-up, not this PR: the probe's catch { findings = [] } around validateObjectFieldRefs turns a rule crash into a silent zero on the receipt — that is #15494's class and is recorded there.

Fixes #15254 closes the P0 on merge. Flipping ready and arming auto-merge now. #15494 (p2) and #15495 (p3) are graded, serial behind this PR on the reference-integrity suite.


Generated by Claude Code

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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P0] Studio package publish accepts a dangling highlightFields reference — the authoring gate never sees what the app builder produces

3 participants