fix(plugin-detail): compile the reference rail's parent scope by relationship arity - #9200
Conversation
…tionship arity `record:reference_rail` compiled an author-supplied `relationshipField` as bare equality twice, in two different grammars: the `$filter` on the wire and the `filter[<field>]=<value>` URL behind "View All". On a `multiple: true` relationship the stored value is an array, equality asks whether that whole array is one id, and the driver refuses with `400 INVALID_FILTER`. The filter half now goes through `composeParentScopeFilter` from `@object-ui/core`, the one compiler the related list's rows and tab badge already share. The child schemas are resolved before the reads, not after a refusal: the rail dispatches once per (record + entries) and has no second attempt to correct itself with. Resolution is best-effort, never a gate. The link half cannot be expressed: no URL spelling on this surface carries membership, so it is suppressed on a multi-value relationship and the reason is logged once. Single-value entries keep both halves byte for byte. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
…arity Brings in objectui#8882 (PR #9184): `composeParentScopeFilter` / `isMultiValueRelationship` in `@object-ui/core`, the seam this branch is the first caller of. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
`tsc -p tsconfig.test.json` reported TS7006 on the two inline `warnSpy.mock.calls.filter` predicates. One typed reader now serves both assertions. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…arity Routine sync before the objectui#8883 rework; no conflict with the rail. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
…in the rail header
The header claimed the seam's verdict was "the same predicate the driver that
executes the query decides on". It is not. Measured at source: the spec asks
`MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) && multiple ===
true)`, while the SQL driver's `isJsonField` asks `JSON_COLUMN_TYPES.has(type)
|| !!field.multiple` — the flag on any type — so `{type:'master_detail',
multiple:true}` answers false to one and true to the other.
The paragraph now states the relationship it measured instead of an identity,
names which rule this renderer follows and why, and cites objectstack#17469,
which owns the divergence. The ⛔ "no local arity rule here" instruction is
kept; it was the true half.
Prose only: every changed line is inside the file docblock. No predicate, no
assertion and no executable line moved.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
PM review — ACCEPT on substance, one prose rework before armingRead against Every ruling-zone item holds, measured here: R2 — ⭐ M2 was the question I most expected to be answered by resemblance, and it wasn't. The rail resolves arity before the reads for a reason that is its own, not the tab badge's: ⛔ Rework: the file header states something false, and it collides with a pin in flight
protected isJsonField(type: string, field: any): boolean {
if (!field.multiple && FILE_REFERENCE_TYPES.has(type)) return this.mediaColumnIsJson();
return JSON_COLUMN_TYPES.has(type) || !!field.multiple;
}
The collision, measured rather than predicted. PR #9195 adds a sweep: every tracked file under
⇒ whichever of the two lands second, that pin goes red. Correcting the paragraph here (prose only, ⛔ no predicate and no assertion moves) is cheaper than a round on either PR. ⭐ Instrument note for anyone chasing this claim: Arm held until the rework lands and CI reads Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Rework ACCEPTED — the pin collision is cleared, measured with a lit controlHead
⇒ the file is now outside PR #9195's pin-three failure set by construction, not by luck. The false claim is gone, and the probe is proven to be looking: grepping the pre-rework head Prose only, verified rather than asserted: every changed line in that file begins with ⭐ Two things the seat did that I want on the record. It re-measured the driver rule at source ( ⛔ Arm still held: 36 checks, 9 in progress, 0 failing. Not-red-yet is not green. Generated by Claude Code |
⛔ Ejected from the merge queue — cause found, and it is my missThis PR entered the queue at 17:54 as The PR is open, unmerged, and green on its own head: 36 checks, 0 pending, 0 failing at The causal chain, every link measured
This PR's new test file falls into it:
This PR's base is
|
…arity Brings in objectui#8937 (PR #9195), whose residue sweep pin now requires every file naming both the spec arity predicate and the driver to cite objectstack#17469. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
…e 8883 rail pin objectui#8937's residue sweep (PR #9195, pin three) requires every tracked file under `packages` that names both the spec arity predicate and the driver to cite the card that owns their disagreement. This test file entered that population when it landed and did not cite it, so the sweep reds on the combined tree even though it is green on this branch's own base — which is what ejected the PR from the merge queue. The docblock now states the relationship it was implying: the spec gates on the field TYPE, `driver-sql` decides storage on `!!field.multiple` whatever the type, the two part company for a `multiple: true` def on a type outside the spec's multi-capable set, and objectstack#17469 owns which is right. It also records why these cases sit clear of that disagreement — they relate through a `lookup` field, which both rules call multi-valued. Prose only: the diff adds docblock lines and removes none. No predicate, no assertion and no executable line moved. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Third round ACCEPTED on content — three state questions answered. ⛔ Arm still held (5 checks running).The fix, verified on the tree that actually matters⭐ The seat reproduced the ejection locally before editing anything — pin three on the combined tree read I re-measured independently, and deliberately on the merged tree rather than this PR's head —
⭐ Leg G is the leg that matters, and it is the one I would have asked for: remove the citation, leave the Q1 — the review gate: A, leave it hung; I clear both in one stroke after reviewing this headThe re-hang is owed and correct. My clear at 17:53 judged ⭐ And the self-disclosure is the part worth keeping: the seat made a split write (PR only), Q2 — the draft flag: A, leave it"⛔ do not flip ready" meant do not flip it ready; it was never an instruction to revert a flip I made. Reverting another actor's state write is not the seat's call and it was right not to. Non-draft with Q3 — attribution: A stands, and the conflict is escalated, not resolved hereThe commits are pushed, and ⛔ rewriting pushed history over a trailer is forbidden by this lane's own rules — so A is the only available answer regardless of which form is preferred. The underlying conflict (this session's attribution instruction names a model identifier; the standing rule forbids one in pushed artifacts) is flagged to the maintainer and is theirs to settle. ⭐ Flagging rather than choosing silently was right.
|
Contract review — head
|
Fixes #8883
record:reference_railcompiled an author-suppliedrelationshipFieldas bareequality twice, in two different grammars — the
$filterit puts on thewire and the
filter[FIELD]=VALUEURL behind its "View All" link. On amultiple: truerelationship the stored value is an array,=asks whetherthat whole array is one id, and
driver-sqlrefuses with400 INVALID_FILTERwhile prescribing$contains.What changed
Half one — the
$filter, repaired. The condition is composed bycomposeParentScopeFilterfrom@object-ui/core: the ONE compiler the relatedlist's rows (#7299) and its tab badge (#8882, PR #9184) already share, whose
arity verdict is
@objectstack/spec/data's ownisMultiValueField. No localarity rule was written at this call site, and no authoring key was added.
Half two — the link, refused. No URL spelling on this surface carries
membership, so the link is suppressed on a multi-value relationship and the
reason is logged once. Losing that affordance is the cost of the repair,
not its purpose — which is why it is a seat-level call while growing the URL
operator set is not.
Landing half one alone was explicitly out: it would show the right rows above a
link that goes somewhere else, a disagreement invisible until the user clicks.
Measurements
M1 — does the rail's
dataSourceexposegetObjectSchema? YES, and it isthe same object the rows read it off.
getObjectSchemais a required(non-optional) member of
DataSourceinpackages/types/src/data.ts:539.record-related-list.tsx:248handsctx?.dataSourcestraight intoRelatedList, which readsdataSource.getObjectSchemaatRelatedList.tsx:515— the rail holds that same value atrecord-reference-rail.tsx:113.ObjectStackAdapter.getObjectSchema(
data-objectstack/src/index.ts:4660) is cached per object name, so N railentries cost N cached metadata reads, not N round trips. Measured, not assumed
from the badge's result. Counter-case measured too:
PagePreview.tsx:255mounts
RecordContextProviderwith nodataSource, so the rail's fetcheffect early-returns there and nothing changes.
Third round — the merge queue ejected this PR, and the cause was a second uncited file
The PR was green on its own head and red in the queue, which is the tell that
mainmoved under it.1f4e02995(objectui#8937 / PR #9195) landed a residuesweep in
packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts:every tracked file under
packagesnaming BOTH the spec arity predicate andthe driver must cite
objectstack#17469. Round two cleared the renderer. Thesweep's population is the CHANGED SET, and this PR's test file was in it
too.
Reproduced locally rather than inferred —
origin/mainmerged in, then the pinrun on the combined tree before any edit:
Readings on the test file, and the population re-derived with the sweep's own
enumeration (
git grep -l isMultiValueField packages, minusCHANGELOG.md,split by
/driver/i):record-reference-rail.parentScopeArity-8883.test.tsxisMultiValueFielddriver(case-insensitive occurrences)objectstack#17469The docblock now states the relationship it was implying: the spec gates on the
field TYPE,
driver-sqldecides storage on!!field.multiplewhatever thetype, the two part company for a
multiple: truedef on a type outside thespec's multi-capable set, and objectstack#17469 owns which is right. It also
records why these cases sit clear of that disagreement — they relate through a
lookupfield, which both rules call multi-valued.Pin three on the merged tree:
Tests 8 passed (8). And it discriminates onTHIS file, proved rather than assumed — leg G below replaces the citation token
with plain words (occurrences 1 → 0,
drivercount unchanged at 7 so the filestays classified as a carrier) and the pin reds again with exactly one uncited
file, then restores under a trap.
The diff adds docblock lines and removes none; no predicate, assertion or
executable line moved. The 7-leg rail matrix was re-run afterwards and no leg
moved — same six assertions, same red combinations, pre-fix leg still
3 failed | 3 passed (6).pnpm --filter @object-ui/plugin-detail teston themerged tree at
33bb17450:Test Files 172 passed (172) / Tests 1595 passed (1595)(one file and eight cases more than round two — the sweep this PR nowsatisfies came in with the merge).
Rework (2026-09-11) — prose only, no executable line moved
The file header carried a sentence that was FALSE: that the seam's verdict is
"the same predicate the driver that executes the query decides on". Measured at
source in the sibling checkout,
packages/drivers/driver-sql/src/sql-driver.ts:!!field.multipleon ANY type, against the spec'sMULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) && multiple === true).So
{ type: 'master_detail', multiple: true }answers false to the spec andtrue to the driver. The paragraph now states that measured relationship
instead of an identity, names which rule this renderer follows and why, and
cites objectstack#17469, which owns the divergence. The ⛔ "no local arity
rule here" instruction is kept — that half was true and load-bearing.
The file is now outside the in-flight citation sweep's failure set by
construction rather than by luck. Readings on this head:
record-reference-rail.tsxisMultiValueField/driver/iobjectstack#17469Every changed line begins with
*inside the file docblock — verified byreading the raw diff, not by intent. The 7-leg ablation matrix was re-run
afterwards and no leg moved: the same six assertions go red in the same
combinations, and the pre-fix leg still reads
3 failed | 3 passed (6).pnpm --filter @object-ui/plugin-detail testre-run at8fd5fba02:Test Files 171 passed (171) / Tests 1587 passed (1587).M2 — which trade? Resolve arity FIRST, and for the rail's own reason. Not a
copy of the badge. The rail's
fetchedSigReflatch keys on(parentId + entries); the arity is in neither, so unlikeRelatedList—whose fetch effect re-runs on the verdict and can attempt equality, be refused,
and refetch — a probe-then-correct design would make the refused first attempt
the only attempt, leaving the entry on its error state until the user
navigates away. The rail also renders a navigational link decided by the same
verdict, so deferring the verdict ships the right rows under the wrong link.
The resolution is not a gate: an adapter without
getObjectSchema, or onewhose fetch rejects, still reads rows on the historical equality wire
(pinned as
DEGRADATION CONTROL).M3 — the URL grammar claim, re-measured, and the card's version is
incomplete. The rail's link targets
/apps/:appName/:objectName(
AppContent.tsx:999→ObjectView), not the/datasurface the card andRelatedList.tsx:671-692describe. Both parsers were run on the same input:drillUrlFilters.parseUrlFilterTriples(the/datasurface)ObjectView.urlFilters(this link's target)filter[owners][contains]=u1[]— dropped[["owners][contains","=","u1"]]— mangled into a filter on a nonexistent fieldfilter[owners]=u1[["owners","=","u1"]][["owners","=","u1"]]filter[amount][gte]=100[["amount",">=","100"]][["amount][gte","=","100"]]Both zero/garbage readings have a lit control in the same command. Conclusion
unchanged and strengthened: neither surface can express membership, and the one
the rail actually links to does not even drop the suffix — it emits a condition
on a field name that does not exist. There is no third spelling either: both
readers only look at keys starting with
filter[(
ObjectDataPage.tsx:228,ObjectView.tsx:2088), so no JSON-AST param isavailable on this route.
M4 — is a multi-value
relationshipFieldreachable? YES, and with zeroauthoring.
ReferenceRailEntrySchema.relationshipFieldis a barez.ZodString(no enum, no arity constraint), and the registry manifest(
plugin-detail/src/index.tsx:796) publishes onlyhideEmptyas an input, sonothing upstream narrows it. Stronger: the synthesised path reaches it without
an author at all —
deriveRelatedLists.ts:244admits everylookupfield withno
multiplefilter,lookupis in the spec'sMULTI_CAPABLE_TYPES, andbuildDefaultPageSchema.ts:1059copies thatrelationshipFieldstraight ontoa rail entry. The card's premise holds and is wider than it states.
Instrument discipline
Pre-fix reading — the rail source restored to
origin/main's bytes(
git checkout c5b0cecf6 -- packages/plugin-detail/src/renderers/record-reference-rail.tsx,blob
a5086aa2on disk, verified by hash and by a grep of the injected tokensgoing
3 -> 0and2 -> 0), same test file, same command:Restored by
git checkout HEAD -- ...under anEXIT INT TERMtrap, proved byblob hash
e6f44dd1and an emptygit diff HEAD.Ablation matrix — every leg mutates one thing, proves it reached disk
(anchor token count before/after plus a changed blob hash, so a zero-hit edit
cannot pass as a run), runs the same file, and restores under a trap:
origin/mainbytes!suppressViewAllfrom the link's conditionfieldsfromcomposeParentScopeFilterif (fieldsFor.size === 0) return;— turn the resolution into a GATEAll six assertions discriminate; none stays green while the thing it names is
deleted. Leg F is the one that matters most for reading the rest: the FIXTURE
case is what stops a permissive evaluator from making this whole file lie, and
it is red the moment the evaluator stops refusing.
Commands (from the repo root, at
3ab5db03c):pnpm exec vitest run packages/plugin-detail/src/renderers/__tests__/record-reference-rail.parentScopeArity-8883.test.tsx->Test Files 1 passed (1) / Tests 6 passed (6)pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build->VERDICT command-exit 0, 12 of 47 workspace projectspnpm --filter @object-ui/plugin-detail type-check->VERDICT command-exit 0. It covers the new test:tsc -p tsconfig.test.jsonis the leg that reported the two TS7006s this branch then fixed, so the coverage claim is a reading and not an assumption.pnpm --filter @object-ui/plugin-detail test->Test Files 171 passed (171) / Tests 1587 passed (1587),VERDICT command-exit 0, at3ab5db03c(git rev-parse --short HEADat that run)$?:check:control-bytes(OK, 7393 files),check:spec-symbols(three green lines, incl. "nothing cites a key its spec symbol does not declare" — the gate that caughtrecord:related_listcannot bind to a multi-value relationship field —RelatedListbuilds its parent filter as bare equality, which the driver refuses with 400 INVALID_FILTER #7299's hand-written arity rule),check:changeset-claims(exit 0, report-only),check:new-line-citations(0 new citation(s)),check:phantom-deps(Every in-scope import is declared by the package that publishes it— the one that would have caught an undeclared@object-ui/core),check:i18n-keys(exit 0).pnpm lint, the fullpnpm test) are CI's runs, not this branch's.turbo run lint:eslint --no-inline-config --format jsonover the two changed files reports2 files, 0 errors (27 warnings, all
@typescript-eslint/no-explicit-anyplus one pre-existing
react-hooks/exhaustive-deps). The rail file's ownbaseline was read the same way with its
origin/mainbytes on disk —0 errors / 13 warnings, identical rule breakdown — so this change adds no
lint finding to it. This config enables no type-aware rules
(
eslint.config.jsdeclares noproject/projectService), so the diffcannot move the verdict on any file it does not contain.
check:sdui-registration-pins— it exits 2(
PREREQUISITE NOT MET) without a console bundle, and building@object-ui/consoleneeds that app's whole workspace closure, which is CI'srun. This diff edits no
ComponentRegistry.registercall and adds noregistration, so nothing it changes is in that gate's subject; CI measures it.
Acceptance notes
Found while measuring, deliberately not fixed here and not widened into
this PR:
ObjectViewis a third, drifted reader of thefilter[...]URL family: an operator suffix is swallowed into the FIELD NAME instead of being dropped #9196 —ObjectView(the component behind/apps/:appName/:objectName,i.e. the route this rail's own link targets) is a THIRD reader of the
filter[...]URL family that never joineddrillUrlFilters. Its/^filter\[(.+)\]$/is greedy, sofilter[amount][gte]=100does not fail tomatch and does not get dropped: it emits a condition on a field literally
named
amount][gte. Measured in the M3 table above, with lit controls. Noin-app producer reaches it today (
useOpenRecordListnavigates to/data),so it is latent — but the URL is an addressable surface by design.
RecordContextValue.dataSourceis declaredstringwhile every producer passes a DataSource ADAPTER — a published type that every consumer has to cast away #9197 —RecordContextValue.dataSourceis declaredstring("Optional datasource id") in the published
@object-ui/reacttype whileevery producer passes a
DataSourceADAPTER, which is why a dozen consumersreach it through
(ctx as any). This card is a live instance: the rail'sgetObjectSchemaread goes through one of those casts.RelatedList.tsx:671-692's comment describes the/dataparser's drop behaviour as if it governed the link spellinggenerally. It is correct about the parser it names and this PR does not
contradict it; it is simply not the parser a "View All" link reaches. Not a
defect, no reproducer, and
ObjectViewis a third, drifted reader of thefilter[...]URL family: an operator suffix is swallowed into the FIELD NAME instead of being dropped #9196 is where the real divergence is recorded —filing a second card for the comment would split one finding in two.
Successor: whoever lands
ObjectViewis a third, drifted reader of thefilter[...]URL family: an operator suffix is swallowed into the FIELD NAME instead of being dropped #9196.Scope declarations
master_detailuntouched — settled on the card (comment 5607573447):{type:'master_detail', multiple:true}answersfalse, soLineItemsPanel.tsx:200andMasterDetailForm.tsx:762are correct aswritten.
URL_FILTER_OPSuntouched — growing the URL operator set changes anaddressable public link format on both sides plus its pin; that is a
maintainer floor, not a patch.
packages/plugin-detail/package.jsonuntouched — bug(plugin-detail):@objectstack/specfloor^17.1.0predatesisMultiValueField(17.3.0) — consumers on 17.1/17.2 get an import failure; plus a false driver-arity docblock and a now-false README sentence (residue of objectui#8886 / #7299) #8937 is in flight onthat file, and no edit was needed:
@object-ui/coreis already apeerDependencyofplugin-detail, andcorealready declares@objectstack/spec: ^17.3.0.Generated by Claude Code