Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain - #6920
Conversation
…rget chain `RecordDetailDrawer` resolved a relationship target through four spellings: def.reference_to ?? def.reference ?? def.referenceTo ?? def.target Two of them are declared by no contract. `@objectstack/spec`'s `FieldSchema` refuses both `referenceTo` and `target` by name with `unrecognized_keys`, each carrying its own "did you mean `reference`" rename; `referenceTo` is also stripped at the designer read door (`RETIRED_FIELD_KEYS`). So they were not redundant fallbacks but invented tolerance surface -- a silent absorption point for a producer that ought to fail visibly (AGENTS.md #0.1). A repo-wide structure-walk producer census (TypeScript compiler API over every tracked source, plus parsed JSON/YAML, recording each hit's ancestor property chain; emit positions only) found 0 producers of `target` and 0 reaching this seam for `referenceTo`, measured in the cell the drawer reads -- a value inside an object schema's `fields` container -- against controls `reference` (92 hits / 36 files) and `reference_to` (52 / 36) hot in the same pass over the same cells. The two surviving arms are deliberately untouched: choosing between them per reader is the open scope of objectui#6837, whose triage refused a single mechanical sweep because the ~20 remaining readers are fed by different contracts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
…orddrawer-invented-arms
✅ 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
|
os-sam
commented
Aug 30, 2026
CONTRACT_REVIEW_TIER review — head |
| term | repo-wide (mine) | in-cell strict | in-cell loose | PR claim |
|---|---|---|---|---|
target | 1311 (+19 shorthand) | 0 | 0 | 1329 / 0 ✓ |
referenceTo | 78 | 1 | 2 | 80 / 2 ✓ |
reference (control) | 192 | 89 | 92 | 194 / 92 ✓ |
reference_to (control) | 137 | 52 | 52 | 136 / 52 ✓ |
Both halves stated: the controls are hot in the same query on the join (92/52 in the very cells the zero lives in, one pass, same units), and the subject target is itself hot repo-wide, collapsing to 0 only under the cell restriction — the zero is produced by the restriction, not scanner blindness. My second, independent type-keyed cell (EXPANDABLE_FIELD_TYPES sibling) agrees: target 0 and referenceTo 29/13 after excluding this PR's own pin probes — every one a test fixture at other seams (action params, filter builder, retirement machinery, the normalizer's own test), none in plugin-detail production.
- The 1329 attribution holds on my re-derivation and on samples: my histogram is
api136,url35,script32,form15,flow13,back9,modal6,fault5, ~1046 with no siblingtype(DOM event/link targets) — sampled hits are action-node endpoint URLs and the action renderers forwardingschema.target. The singlelookup-siblingtargetemit in the whole repo is this PR's own refusal probe (referenceArms-6837.test.tsx:127) — a negative fixture this PR added, not a producer. - The 2 in-cell
referenceTohits are what the PR says they are — I read both:MetadataFieldsPage.specKeyReference.test.tsx:75poisonslegacy_idto assert the read door strips it;object-fields-io.spec-keys.test.ts:235feedsreferenceTothroughroundTrip()to assert the emitted body parses clean. Negative fixtures of the retirement machinery, verbatim.
The spec probe — all four verified two-directionally
Against installed @objectstack/spec 17.2.0 (/data subpath), at both FieldSchema and ObjectSchema.fields level: referenceACCEPT; reference_to, referenceTo, target each REFUSE with unrecognized_keys carrying its own "Did you mean X → reference?" rename. And target: "reference"is in the aliases map (spec dist/data/index.js:2113, beside targetObject: "reference"). The PR's correction to the card is accurate, and the "rename hint attached to a refusal" framing is the measured truth — this is what makes the deleted arms invented tolerance rather than redundancy, and it holds.
Ablation leg C — re-run from scratch, matched exactly
On the merged head: unmutated pin 8 passed. Four-arm chain restored (anchored counts live 1→0 / mutated 0→1; blob 69b27fb6b → 022e83fd): 3 failed / 5 passed — exactly the two named refusals plus the target-through-normalizer case, with all live-arm controls green (which is what makes them controls). Prediction written before running; matched. Restore proven both ways scoped to the path (blob back to HEAD's 69b27fb6b, scoped diff empty, tree clean). Provenance claim verified too: the file's blob at 04cada79 is exactly ec43039c…, so the PR's ablation hashes are the pre-merge blob as stated. Resolution path confirmed: pin imports ../RecordDetailDrawer relative and root vitest.config.mts:276 aliases @object-ui/core to packages/core/src — no dist leg.
Also reproduced on this head: the full plugin-detail suite — 115 files / 1053 tests passed, identical to the claim — and type-check exit 0 with the script echoed (tsc --noEmit && tsc -p tsconfig.test.json) after building the dependency closure. Before the closure was built, the same command produced TS2307 — the false RED the discipline names — and it was treated as NOT MEASURED, not as a colour, until the build made it a real reading. --listFiles on the test project lists the pin exactly once; the build project lists it zero times (the test glob is excluded there), confirming the pin is covered by the project that can.
The #6915 merge — nothing of theirs reverted
#6915 landed as 0e292dd62 (now main, this PR's base). git diff origin/main..HEAD on RecordDetailDrawer.tsx shows only the chain hunk; #6915's width docblock ruling stands verbatim at :76-92 on the PR head, and their changeset drawer-bucket-pointer-ruling.md is present and untouched since main.
Scope discipline — confirmed from the diff, not the prose
Exactly 3 files. Surviving arms reference_to / reference untouched; the adjacent reference_field line untouched; none of the other ~20 readers touched. Refs #6837 with no closing keyword in body or either commit message, and GitHub's own closed_by_pull_requests on #6837 reads 0 — the card stays open. The changeset grades minor, pre-applying the exact blocking follow-up #6916's review had to dispatch.
⭐ The section-7 refusal — correct, and worth saying so on the record
I re-measured the adjacent chain's key pair in the same pass: referenceField 0 in-cell (6 repo-wide) — but the control reference_field is also 0 in-cell (15 repo-wide). The control is cold, so that zero distinguishes nothing: it would look identical if the cell definition were wrong for that key. Declining to delete on it was the right call, and recording it in the open card's scope instead of filing a duplicate was the right disposal. This is the four-times-burned rule doing its job before the burn, not after.
NOT MEASURED, declared as such
The PR's exact scanner bit-for-bit (not committed — verified in substance by two independent re-derivations); check:spec-floors (left as PREREQUISITE NOT MET exactly as the PR reports it — not converted to a colour); the repo-wide lint/gate farm (CI's). No shared-verify-lock contention was encountered in any of my runs (0s waits throughout) — reported as a measurement.
One non-blocking observation
For an off-contract third-party producer, the post-change failure mode is still silent — the field loses its relation resolution and renders its raw id (readonly either way, so nothing becomes editable; milder than #6916's silently-wrong number). A loud dev-mode diagnostic would belong at the choke point or the producer, which is #6837's open per-reader scope and a design decision — recorded, not required.
Generated by Claude Code
Uh oh!
There was an error while loading. Please reload this page.
Refs #6837
⭐
Refs, deliberately — not a closing keyword. This is the first slice onlyof #6837. The card's remaining scope is the per-reader classification table over ~20
more readers in eight other packages, and triage refused a single mechanical sweep for
them: ⛔ 不得开一个 PR 把二十处一起改掉. #6584 already demonstrated what closing on the
first slice costs — a card that closes takes its deferred half with it, and that bucket
question lost its home for four days. #6837 stays open after this lands.
RecordDetailDrawerresolved a relationship target through four spellings:Two of them are declared by no contract. This PR removes exactly those two, and leaves
the other two alone.
1. The producer census came first — it is a precondition, not a formality
Triage made it one, with a measured reason: 没有正对照的零,在本仓最近的卡里已经连翻
四次(objectstack#13293 / #13304 / #13305 / #13306).
Method — the one #6719 established and triage passed forward: a structure walk,
⛔ not text matching. The TypeScript compiler API over every tracked
.ts/.tsx, plusparsed JSON and YAML; each hit records its ancestor property chain, so a hit's cell
is established structurally rather than by a line window. 5851 tracked files, 1819 hit
records.
EMIT positions only. A
PropertyAssignmentis a producer; aPropertyAccessExpression(
def.target) is a reader and is never counted.PropertySignature— a typedeclaration — is bucketed separately. Counting reads instead of emits is precisely the
wrong-question failure the control exists to catch.
THE CELL is a value inside an object schema's
fieldscontainer — literally whatthis component reads,
objectSchema.fields[name].Sweep A — repo-wide, every emit position, any depth
targetreferenceToreferencereference_toSweep B — THE CELL (emit inside a
fieldscontainer)targetreferenceToreferencereference_toThe 2 in-cell
referenceTohits are negative fixtures of the retirement machineryitself —
object-fields-io.spec-keys.test.ts:235andMetadataFieldsPage.specKeyReference.test.tsx:75both poison a draft with the retiredkey precisely to assert the read door strips it, so the body comes out
ObjectSchema-parseable. A fixture asserting removal is not a producer.A second, independent cell test — because one cell definition is not enough
The ancestor-chain test has a blind spot: a field def built in a variable and only later
assigned into
fields. So a second test that does not rely on afieldsancestor wasrun — the enclosing object's own
typevalue being reference-bearing(
EXPANDABLE_FIELD_TYPES=lookup/master_detail/tree/user, the same familythe drawer's own
isExpandableFieldTypereads):targetreferenceToreferencereference_toThe two tests agree on
target: zero, both ways. All 29referenceTohits are testfixtures at other seams — action params (
paramToField,resolveActionParams,ActionParamDialog), the filter builder, the retirement machinery — none inplugin-detail, and none reaching this drawer. The one non-test emitter,resolveActionParams.ts:532, writesreferenceToonto anActionParamDef, which is theaction-parameter contract's own live key and a different tier; it never becomes a
fieldscontainer. (That reader is itself one of the ~20 named in the card body — out of fence
here.)
⭐ Both halves of the control discipline, stated separately
Triage required the control on the join — the cell the zero lives in — not merely on
the terms.
Half 1 — the query ran. The controls are hot in the very cells where the subject zero
lives: 92 and 52 hits, from the same pass.
Half 2 — the question was right. A hot control proves the query ran; it does not prove
the question was right. The second half is that subject and control terms were extracted
by the same pass, from the same cells, in the same units — emitted keys on a field
definition, which is exactly what
targetandreferenceTowere counted as. Had thequestion been wrong (scanning read positions instead of emit positions, or a cell
definition that excludes real field defs), the controls would have moved together with
the subjects rather than separating 92-to-0.
There is a third, unusually direct check available here: the subject term
targetisitself hot repo-wide — 1329 emits — and collapses to 0 only when restricted to the cell.
So the zero is produced by the restriction, not by a scanner that cannot see the word.
Attributed by the enclosing object's own
typevalue, all 1329 belong to a differenttier:
api(137),url(35),script(32),form(16),flow(13),back(9),modal(6),fault(5) — action and navigation nodes — plus 1062 with no siblingtypeat all (DOM event targets, link targets). Not one lands on a field definition.
⭐ The recorded exclusion was honoured and not re-opened:
examples/schema-catalog/src/schemas/fields-lookup/*.jsonare{ type: 'form', fields: [...] }UI component schemas — ObjectUI's own view/fieldtier, not object metadata documents. They carry
reference_to, are untouched by this PR,and appear in the census only as context.
The stop-and-report branch was not reached. Had
targetshown a producer at thisseam, this would have gone back as a report rather than a deletion.
2.⚠️ A correction to the card's stated premise — the conclusion survives, one supporting fact does not
The card and the dispatch both say
targetis "not in the spec's alias table". That isnot accurate, and the record should say so.
targetis in the alias table — it sitsin
FieldSchema's ownaliasesmap in@objectstack/specastarget: "reference".What the table does, however, is the opposite of declaring the key. Measured
two-directionally against the installed spec:
reference: 'crm_account'reference_to: 'crm_account'unrecognized_keys, "Did you meanreference_to→reference?"referenceTo: 'crm_account'unrecognized_keys, "Did you meanreferenceTo→reference?"target: 'crm_account'unrecognized_keys, "Did you meantarget→reference?"So the alias entry is a rename hint attached to a refusal, not an acceptance. The
operative claim — no contract declares these two spellings — is therefore stronger
than the card put it: the spec names
targetexplicitly, in order to refuse it. Thepremise holds; only its supporting detail needed correcting.
3. Why these two were invented tolerance surface, not redundancy
referenceTo— inRETIRED_FIELD_KEYS(objectui#6041, the card cites finding(app-shell):object-fields-iostrips onlyindexedon read, so the four other retired designer keys round-trip straight back out to a 422 #6519), strippedat all three designer strip sites, so the read door removes it before a draft ever
round-trips.
target— refused by name, and never read anywhere else in the stack:normalizeFieldReferenceKeysreadsreference_to ?? reference ?? referenceToand doesnot read
target. It has no home in the spec, none at the choke point, none in theretirement registry.
⇒ Each was a place where a producer emitting a refused spelling would be silently
absorbed rather than failing visibly (AGENTS.md #0.1) — the exact tension the card was
filed to record, under a normalizer whose own docstring says it runs at the choke point
"so per-consumer dual-key fallbacks can't drift".
Accept-set move, stated in the changeset: a def carrying only
referenceTo, or onlytarget, stops resolving a target. Bounded by the ingestion door — areferenceTo-onlydef that came through
normalizeSchemaReferenceKeysstill resolves, because the chokepoint stamps both snake_case keys before the drawer sees it. Only a def that bypassed that
door entirely is affected, and for
targetnot even that door ever helped.reference_toandreferenceare deliberately untouched. Choosing between them perreader is #6837's open scope.
4. ⭐ Pin form — copied from PR #6916 (card #6840), not invented
Per triage's family rule — 谁先落地谁把判据写成可复用的形制,后一张照抄,⛔ 不要各自发明
一套 refusal-pin 写法 — #6916 published the form first, in its section 5. All five
elements are adopted in
packages/plugin-detail/src/__tests__/RecordDetailDrawer.referenceArms-6837.test.tsx.(#6916 is under clause-② review; as of this writing no review comment has changed the
form, so the published form governs.)
counts and the control counts side by side, so the pin carries its own evidence and a
later reader can tell a measured zero from an assumed one.
reference_toandreferenceresolving, the full field list deriving, the relation still marked readonly.Without them, a drawer that simply stopped resolving anything would pass the refusals
too.
does NOT read 'referenceTo'anddoes NOT read 'target', each asserting the honest zero.deleted arms sat at the end of the chain, so neither could ever preempt a
contract-carrying spelling. The pin therefore says so in prose rather than
fabricating a case: a
{ reference: 'a', target: 'b' }case would resolve to'a'both before and after and would measure nothing. Copying the honesty matters more than
copying the case list.
a refused spelling is fixed at the producer or canonicalised once at the choke point,
never by a renderer-side alias. And ⛔ the two surviving arms belong to finding(objectui): ~20 more relationship-target readers still run a legacy-first
reference_tochain the ingestion normalizer already made redundant #6837's openscope.
Two extra cases carry the mechanism rather than just the verdict: a
referenceTo-onlydef through
normalizeSchemaReferenceKeysstill resolves (so the deletion is losslessat the ingestion door), while a
target-only def does not resolve even through thenormalizer (nothing in the stack ever declared it).
5. Verification
Module resolution path, stated first because the ablation stands on it: the pin imports
the component by relative source path (
../RecordDetailDrawer), and@object-ui/coreis aliased by the root vitest config to
packages/core/src. Both legs resolve tosource — no package
exportshop, nodist/, therefore no rebuild leg that couldleave the ablation measuring stale output.
RED first — this change is behavioural, and the pin was red
A def carrying only
targetused to resolve and now does not, so the pin can be red andwas. The fact was mutated, never the assertion — the chain in the source was put back,
under a
trap ... EXIT INT TERMwith absolute paths resolved fromgit rev-parse --show-toplevel, restore pinned togit checkout HEAD -- path(never thebare form, which restores from a possibly-mutated index).
?? def.referenceTorestoredreferenceTorefusal red, rest green?? def.targetrestoredtargetrefusal and the through-the-normalizer case redLeg C is the red-first reading against the pre-change source. Every prediction was
written down before the run and all three matched.
Mutation proven on disk, not by the editor's exit code — an anchor-miss aborts the leg
rather than reporting a silent no-op: anchored occurrence counts (live chain 1→0, mutated
chain 0→1) and
git hash-objectdiverging from the HEAD blob(
ec43039c…→6ebae4f5…/d75723ec…/f05f5819…).Restore proven both ways, scoped to the mutated path only, after every leg:
git diff HEAD -- pathempty andgit hash-objectequal to the HEAD blob(
ec43039c…), with the whole tree clean at the end.The rest
vitest runthe pin fileplugin-detailfull suitevitest run packages/plugin-detail/src/plugin-gantt,plugin-calendar,plugin-kanban,plugin-dashboard--filter @object-ui/plugin-detail run type-checktsc --noEmit && tsc -p tsconfig.test.jsontsc -p tsconfig.test.json --listFiles--filter @object-ui/plugin-detail run lintcheck:control-bytes,check:vi-mock-specifiers,check:vi-mock-inherit,check:self-import,check:esm-specifiers,check:phantom-deps,check:designer-field-key-parity,check:element-data-source-declaration,check:side-effects-array,check-changeset-no-major--listFileswas read carefully, because "type-check is clean" can be a true sentencethat says nothing about a new test file.
tsconfig.jsonexcludes**/*.test.tsx, sothe build project sees the pin 0 times;
tsconfig.test.jsonexists precisely to covertests and sees it 1 time. Both projects see the edited source once. So the pin is
type-checked — by the project that can.
Lint compared against the BASE version of the same file, not a bare count. The edited
file at base blob
da15724a…and at this branch's blobec43039c…, counts read from--format json: 0 errors / 8 warnings, identical both ways. Blob ids were verified inboth directions and the restore was proven by an empty
git status. The new pin file hasno base version and is reported standalone: 0 errors / 6 warnings, all
@typescript-eslint/no-explicit-any— the same rule and the same class as its closesthouse analogue
expandableFamily.identity-5874.test.tsx(5 of the same).NOT MEASURED — reported as such rather than as a colour
check:spec-floorsexits 1 with 12 findings, all[no-artifact], zero of them namingplugin-detail. The gate prints its own remedy: "Build the workspace before running thisgate:
pnpm exec turbo run build --filter=!@object-ui/site". This worktree built onlyplugin-detail's dependency closure, so 12 packages have nodist/to judge. This is aPREREQUISITE NOT MET, not a red — and this diff adds no spec import and moves no
dependency range. Left to CI rather than reported as either colour.
Scope of the local run, declared
The repo-wide
eslint .and the remaining gate farm were not run locally; CI runs thefarm exactly once regardless. Type-aware linting is not enabled (no
projectService/project:ineslint.config.js), so this diff cannot move the verdict of any file it doesnot touch.
Tree provenance. The ablation and its blob hashes were produced on commit
04cada79.origin/mainthen moved under this branch and #6915 landed, soorigin/mainwas merged in— see the next section — and the suite plus type-check were re-run on the merged head
7856fa8c, both green, withgit statusclean.6.⚠️ The flagged file overlap with #6915 — resolved, and nothing of theirs was touched
RecordDetailDrawer.tsxwas flagged as also touched by then-unlanded PR #6915 (card#6584). It landed first.
origin/mainwas merged into this branch: clean, noconflict. Their hunk is a comment-only addition to the
widthdocblock at ~:77-87recording the 2026-08-27 ruling; mine is the chain, which the merge moved from :267 to
:300. Disjoint hunks, no semantic overlap, and nothing they added was reverted or
"fixed" — their docblock and their changeset are present and untouched.
(For the record: the card's cited
:267was still exact onorigin/mainat branch time —re-located by text, not trusted as a line number.)
7. Out of scope — recorded, not fixed
⛔ The ~20-reader sweep is not here. The classification table — per reader, whether its
caller feeds an object metadata document or ObjectUI's own view/field contract
(
DetailViewFieldSchema) — remains #6837's open scope, which is why this PR saysRefs.One adjacent observation, and the census refused to license acting on it. The very next
line is a two-spelling chain on a different key:
FieldSchemarefuses both spellings by name, andreferenceFieldhas 0 in-cellproducers — but a first census pass returns 0 for the control
reference_fieldaswell. ⇒ The control is cold, so that zero is not a measurement, and no deletion is
justified on it. Recorded here rather than acted on or filed as a fresh card: it is the
same per-reader question #6837's classification table already owns, on a card that stays
open. Deleting an arm on a cold-control zero is exactly what the four-times-burned rule
exists to prevent.
Authored by Claude Code in session
https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB(recorded in prose because anedited PR body drops the session form from the footer).
Generated by Claude Code
Generated by Claude Code