Skip to content

fix(metadata-core,metadata): a form SECTION binds current_user too, so the unbound-root notice stops flagging one - #13348

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13072-form-predicate-root-policy-section-remeasure
Aug 30, 2026
Merged

fix(metadata-core,metadata): a form SECTION binds current_user too, so the unbound-root notice stops flagging one#13348
os-zhuang merged 2 commits into
mainfrom
claude/issue-13072-form-predicate-root-policy-section-remeasure

Conversation

@claude

@claudeclaudeBot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Fixes#13072

Site 1 only, per the PM dispatch comment that narrows this card
(#13072 (comment)).
Sites 2-4 are owned by #13077, which is open; this PR touches no content/docs/**
and no packages/lint/** path.

What was wrong

BOUND_FORM_VIEW_PREDICATE_ROOTS in
packages/metadata-core/src/form-predicate-root-policy.ts was
['record', 'previous', 'parent', 'data'], and its docblock justified the
omission of current_user by quoting the section contract sentence that #12914
replaced. A legacy artifact carrying a legitimate section-level current_user
predicate was therefore reported by this live detector as an unbound root that
will fault open — a boot notice about a predicate that now resolves. The
module's own doc names that failure and forbids it: "A notice that fires on a
healthy, current, correctly-authored artifact is worse than no notice, because
the next one is ignored."

Re-measured on origin/main before editing, not copied from the card

Read at merge base a286411df, packages/spec/src/ui/view.zod.ts:

whatwherereads
section contract, docblockFormSectionSchema.visibleWhen, :2306-2347"## current_user DOES bind here — client-side, and only under a host that publishes a scope (objectui#6110 + #6111)" … "So a section predicate resolves current_user and its ADR-0068 D1 aliases (user, ctx.user, os.user) alongside record + previous + parent."
section contract, describe():2348"current_user (and the ADR-0068 aliases user / ctx.user / os.user) resolves here too"
field contract, describe():2151the same sentence without "too"

The two surfaces bind the same eight roots. The vocabularies converged; they did
not merely come close.

What changed

One vocabulary now serves both slots: record, previous, parent, data,
current_user, user, ctx, os.

  • BOUND_FORM_VIEW_PREDICATE_ROOTS gains the current_user family.
  • BOUND_FORM_FIELD_PREDICATE_ROOTS is now that same list by identity, kept
    under its own name because the operator notice prints a rule per surface and
    because the question is still per surface — two different renderers bind the
    family (objectui#6010 versus objectui#6110 + v17 GA 交付物:AI 一键升级客户元数据项目(升级 skill,骑在 D2 conversions 之上) #6111) and either can move
    without the other.
  • FIELD_ONLY_BOUND_PREDICATE_ROOTS is removed rather than emptied. See the
    fork-clause section below for why that is not a public-contract change.
  • surface stays on every finding; it no longer selects a vocabulary.

The fork clause did not fire, and here is the measurement

The dispatch carries a stop condition: if collapsing the split changes a
published contract rather than a detector's internal vocabulary, stop and
report. It does not, and the evidence is repo-local and re-measurable:

  • npm view @objectstack/metadata-core versions ends at 17.2.0; there is no
    newer published version.
  • git tag --contains ba2ffbcaf — the commit that first added
    form-predicate-root-policy.ts — returns nothing. It is in no release tag.
  • Both changesets that introduce the detector
    (.changeset/artifact-unbound-form-predicate-root-notice.md,
    .changeset/form-predicate-root-vocabulary-per-surface.md) are still
    pending in .changeset/, so the feature has never shipped.
  • In-repo consumers of the constants: packages/metadata/src/plugin.ts (uses
    the VIEW and FIELD lists, never FIELD_ONLY_…) and this module's own test.

So no upgrader can hold FIELD_ONLY_BOUND_PREDICATE_ROOTS, and this is the last
moment at which removing it is free. Shipping it as an empty constant named
FIELD_ONLY_… would have created the published-contract problem the fork
clause is watching for, not avoided it.

Why the vocabulary is no longer justified by quoting the contract

The card flagged that this module already paid for this mistake once on the
FIELD half. It has now been wrong twice, both times by transcribing prose that
was faithful and stale. Prose is a transcription of a renderer, so it can only
ever lag one. Membership is therefore stated as the mechanism — a root is bound
on a surface iff some renderer threads a scope carrying it into that surface's
evaluator
— with the threading site named per entry (objectui#6010 for the
field slot; objectui#6110's isSectionVisible threading plus objectui#6111's
section-divider pseudo-field for the section slot). That is refutable by going
and looking, which a quoted sentence is not.

And the guard is mechanical rather than editorial: the module's test now reads
the live.describe() text of FormFieldSchema.visibleWhen and
FormSectionSchema.visibleWhen out of @objectstack/spec/ui and fails when it
stops agreeing with the list. A helper turns an unreadable accessor into a RED
test rather than an undefined that would satisfy every assertion beneath it.

The stale verbatim quote in the test, handled without deleting it

form-predicate-root-policy.test.ts quoted the retired sentence in a comment
("No current_user at section level — it is unbound here and the predicate
would fault open"). It is kept, as dated history and as the record of how
this file failed, and replaced as a mechanism by the live read above. Deleting
it would have removed the evidence and left the mechanism unfixed.

Blast radius, stated as the card states it

This is a notice, not a refusal — no parse change, no gate, no behaviour
change — and it only runs inside the versioned window
applyArtifactForwardConversions opens. The cost removed is a false operator
signal on legacy artifacts, not a broken runtime.

Declared widening beyond the two dispatched files

The dispatch fenced the file surface to site 1. Two files in packages/metadata
are in this diff anyway, both forced and both declared here:

  1. src/plugin-unbound-form-predicate-roots.test.ts — its case "DOES flag the
    same root at section level, and prints the section vocabulary there"
    pins
    the exact behaviour this card removes, so the fix cannot land without it.
    Skipping, disabling or re-baselining it is forbidden, so the expectation is
    inverted in place with a comment naming the contract change, and a new
    companion control ("still flags a genuinely unbound SECTION root, and prints
    the section rule"
    ) keeps the section arm non-vacuous and re-pins the printed
    section vocabulary at the door.
  2. src/plugin.tscomment only, no code change. The comment beside the
    notice asserted "The bound vocabulary differs between a field slot and a
    section slot (a field also binds the current_user family, objectui#6010)",
    which is the same stale claim this card is about, one file over. No open PR
    touches either file (checked against the 29 open PRs at the time of writing).

Verification

Gate family derived mechanically, not recalled:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived
after merging origin/main so the answer came from a non-stale tree (the first
derivation warned STALE TREE; the two families are identical). Exit codes were
captured before any pipe.

Green (each quoting its own verdict line, not a bare exit code):

  • pnpm --filter @objectstack/metadata-core typechecktsc --noEmit && tsc --noEmit -p tsconfig.test.json, clean. Both edited files are genuinely inside that program: tsc -p tsconfig.test.json --listFiles lists form-predicate-root-policy.ts and form-predicate-root-policy.test.ts.
  • pnpm --filter @objectstack/metadata-core --filter @objectstack/metadata test — 15 files / 261 tests and 39 files / 679 tests, all passing, run at final commit e7a04a708.
  • check:nul-bytes, check:test-source-alias ("OK — 72 packages with tests scanned"), check:cross-package-test-inputs, check-undeclared-dep-imports, check-adr-0087-registration, check-empty-changeset, check-changeset-no-major, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-half-states, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:objectql-double-limit, check:durability-log-level, check:logger-receiver-detach, check:page-declaration-shape, check:slot-lookup, check:stack-collection-maps, check:published-files, check:type-source-resolution, check-comment-mask-adoption, check-keyed-text-bounds, check-plugin-teardown-shape, check-ci-filter-parity, check:type-check-coverage, check-shard-attestation, check-cross-package-test-inputs, docs-audit/check-affected-docs, docs-audit/check-drift-comment, release-rehearsal-clone --self-test — all exit 0.

NOT MEASURED locally, each quoting its own refusal, none counted as a pass:

  • node scripts/pm/check-half-states.mjs — exit 3: "Nothing was swept: no issue was listed, no predicate (H1-H16) ran … It is not a clean board and it is not a dirty one — it is no reading at all." (container GitHub credential). The workflow-invoked half, pnpm check:pm-half-states, passes: "1551 cases pass".
  • node scripts/check-test-completeness.mjs — exit 3: "the local reading for this gate is NOT MEASURED. It is not a red, and there is nothing here to fix."
  • pnpm check:dual-build-cjs-loads — exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … This is NOT a pass: nothing was measured."
  • pnpm check:type-check-debt — exit 1 on its prerequisite, not on a finding: "--re-measure cannot run: 44 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk."

Declared narrowing. Both of the last two want the whole workspace built
(turbo run build --filter='./packages/*' --filter='./packages/*/*'). That run
was attempted here under the shared verify lock and was SIGKILLed at 13m41s
(Failed: @objectstack/spec#build, run failed: command exited (137)) on a
contended shared box — and it left packages/spec/dist without its declaration
files, which was detected and repaired by rebuilding the closure before any
reading below was taken. CI runs both with the closure built.

For the one gate that could actually have moved, the narrowing was replaced by a
direct measurement rather than left as a gap: @objectstack/metadata is the only
package in this diff carried in the type-check DEBT ledger (89 frozen raw
errors). Measured on the repaired closure, tsc --noEmit -p packages/metadata/tsconfig.json
reports 89 raw errors — the ledger figure exactly, shrink-only ratchet
unmoved — and zero of them are in either file this PR edits.

Non-vacuity (ablation)

Mutation: the current_user family removed from the single vocabulary in
form-predicate-root-policy.ts. Script carried trap restore EXIT INT TERM
with an absolute REPO_ROOT-anchored path.

  • Mutation proven on disk before anything was run: quoted 'current_user'
    occurrences in the file 4 to 0, git hash-object
    596ed4d6… to 626b8782….
  • Rebuilt (pnpm --filter @objectstack/metadata-core build, exit 0) because the
    door suite consumes dist/. The preflight's --absent leg reports exit 1,
    and that is a marker-choice artifact rather than a stale dist: current_user
    survives in dist/index.d.ts / index.d.cts because it appears in the
    module's doc prose, which tsup carries into the declarations. The
    executable outputs are the discriminator, and they moved exactly as
    required — the mutated leg lists no dist/index.js and no dist/index.cjs
    hit, the restored leg lists both.
  • Ablated result: form-predicate-root-policy.test.ts5 failed / 22 passed
    (every new and every inverted case among them), and the door suite
    plugin-unbound-form-predicate-roots.test.ts4 failed / 6 passed.
  • Restore verified by comparing git hash-object against the HEAD blob — back to
    596ed4d6…, git diff HEAD for the path empty — not by the restore
    command's exit code. Rebuilt again, preflight present leg "✓ marker present in
    4 built files", and both suites returned to 27/27 and 10/10.

Changeset

.changeset/form-predicate-root-section-binds-current-user.md, graded patch
for both packages — honest for a correction to a detector's vocabulary that has
never shipped. It carries the FROM to TO line for the removed export and an
ADR-0087 not-required (unpublished) disposition.

Not addressed here

#13077 is open and owns sites 2-4 (content/docs/ui/views.mdx,
content/docs/protocol/objectui/layout-dsl.mdx,
packages/lint/scripts/check-doc-formula-expressions.mjs). Nothing in this PR
touches those paths.

Generated by Claude Code


Generated by Claude Code

…re-measure the detector vocabulary
The unbound-root boot notice judged a SECTION-level predicate against
`record` / `previous` / `parent` / `data`, sourced faithfully from the section
contract prose that #12914 replaced. objectui#6110 threads the host shell's
predicate scope into `isSectionVisible`, and objectui#6111 evaluates the
authored section `visibleWhen` on the `section-divider` pseudo-field with that
scope bound, so a legacy artifact carrying a legitimate section-level
`current_user` predicate was flagged as an unbound root that faults open — a
boot notice about a predicate that resolves.
One vocabulary now serves both surfaces. `FIELD_ONLY_BOUND_PREDICATE_ROOTS`
is removed rather than emptied (nothing has published it). The vocabulary is
justified by the binding mechanism per entry instead of by quoting prose, and
the module's test reads the live `.describe()` text out of `@objectstack/spec`
so the next re-measurement of that sentence fails this file instead of
outliving it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-core, @objectstack/metadata, touching 6 documentable anchor(s).

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

  • content/docs/protocol/objectui/actions.mdx(via /f/:slug (route))
  • content/docs/protocol/objectui/layout-dsl.mdx(via /f/:slug (route))
  • content/docs/ui/forms.mdx(via /f/:slug (route))
  • content/docs/ui/views.mdx(via /f/:slug (route))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 — 12 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 e8ff4274b4e16a06465f05271423a2cc87774475packageMentionDocs.

Which tree this was computed on

This run read content/docs from bd6cbb4984b7ab9e24cbf04ede6759380a1acf2a — the merge of head e7a04a708a3128847d3d1b6c9d4d5851f54aef07 into base e8ff4274b4e16a06465f05271423a2cc87774475, 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 bd6cbb4984b7ab9e24cbf04ede6759380a1acf2a && git checkout bd6cbb4984b7ab9e24cbf04ede6759380a1acf2a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e8ff4274b4e16a06465f05271423a2cc87774475 e7a04a708a3128847d3d1b6c9d4d5851f54aef07 && git checkout -B drift-repro e8ff4274b4e16a06465f05271423a2cc87774475 && git merge --no-ff e7a04a708a3128847d3d1b6c9d4d5851f54aef07
node scripts/docs-audit/affected-docs.mjs --json e8ff4274b4e16a06465f05271423a2cc87774475

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

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

PM review — accepted for landing (held in draft until CI is complete and green)

Reviewed at head e7a04a708, three-dot against the branch's real merge base a286411df.

The #13077 fence held — measured

Five files: the changeset, form-predicate-root-policy.{ts,test.ts}, and the two declared packages/metadata widenings. Zerocontent/docs/**, zeropackages/lint/** — the paths #13077 owns — and zero governed surfaces, content/docs/releases/** or packages/spec/src/**. The same four patterns matched 4/4 against a synthetic control list, so the zeros are real.

That fence was the whole reason I narrowed this dispatch against a stale instruction on the card. It held.

⭐ The fork clause did not fire, and the reasoning inverts the question correctly

My dispatch's stop condition was: if collapsing the per-surface split turns out to force a public-contract choice rather than a detector's internal vocabulary, stop and report.

The report answers it by measuring that FIELD_ONLY_BOUND_PREDICATE_ROOTS has never shipped. I checked the repo-local half myself on origin/main:

readingresult
.changeset/artifact-unbound-form-predicate-root-notice.md still pending1
.changeset/form-predicate-root-vocabulary-per-surface.md still pending1
git tag --contains ba2ffbcaf(empty)
control.changeset/*.md files present at all465

A changeset still sitting in .changeset/ is a feature that has not been released, and the introducing commit is in no tag. So no upgrader can hold that export.

And the inversion is the good part: shipping it as an empty constant still named FIELD_ONLY_… would have created the published-contract problem the fork clause exists to catch, not avoided it. Removing it now is the conservative move, not the aggressive one. That is a sharper reading of my own stop condition than the one I wrote.

⭐ The stale-quote handling is better than what I asked for

I said: the test quotes the retired sentence verbatim, no gate reads comments, handle it — ⛔ and not by deleting the comment.

It kept the quote as dated history and as the record of how this file failed, and replaced it as a mechanism: the test now reads the live .describe() text of FormFieldSchema.visibleWhen and FormSectionSchema.visibleWhen out of @objectstack/spec/ui and fails when it stops agreeing with the list, with a helper that turns an unreadable accessor into a RED test rather than an undefined that would satisfy every assertion beneath it.

That last clause is the one that matters. An accessor returning undefined is exactly how a "live read" quietly becomes a no-op, and catching it is the difference between a mechanical guard and a decorative one.

⭐ The root cause is named, not just the symptom

The card noted this module had already paid for this mistake once on the FIELD half. The PR states why it happened twice:

Prose is a transcription of a renderer, so it can only ever lag one.

and restates membership as the mechanisma root is bound on a surface iff some renderer threads a scope carrying it into that surface's evaluator — with the threading site named per entry (objectui#6010 for the field slot; objectui#6110's isSectionVisible threading plus objectui#6111's section-divider pseudo-field for the section slot). That is refutable by going and looking, which a quoted sentence is not. Exactly the falsifiable grounding the dispatch asked for.

The declared widening is acceptable

Two packages/metadata files beyond the dispatched surface, both declared rather than slipped in:

  1. plugin-unbound-form-predicate-roots.test.ts — its case pins the exact behaviour this card removes, so the fix cannot land without it. Inverted in place with a comment naming the contract change, plus a new companion control that keeps the section arm non-vacuous. Forced, and handled the required way — ⛔ not skipped, disabled or re-baselined.
  2. plugin.ts — comment only, carrying the same stale claim one file over. Leaving it would have reproduced this card's own defect in a neighbouring file.

⭐ And it checked both against the open-PR set before touching them. That is the discipline I adopted an hour ago after my #13279 dispatch duplicated #13282's work — arrived at independently here.

Verification

The honest parts are the ones worth naming:

  • The full workspace build was SIGKILLed at 13m41s (exit 137) on a contended box, and it left packages/spec/dist without declaration files. That was detected and repaired before any reading was taken, rather than silently poisoning the greens beneath it.
  • For the one gate that could actually have moved, the narrowing was replaced by a direct measurement instead of left as a gap: @objectstack/metadata is the only ledgered package in this diff, and tsc --noEmit -p packages/metadata/tsconfig.json reports 89 — the frozen figure exactly — with zero errors in either edited file.
  • Four gates read NOT MEASURED, each quoting its own refusal text; none counted as a pass.
  • ⚠️ The ablation's --absent preflight leg reports exit 1, and rather than hand-waving it the report identifies the cause: current_user survives in dist/index.d.ts because it appears in the module's doc prose, which tsup carries into declarations. The executable outputs are the discriminator, and they moved as required (mutated leg: no dist/index.js / dist/index.cjs hit; restored leg: both). Naming a marker-choice artifact as such, instead of reporting a clean --absent, is the right call.
  • Ablated result 5 failed / 22 passed in the policy suite and 4 failed / 6 passed at the door; restore verified by git hash-object against the HEAD blob, not by the restore command's exit code.

Fixes #13072 is correct

Sites 2–4 belong to #13077, which stays open. Site 1 is the whole of what remained on #13072, so closing it on merge is right — and Part-of PR must not also close its card does not apply here because this is not a Part of PR.

Landing posture

Not enqueued. The PR opened minutes ago and CI has not completed. Bar is EVERY check completed and green; total_count grows as rollup rows appear. Holding in draft; I'll flip ready and arm when it clears, then verify by content on origin/main.

Nothing to change.


Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

Docs-drift advisory — resolved by measurement. Nothing to change here.

Four hand-written pages listed, all via the /f/:slugroute anchor — a mention, not a claim this diff could falsify. So I searched for the one claim class it could falsify (a page asserting current_user is unbound at section level) rather than re-reading four pages or waving them off.

Read on origin/main. Pages that name both FormSection and current_user:

pagedisposition
content/docs/protocol/objectui/layout-dsl.mdx#13077 owns it (that card's site 3) — deliberately not touched here
content/docs/references/ui/view.mdxgenerated, not hand-written; it derives from packages/spec/src/ui/view.zod.ts, which #12914 / PR #13074 already corrected and regenerated
content/docs/releases/v15.mdx⛔ release-owned, read-only
content/docs/releases/v17.mdx⛔ release-owned, read-only

Positive controls: 43 pages name current_user, 5 name FormSection — so the corpus is searchable and this is a reading, not a dead grep.

⚠️ Worth naming explicitly: content/docs/ui/views.mdx appears in the advisory list but does not pair FormSection with current_user. It is there on the /f/:slug route anchor alone. It is separately #13077's site 2, for a different sentence.

⇒ Every page carrying a falsifiable section-binding claim is either owned by #13077, generated and already correct, or release-owned and read-only. This PR changes a detector's internal vocabulary in @objectstack/metadata-core — not the spec contract text the reference page is generated from — so it cannot falsify that page even in principle.

Nothing filed, nothing edited. Recorded so the next reviewer does not have to re-derive that the four rows are mention-only.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 30, 2026 05:32
@os-zhuang
os-zhuang enabled auto-merge August 30, 2026 05:32
@os-zhuang
os-zhuang added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 200d255Aug 30, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13072-form-predicate-root-policy-section-remeasure branch August 30, 2026 06:07
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude