Skip to content

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain - #6920

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms
Aug 30, 2026
Merged

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain#6920
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Refs #6837

Refs, deliberately — not a closing keyword. This is the first slice only
of #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.

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. 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, plus
parsed 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 PropertyAssignment is a producer; a PropertyAccessExpression
(def.target) is a reader and is never counted. PropertySignature — a type
declaration — 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 fields container — literally what
this component reads, objectSchema.fields[name].

Sweep A — repo-wide, every emit position, any depth

termrolehitsfiles
targetSUBJECT1329366
referenceToSUBJECT8041
referenceCONTROL19475
reference_toCONTROL13687

Sweep B — THE CELL (emit inside a fields container)

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT22
referenceCONTROL9236
reference_toCONTROL5236

The 2 in-cell referenceTo hits are negative fixtures of the retirement machinery
itself
object-fields-io.spec-keys.test.ts:235 and
MetadataFieldsPage.specKeyReference.test.tsx:75 both poison a draft with the retired
key 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 a fields ancestor was
run — the enclosing object's own type value being reference-bearing
(EXPANDABLE_FIELD_TYPES = lookup / master_detail / tree / user, the same family
the drawer's own isExpandableFieldType reads):

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT2913
referenceCONTROL15558
reference_toCONTROL10064

The two tests agree on target: zero, both ways. All 29 referenceTo hits are test
fixtures at other seams — action params (paramToField, resolveActionParams,
ActionParamDialog), the filter builder, the retirement machinery — none in
plugin-detail, and none reaching this drawer.
The one non-test emitter,
resolveActionParams.ts:532, writes referenceTo onto an ActionParamDef, which is the
action-parameter contract's own live key and a different tier; it never becomes a fields
container. (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 target and referenceTo were counted as. Had the
question 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 target is
itself 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 type value, all 1329 belong to a different
tier
: api (137), url (35), script (32), form (16), flow (13), back (9),
modal (6), fault (5) — action and navigation nodes — plus 1062 with no sibling type
at 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/*.json are
{ type: 'form', fields: [...] }UI component schemas — ObjectUI's own view/field
tier, 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 target shown a producer at this
seam, 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 target is "not in the spec's alias table". That is
not accurate, and the record should say so.
targetis in the alias table — it sits
in FieldSchema's own aliases map in @objectstack/spec as target: "reference".

What the table does, however, is the opposite of declaring the key. Measured
two-directionally against the installed spec:

probeverdict
reference: 'crm_account'ACCEPT
reference_to: 'crm_account'REFUSE — unrecognized_keys, "Did you mean reference_toreference?"
referenceTo: 'crm_account'REFUSE — unrecognized_keys, "Did you mean referenceToreference?"
target: 'crm_account'REFUSE — unrecognized_keys, "Did you mean targetreference?"

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 target explicitly, in order to refuse it. The
premise holds; only its supporting detail needed correcting.


3. Why these two were invented tolerance surface, not redundancy

⇒ 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 only
target, stops resolving a target. Bounded by the ingestion door — a referenceTo-only
def that came through normalizeSchemaReferenceKeys still resolves, because the choke
point stamps both snake_case keys before the drawer sees it. Only a def that bypassed that
door entirely is affected, and for target not even that door ever helped.

⚠️reference_to and reference are deliberately untouched. Choosing between them per
reader 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.)

  1. A header stating the measurement, not just the conclusion — the cell, the subject
    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.
  2. The live arms pinned in the same file as the dead onesreference_to and
    reference resolving, the full field list deriving, the relation still marked readonly.
    Without them, a drawer that simply stopped resolving anything would pass the refusals
    too.
  3. A named refusal case per deleted keydoes NOT read 'referenceTo' and
    does NOT read 'target', each asserting the honest zero.
  4. A precedence-inversion case only where one exists — and here none does. Both
    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.
  5. The floor restated at the pin — ⛔ do not re-add a spelling arm; a producer emitting
    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_to chain the ingestion normalizer already made redundant #6837's open
    scope.

Two extra cases carry the mechanism rather than just the verdict: a referenceTo-only
def throughnormalizeSchemaReferenceKeys still resolves (so the deletion is lossless
at the ingestion door), while a target-only def does not resolve even through the
normalizer (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/core
is aliased by the root vitest config to packages/core/src. Both legs resolve to
source — no package exports hop, no dist/, therefore no rebuild leg that could
leave the ablation measuring stale output.

RED first — this change is behavioural, and the pin was red

A def carrying only target used to resolve and now does not, so the pin can be red and
was. The fact was mutated, never the assertion — the chain in the source was put back,
under a trap ... EXIT INT TERM with absolute paths resolved from
git rev-parse --show-toplevel, restore pinned to git checkout HEAD -- path (never the
bare form, which restores from a possibly-mutated index).

legmutation (the fact)predictedmeasured
A?? def.referenceTo restoredreferenceTo refusal red, rest green1 failed / 7 passed
B?? def.target restoredtarget refusal and the through-the-normalizer case red2 failed / 6 passed
Cthe original four-arm chain restoredall 3 refusals red, all 5 live-arm controls green3 failed / 5 passed
this branch, unmutatedall green8 passed

Leg 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) andgit hash-object diverging from the HEAD blob
(ec43039c…6ebae4f5… / d75723ec… / f05f5819…).

Restore proven both ways, scoped to the mutated path only, after every leg:
git diff HEAD -- path empty andgit hash-object equal to the HEAD blob
(ec43039c…), with the whole tree clean at the end.

The rest

whatcommandresult
the new pinvitest run the pin file8 passed
plugin-detail full suitevitest run packages/plugin-detail/src/115 files / 1053 tests passed
every consumer that mounts the drawer12 explicit paths across plugin-gantt, plugin-calendar, plugin-kanban, plugin-dashboard12 files / 71 tests passed
type-check--filter @object-ui/plugin-detail run type-checkexit 0, script echoed: tsc --noEmit && tsc -p tsconfig.test.json
pin is a program inputtsc -p tsconfig.test.json --listFiles1 — measured, not assumed
lint, the real gate--filter @object-ui/plugin-detail run lintexit 0, 0 errors (880 pre-existing warnings)
gatescheck: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-majorall exit 0, each quoted by its own verdict line

--listFiles was read carefully, because "type-check is clean" can be a true sentence
that says nothing about a new test file.
tsconfig.jsonexcludes**/*.test.tsx, so
the build project sees the pin 0 times; tsconfig.test.json exists precisely to cover
tests 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 blob ec43039c…, counts read from
--format json: 0 errors / 8 warnings, identical both ways. Blob ids were verified in
both directions and the restore was proven by an empty git status. The new pin file has
no 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 closest
house analogue expandableFamily.identity-5874.test.tsx (5 of the same).

NOT MEASURED — reported as such rather than as a colour

check:spec-floors exits 1 with 12 findings, all [no-artifact], zero of them naming
plugin-detail. The gate prints its own remedy: "Build the workspace before running this
gate: pnpm exec turbo run build --filter=!@object-ui/site"
. This worktree built only
plugin-detail's dependency closure, so 12 packages have no dist/ to judge. This is a
PREREQUISITE 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 the
farm exactly once regardless. Type-aware linting is not enabled (no projectService /
project: in eslint.config.js), so this diff cannot move the verdict of any file it does
not touch.

Tree provenance. The ablation and its blob hashes were produced on commit 04cada79.
origin/main then moved under this branch and #6915 landed, so origin/main was merged in
— see the next section — and the suite plus type-check were re-run on the merged head
7856fa8c
, both green, with git status clean.


6. ⚠️ The flagged file overlap with #6915 — resolved, and nothing of theirs was touched

RecordDetailDrawer.tsx was flagged as also touched by then-unlanded PR #6915 (card
#6584). It landed first. origin/main was merged into this branch: clean, no
conflict.
Their hunk is a comment-only addition to the width docblock at ~:77-87
recording 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 :267 was still exact on origin/main at 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 says Refs.

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:

reference_field: def.reference_field??def.referenceField,

FieldSchema refuses both spellings by name, and referenceField has 0 in-cell
producers — but a first census pass returns 0 for the control reference_field as
well
. ⇒ 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 an
edited PR body drops the session form from the footer).

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.0 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-vWzpMzCA.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.13KB116.43KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.40KB62.44KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

CONTRACT_REVIEW_TIER review — head 7856fa8c

Verdict: ACCEPT — no blocking follow-ups. The load-bearing measurements are real: I re-derived the census with my own scanner under two independently chosen cell definitions, re-ran the spec probes two-directionally against the installed spec, re-ran ablation leg C from scratch with hash proof, and verified the #6915 merge file-by-file. One non-blocking observation for the record, below.

⭐ Routing conclusion — clause ② applies; the door is NOT total; the PM did not over-gate

I measured the ingestion-door question rather than inheriting #6916's answer, and it comes out the same way, for the same structural reason:

  • normalizeSchemaReferenceKeys has exactly two production call sites — MetadataProvider.tsx:657 and ObjectStackAdapter.getObjectSchema (data-objectstack/src/index.ts:3753). The fold is a private property of those two implementations, not of the seam.
  • The seam is published twice over. (1) getObjectSchema(objectName): Promise<any> is a required member of the published DataSource interface (types/src/data.ts:519 — "Required for all DataSource implementations"), and all three in-repo drawer mounts feed the drawer from it on the genericdataSource (ObjectKanban.tsx:239, ObjectCalendar.tsx:402, ObjectGantt.tsx:706) — a third-party DataSource implementation reaches the chain raw through supported mount paths, no fold in between. (2) RecordDetailDrawer is itself a public export of @object-ui/plugin-detail with objectSchema as a public prop; this PR's own pin demonstrates raw ingress by passing a bare { name, fields } straight in.
  • The in-repo non-normalizing implementations are harmless by construction, and I checked each: ValueDataSource:395 infers { type: typeof value } fields and ApiDataSource:326 returns an empty stub — neither can emit any reference spelling; the site gallery and console workbench fixtures fall inside the census (0 target, no in-cell referenceTo).

⇒ A def carrying only referenceTo or only targetcan reach this chain from outside the repo, so the reject direction is real at a seam beyond this repo's reach — exactly the #6916 situation, and clause ② was the right gate. Not over-gated. To the PR's credit, it never claims totality: the changeset bounds itself to defs that did enter through the door, and the edited comment retains "the drawer can receive a raw schema from any DataSource". The cost lands only on off-contract producers emitting spellings the spec refuses by name — the card's sanctioned intent (AGENTS.md #0.1).

The census — reproduced independently, both cells

My own structure-walk scanner (TS compiler API + parsed JSON/YAML, PropertyAssignment emits only, shorthand tracked separately, 5854 tracked files):

termrepo-wide (mine)in-cell strictin-cell loosePR claim
target1311 (+19 shorthand)001329 / 0
referenceTo781280 / 2
reference (control)1928992194 / 92 ✓
reference_to (control)1375252136 / 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 api 136, url 35, script 32, form 15, flow 13, back 9, modal 6, fault 5, ~1046 with no sibling type (DOM event/link targets) — sampled hits are action-node endpoint URLs and the action renderers forwarding schema.target. The singlelookup-sibling target emit 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 referenceTo hits are what the PR says they are — I read both: MetadataFieldsPage.specKeyReference.test.tsx:75 poisons legacy_id to assert the read door strips it; object-fields-io.spec-keys.test.ts:235 feeds referenceTo through roundTrip() 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 69b27fb6b022e83fd): 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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 16:39
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 9409eb9Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6837-recorddrawer-invented-arms branch August 30, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain - #6920

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms
Aug 30, 2026
Merged

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain#6920
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Refs #6837

Refs, deliberately — not a closing keyword. This is the first slice only
of #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.

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. 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, plus
parsed 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 PropertyAssignment is a producer; a PropertyAccessExpression
(def.target) is a reader and is never counted. PropertySignature — a type
declaration — 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 fields container — literally what
this component reads, objectSchema.fields[name].

Sweep A — repo-wide, every emit position, any depth

termrolehitsfiles
targetSUBJECT1329366
referenceToSUBJECT8041
referenceCONTROL19475
reference_toCONTROL13687

Sweep B — THE CELL (emit inside a fields container)

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT22
referenceCONTROL9236
reference_toCONTROL5236

The 2 in-cell referenceTo hits are negative fixtures of the retirement machinery
itself
object-fields-io.spec-keys.test.ts:235 and
MetadataFieldsPage.specKeyReference.test.tsx:75 both poison a draft with the retired
key 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 a fields ancestor was
run — the enclosing object's own type value being reference-bearing
(EXPANDABLE_FIELD_TYPES = lookup / master_detail / tree / user, the same family
the drawer's own isExpandableFieldType reads):

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT2913
referenceCONTROL15558
reference_toCONTROL10064

The two tests agree on target: zero, both ways. All 29 referenceTo hits are test
fixtures at other seams — action params (paramToField, resolveActionParams,
ActionParamDialog), the filter builder, the retirement machinery — none in
plugin-detail, and none reaching this drawer.
The one non-test emitter,
resolveActionParams.ts:532, writes referenceTo onto an ActionParamDef, which is the
action-parameter contract's own live key and a different tier; it never becomes a fields
container. (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 target and referenceTo were counted as. Had the
question 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 target is
itself 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 type value, all 1329 belong to a different
tier
: api (137), url (35), script (32), form (16), flow (13), back (9),
modal (6), fault (5) — action and navigation nodes — plus 1062 with no sibling type
at 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/*.json are
{ type: 'form', fields: [...] }UI component schemas — ObjectUI's own view/field
tier, 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 target shown a producer at this
seam, 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 target is "not in the spec's alias table". That is
not accurate, and the record should say so.
targetis in the alias table — it sits
in FieldSchema's own aliases map in @objectstack/spec as target: "reference".

What the table does, however, is the opposite of declaring the key. Measured
two-directionally against the installed spec:

probeverdict
reference: 'crm_account'ACCEPT
reference_to: 'crm_account'REFUSE — unrecognized_keys, "Did you mean reference_toreference?"
referenceTo: 'crm_account'REFUSE — unrecognized_keys, "Did you mean referenceToreference?"
target: 'crm_account'REFUSE — unrecognized_keys, "Did you mean targetreference?"

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 target explicitly, in order to refuse it. The
premise holds; only its supporting detail needed correcting.


3. Why these two were invented tolerance surface, not redundancy

⇒ 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 only
target, stops resolving a target. Bounded by the ingestion door — a referenceTo-only
def that came through normalizeSchemaReferenceKeys still resolves, because the choke
point stamps both snake_case keys before the drawer sees it. Only a def that bypassed that
door entirely is affected, and for target not even that door ever helped.

⚠️reference_to and reference are deliberately untouched. Choosing between them per
reader 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.)

  1. A header stating the measurement, not just the conclusion — the cell, the subject
    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.
  2. The live arms pinned in the same file as the dead onesreference_to and
    reference resolving, the full field list deriving, the relation still marked readonly.
    Without them, a drawer that simply stopped resolving anything would pass the refusals
    too.
  3. A named refusal case per deleted keydoes NOT read 'referenceTo' and
    does NOT read 'target', each asserting the honest zero.
  4. A precedence-inversion case only where one exists — and here none does. Both
    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.
  5. The floor restated at the pin — ⛔ do not re-add a spelling arm; a producer emitting
    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_to chain the ingestion normalizer already made redundant #6837's open
    scope.

Two extra cases carry the mechanism rather than just the verdict: a referenceTo-only
def throughnormalizeSchemaReferenceKeys still resolves (so the deletion is lossless
at the ingestion door), while a target-only def does not resolve even through the
normalizer (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/core
is aliased by the root vitest config to packages/core/src. Both legs resolve to
source — no package exports hop, no dist/, therefore no rebuild leg that could
leave the ablation measuring stale output.

RED first — this change is behavioural, and the pin was red

A def carrying only target used to resolve and now does not, so the pin can be red and
was. The fact was mutated, never the assertion — the chain in the source was put back,
under a trap ... EXIT INT TERM with absolute paths resolved from
git rev-parse --show-toplevel, restore pinned to git checkout HEAD -- path (never the
bare form, which restores from a possibly-mutated index).

legmutation (the fact)predictedmeasured
A?? def.referenceTo restoredreferenceTo refusal red, rest green1 failed / 7 passed
B?? def.target restoredtarget refusal and the through-the-normalizer case red2 failed / 6 passed
Cthe original four-arm chain restoredall 3 refusals red, all 5 live-arm controls green3 failed / 5 passed
this branch, unmutatedall green8 passed

Leg 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) andgit hash-object diverging from the HEAD blob
(ec43039c…6ebae4f5… / d75723ec… / f05f5819…).

Restore proven both ways, scoped to the mutated path only, after every leg:
git diff HEAD -- path empty andgit hash-object equal to the HEAD blob
(ec43039c…), with the whole tree clean at the end.

The rest

whatcommandresult
the new pinvitest run the pin file8 passed
plugin-detail full suitevitest run packages/plugin-detail/src/115 files / 1053 tests passed
every consumer that mounts the drawer12 explicit paths across plugin-gantt, plugin-calendar, plugin-kanban, plugin-dashboard12 files / 71 tests passed
type-check--filter @object-ui/plugin-detail run type-checkexit 0, script echoed: tsc --noEmit && tsc -p tsconfig.test.json
pin is a program inputtsc -p tsconfig.test.json --listFiles1 — measured, not assumed
lint, the real gate--filter @object-ui/plugin-detail run lintexit 0, 0 errors (880 pre-existing warnings)
gatescheck: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-majorall exit 0, each quoted by its own verdict line

--listFiles was read carefully, because "type-check is clean" can be a true sentence
that says nothing about a new test file.
tsconfig.jsonexcludes**/*.test.tsx, so
the build project sees the pin 0 times; tsconfig.test.json exists precisely to cover
tests 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 blob ec43039c…, counts read from
--format json: 0 errors / 8 warnings, identical both ways. Blob ids were verified in
both directions and the restore was proven by an empty git status. The new pin file has
no 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 closest
house analogue expandableFamily.identity-5874.test.tsx (5 of the same).

NOT MEASURED — reported as such rather than as a colour

check:spec-floors exits 1 with 12 findings, all [no-artifact], zero of them naming
plugin-detail. The gate prints its own remedy: "Build the workspace before running this
gate: pnpm exec turbo run build --filter=!@object-ui/site"
. This worktree built only
plugin-detail's dependency closure, so 12 packages have no dist/ to judge. This is a
PREREQUISITE 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 the
farm exactly once regardless. Type-aware linting is not enabled (no projectService /
project: in eslint.config.js), so this diff cannot move the verdict of any file it does
not touch.

Tree provenance. The ablation and its blob hashes were produced on commit 04cada79.
origin/main then moved under this branch and #6915 landed, so origin/main was merged in
— see the next section — and the suite plus type-check were re-run on the merged head
7856fa8c
, both green, with git status clean.


6. ⚠️ The flagged file overlap with #6915 — resolved, and nothing of theirs was touched

RecordDetailDrawer.tsx was flagged as also touched by then-unlanded PR #6915 (card
#6584). It landed first. origin/main was merged into this branch: clean, no
conflict.
Their hunk is a comment-only addition to the width docblock at ~:77-87
recording 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 :267 was still exact on origin/main at 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 says Refs.

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:

reference_field: def.reference_field??def.referenceField,

FieldSchema refuses both spellings by name, and referenceField has 0 in-cell
producers — but a first census pass returns 0 for the control reference_field as
well
. ⇒ 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 an
edited PR body drops the session form from the footer).

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.0 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-vWzpMzCA.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.13KB116.43KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.40KB62.44KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

CONTRACT_REVIEW_TIER review — head 7856fa8c

Verdict: ACCEPT — no blocking follow-ups. The load-bearing measurements are real: I re-derived the census with my own scanner under two independently chosen cell definitions, re-ran the spec probes two-directionally against the installed spec, re-ran ablation leg C from scratch with hash proof, and verified the #6915 merge file-by-file. One non-blocking observation for the record, below.

⭐ Routing conclusion — clause ② applies; the door is NOT total; the PM did not over-gate

I measured the ingestion-door question rather than inheriting #6916's answer, and it comes out the same way, for the same structural reason:

  • normalizeSchemaReferenceKeys has exactly two production call sites — MetadataProvider.tsx:657 and ObjectStackAdapter.getObjectSchema (data-objectstack/src/index.ts:3753). The fold is a private property of those two implementations, not of the seam.
  • The seam is published twice over. (1) getObjectSchema(objectName): Promise<any> is a required member of the published DataSource interface (types/src/data.ts:519 — "Required for all DataSource implementations"), and all three in-repo drawer mounts feed the drawer from it on the genericdataSource (ObjectKanban.tsx:239, ObjectCalendar.tsx:402, ObjectGantt.tsx:706) — a third-party DataSource implementation reaches the chain raw through supported mount paths, no fold in between. (2) RecordDetailDrawer is itself a public export of @object-ui/plugin-detail with objectSchema as a public prop; this PR's own pin demonstrates raw ingress by passing a bare { name, fields } straight in.
  • The in-repo non-normalizing implementations are harmless by construction, and I checked each: ValueDataSource:395 infers { type: typeof value } fields and ApiDataSource:326 returns an empty stub — neither can emit any reference spelling; the site gallery and console workbench fixtures fall inside the census (0 target, no in-cell referenceTo).

⇒ A def carrying only referenceTo or only targetcan reach this chain from outside the repo, so the reject direction is real at a seam beyond this repo's reach — exactly the #6916 situation, and clause ② was the right gate. Not over-gated. To the PR's credit, it never claims totality: the changeset bounds itself to defs that did enter through the door, and the edited comment retains "the drawer can receive a raw schema from any DataSource". The cost lands only on off-contract producers emitting spellings the spec refuses by name — the card's sanctioned intent (AGENTS.md #0.1).

The census — reproduced independently, both cells

My own structure-walk scanner (TS compiler API + parsed JSON/YAML, PropertyAssignment emits only, shorthand tracked separately, 5854 tracked files):

termrepo-wide (mine)in-cell strictin-cell loosePR claim
target1311 (+19 shorthand)001329 / 0
referenceTo781280 / 2
reference (control)1928992194 / 92 ✓
reference_to (control)1375252136 / 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 api 136, url 35, script 32, form 15, flow 13, back 9, modal 6, fault 5, ~1046 with no sibling type (DOM event/link targets) — sampled hits are action-node endpoint URLs and the action renderers forwarding schema.target. The singlelookup-sibling target emit 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 referenceTo hits are what the PR says they are — I read both: MetadataFieldsPage.specKeyReference.test.tsx:75 poisons legacy_id to assert the read door strips it; object-fields-io.spec-keys.test.ts:235 feeds referenceTo through roundTrip() 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 69b27fb6b022e83fd): 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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 16:39
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 9409eb9Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6837-recorddrawer-invented-arms branch August 30, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain - #6920

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms
Aug 30, 2026
Merged

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain#6920
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Refs #6837

Refs, deliberately — not a closing keyword. This is the first slice only
of #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.

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. 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, plus
parsed 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 PropertyAssignment is a producer; a PropertyAccessExpression
(def.target) is a reader and is never counted. PropertySignature — a type
declaration — 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 fields container — literally what
this component reads, objectSchema.fields[name].

Sweep A — repo-wide, every emit position, any depth

termrolehitsfiles
targetSUBJECT1329366
referenceToSUBJECT8041
referenceCONTROL19475
reference_toCONTROL13687

Sweep B — THE CELL (emit inside a fields container)

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT22
referenceCONTROL9236
reference_toCONTROL5236

The 2 in-cell referenceTo hits are negative fixtures of the retirement machinery
itself
object-fields-io.spec-keys.test.ts:235 and
MetadataFieldsPage.specKeyReference.test.tsx:75 both poison a draft with the retired
key 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 a fields ancestor was
run — the enclosing object's own type value being reference-bearing
(EXPANDABLE_FIELD_TYPES = lookup / master_detail / tree / user, the same family
the drawer's own isExpandableFieldType reads):

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT2913
referenceCONTROL15558
reference_toCONTROL10064

The two tests agree on target: zero, both ways. All 29 referenceTo hits are test
fixtures at other seams — action params (paramToField, resolveActionParams,
ActionParamDialog), the filter builder, the retirement machinery — none in
plugin-detail, and none reaching this drawer.
The one non-test emitter,
resolveActionParams.ts:532, writes referenceTo onto an ActionParamDef, which is the
action-parameter contract's own live key and a different tier; it never becomes a fields
container. (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 target and referenceTo were counted as. Had the
question 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 target is
itself 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 type value, all 1329 belong to a different
tier
: api (137), url (35), script (32), form (16), flow (13), back (9),
modal (6), fault (5) — action and navigation nodes — plus 1062 with no sibling type
at 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/*.json are
{ type: 'form', fields: [...] }UI component schemas — ObjectUI's own view/field
tier, 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 target shown a producer at this
seam, 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 target is "not in the spec's alias table". That is
not accurate, and the record should say so.
targetis in the alias table — it sits
in FieldSchema's own aliases map in @objectstack/spec as target: "reference".

What the table does, however, is the opposite of declaring the key. Measured
two-directionally against the installed spec:

probeverdict
reference: 'crm_account'ACCEPT
reference_to: 'crm_account'REFUSE — unrecognized_keys, "Did you mean reference_toreference?"
referenceTo: 'crm_account'REFUSE — unrecognized_keys, "Did you mean referenceToreference?"
target: 'crm_account'REFUSE — unrecognized_keys, "Did you mean targetreference?"

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 target explicitly, in order to refuse it. The
premise holds; only its supporting detail needed correcting.


3. Why these two were invented tolerance surface, not redundancy

⇒ 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 only
target, stops resolving a target. Bounded by the ingestion door — a referenceTo-only
def that came through normalizeSchemaReferenceKeys still resolves, because the choke
point stamps both snake_case keys before the drawer sees it. Only a def that bypassed that
door entirely is affected, and for target not even that door ever helped.

⚠️reference_to and reference are deliberately untouched. Choosing between them per
reader 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.)

  1. A header stating the measurement, not just the conclusion — the cell, the subject
    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.
  2. The live arms pinned in the same file as the dead onesreference_to and
    reference resolving, the full field list deriving, the relation still marked readonly.
    Without them, a drawer that simply stopped resolving anything would pass the refusals
    too.
  3. A named refusal case per deleted keydoes NOT read 'referenceTo' and
    does NOT read 'target', each asserting the honest zero.
  4. A precedence-inversion case only where one exists — and here none does. Both
    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.
  5. The floor restated at the pin — ⛔ do not re-add a spelling arm; a producer emitting
    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_to chain the ingestion normalizer already made redundant #6837's open
    scope.

Two extra cases carry the mechanism rather than just the verdict: a referenceTo-only
def throughnormalizeSchemaReferenceKeys still resolves (so the deletion is lossless
at the ingestion door), while a target-only def does not resolve even through the
normalizer (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/core
is aliased by the root vitest config to packages/core/src. Both legs resolve to
source — no package exports hop, no dist/, therefore no rebuild leg that could
leave the ablation measuring stale output.

RED first — this change is behavioural, and the pin was red

A def carrying only target used to resolve and now does not, so the pin can be red and
was. The fact was mutated, never the assertion — the chain in the source was put back,
under a trap ... EXIT INT TERM with absolute paths resolved from
git rev-parse --show-toplevel, restore pinned to git checkout HEAD -- path (never the
bare form, which restores from a possibly-mutated index).

legmutation (the fact)predictedmeasured
A?? def.referenceTo restoredreferenceTo refusal red, rest green1 failed / 7 passed
B?? def.target restoredtarget refusal and the through-the-normalizer case red2 failed / 6 passed
Cthe original four-arm chain restoredall 3 refusals red, all 5 live-arm controls green3 failed / 5 passed
this branch, unmutatedall green8 passed

Leg 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) andgit hash-object diverging from the HEAD blob
(ec43039c…6ebae4f5… / d75723ec… / f05f5819…).

Restore proven both ways, scoped to the mutated path only, after every leg:
git diff HEAD -- path empty andgit hash-object equal to the HEAD blob
(ec43039c…), with the whole tree clean at the end.

The rest

whatcommandresult
the new pinvitest run the pin file8 passed
plugin-detail full suitevitest run packages/plugin-detail/src/115 files / 1053 tests passed
every consumer that mounts the drawer12 explicit paths across plugin-gantt, plugin-calendar, plugin-kanban, plugin-dashboard12 files / 71 tests passed
type-check--filter @object-ui/plugin-detail run type-checkexit 0, script echoed: tsc --noEmit && tsc -p tsconfig.test.json
pin is a program inputtsc -p tsconfig.test.json --listFiles1 — measured, not assumed
lint, the real gate--filter @object-ui/plugin-detail run lintexit 0, 0 errors (880 pre-existing warnings)
gatescheck: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-majorall exit 0, each quoted by its own verdict line

--listFiles was read carefully, because "type-check is clean" can be a true sentence
that says nothing about a new test file.
tsconfig.jsonexcludes**/*.test.tsx, so
the build project sees the pin 0 times; tsconfig.test.json exists precisely to cover
tests 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 blob ec43039c…, counts read from
--format json: 0 errors / 8 warnings, identical both ways. Blob ids were verified in
both directions and the restore was proven by an empty git status. The new pin file has
no 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 closest
house analogue expandableFamily.identity-5874.test.tsx (5 of the same).

NOT MEASURED — reported as such rather than as a colour

check:spec-floors exits 1 with 12 findings, all [no-artifact], zero of them naming
plugin-detail. The gate prints its own remedy: "Build the workspace before running this
gate: pnpm exec turbo run build --filter=!@object-ui/site"
. This worktree built only
plugin-detail's dependency closure, so 12 packages have no dist/ to judge. This is a
PREREQUISITE 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 the
farm exactly once regardless. Type-aware linting is not enabled (no projectService /
project: in eslint.config.js), so this diff cannot move the verdict of any file it does
not touch.

Tree provenance. The ablation and its blob hashes were produced on commit 04cada79.
origin/main then moved under this branch and #6915 landed, so origin/main was merged in
— see the next section — and the suite plus type-check were re-run on the merged head
7856fa8c
, both green, with git status clean.


6. ⚠️ The flagged file overlap with #6915 — resolved, and nothing of theirs was touched

RecordDetailDrawer.tsx was flagged as also touched by then-unlanded PR #6915 (card
#6584). It landed first. origin/main was merged into this branch: clean, no
conflict.
Their hunk is a comment-only addition to the width docblock at ~:77-87
recording 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 :267 was still exact on origin/main at 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 says Refs.

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:

reference_field: def.reference_field??def.referenceField,

FieldSchema refuses both spellings by name, and referenceField has 0 in-cell
producers — but a first census pass returns 0 for the control reference_field as
well
. ⇒ 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 an
edited PR body drops the session form from the footer).

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.0 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-vWzpMzCA.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.13KB116.43KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.40KB62.44KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

CONTRACT_REVIEW_TIER review — head 7856fa8c

Verdict: ACCEPT — no blocking follow-ups. The load-bearing measurements are real: I re-derived the census with my own scanner under two independently chosen cell definitions, re-ran the spec probes two-directionally against the installed spec, re-ran ablation leg C from scratch with hash proof, and verified the #6915 merge file-by-file. One non-blocking observation for the record, below.

⭐ Routing conclusion — clause ② applies; the door is NOT total; the PM did not over-gate

I measured the ingestion-door question rather than inheriting #6916's answer, and it comes out the same way, for the same structural reason:

  • normalizeSchemaReferenceKeys has exactly two production call sites — MetadataProvider.tsx:657 and ObjectStackAdapter.getObjectSchema (data-objectstack/src/index.ts:3753). The fold is a private property of those two implementations, not of the seam.
  • The seam is published twice over. (1) getObjectSchema(objectName): Promise<any> is a required member of the published DataSource interface (types/src/data.ts:519 — "Required for all DataSource implementations"), and all three in-repo drawer mounts feed the drawer from it on the genericdataSource (ObjectKanban.tsx:239, ObjectCalendar.tsx:402, ObjectGantt.tsx:706) — a third-party DataSource implementation reaches the chain raw through supported mount paths, no fold in between. (2) RecordDetailDrawer is itself a public export of @object-ui/plugin-detail with objectSchema as a public prop; this PR's own pin demonstrates raw ingress by passing a bare { name, fields } straight in.
  • The in-repo non-normalizing implementations are harmless by construction, and I checked each: ValueDataSource:395 infers { type: typeof value } fields and ApiDataSource:326 returns an empty stub — neither can emit any reference spelling; the site gallery and console workbench fixtures fall inside the census (0 target, no in-cell referenceTo).

⇒ A def carrying only referenceTo or only targetcan reach this chain from outside the repo, so the reject direction is real at a seam beyond this repo's reach — exactly the #6916 situation, and clause ② was the right gate. Not over-gated. To the PR's credit, it never claims totality: the changeset bounds itself to defs that did enter through the door, and the edited comment retains "the drawer can receive a raw schema from any DataSource". The cost lands only on off-contract producers emitting spellings the spec refuses by name — the card's sanctioned intent (AGENTS.md #0.1).

The census — reproduced independently, both cells

My own structure-walk scanner (TS compiler API + parsed JSON/YAML, PropertyAssignment emits only, shorthand tracked separately, 5854 tracked files):

termrepo-wide (mine)in-cell strictin-cell loosePR claim
target1311 (+19 shorthand)001329 / 0
referenceTo781280 / 2
reference (control)1928992194 / 92 ✓
reference_to (control)1375252136 / 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 api 136, url 35, script 32, form 15, flow 13, back 9, modal 6, fault 5, ~1046 with no sibling type (DOM event/link targets) — sampled hits are action-node endpoint URLs and the action renderers forwarding schema.target. The singlelookup-sibling target emit 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 referenceTo hits are what the PR says they are — I read both: MetadataFieldsPage.specKeyReference.test.tsx:75 poisons legacy_id to assert the read door strips it; object-fields-io.spec-keys.test.ts:235 feeds referenceTo through roundTrip() 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 69b27fb6b022e83fd): 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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 16:39
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 9409eb9Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6837-recorddrawer-invented-arms branch August 30, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain - #6920

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms
Aug 30, 2026
Merged

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain#6920
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Refs #6837

Refs, deliberately — not a closing keyword. This is the first slice only
of #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.

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. 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, plus
parsed 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 PropertyAssignment is a producer; a PropertyAccessExpression
(def.target) is a reader and is never counted. PropertySignature — a type
declaration — 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 fields container — literally what
this component reads, objectSchema.fields[name].

Sweep A — repo-wide, every emit position, any depth

termrolehitsfiles
targetSUBJECT1329366
referenceToSUBJECT8041
referenceCONTROL19475
reference_toCONTROL13687

Sweep B — THE CELL (emit inside a fields container)

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT22
referenceCONTROL9236
reference_toCONTROL5236

The 2 in-cell referenceTo hits are negative fixtures of the retirement machinery
itself
object-fields-io.spec-keys.test.ts:235 and
MetadataFieldsPage.specKeyReference.test.tsx:75 both poison a draft with the retired
key 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 a fields ancestor was
run — the enclosing object's own type value being reference-bearing
(EXPANDABLE_FIELD_TYPES = lookup / master_detail / tree / user, the same family
the drawer's own isExpandableFieldType reads):

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT2913
referenceCONTROL15558
reference_toCONTROL10064

The two tests agree on target: zero, both ways. All 29 referenceTo hits are test
fixtures at other seams — action params (paramToField, resolveActionParams,
ActionParamDialog), the filter builder, the retirement machinery — none in
plugin-detail, and none reaching this drawer.
The one non-test emitter,
resolveActionParams.ts:532, writes referenceTo onto an ActionParamDef, which is the
action-parameter contract's own live key and a different tier; it never becomes a fields
container. (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 target and referenceTo were counted as. Had the
question 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 target is
itself 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 type value, all 1329 belong to a different
tier
: api (137), url (35), script (32), form (16), flow (13), back (9),
modal (6), fault (5) — action and navigation nodes — plus 1062 with no sibling type
at 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/*.json are
{ type: 'form', fields: [...] }UI component schemas — ObjectUI's own view/field
tier, 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 target shown a producer at this
seam, 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 target is "not in the spec's alias table". That is
not accurate, and the record should say so.
targetis in the alias table — it sits
in FieldSchema's own aliases map in @objectstack/spec as target: "reference".

What the table does, however, is the opposite of declaring the key. Measured
two-directionally against the installed spec:

probeverdict
reference: 'crm_account'ACCEPT
reference_to: 'crm_account'REFUSE — unrecognized_keys, "Did you mean reference_toreference?"
referenceTo: 'crm_account'REFUSE — unrecognized_keys, "Did you mean referenceToreference?"
target: 'crm_account'REFUSE — unrecognized_keys, "Did you mean targetreference?"

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 target explicitly, in order to refuse it. The
premise holds; only its supporting detail needed correcting.


3. Why these two were invented tolerance surface, not redundancy

⇒ 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 only
target, stops resolving a target. Bounded by the ingestion door — a referenceTo-only
def that came through normalizeSchemaReferenceKeys still resolves, because the choke
point stamps both snake_case keys before the drawer sees it. Only a def that bypassed that
door entirely is affected, and for target not even that door ever helped.

⚠️reference_to and reference are deliberately untouched. Choosing between them per
reader 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.)

  1. A header stating the measurement, not just the conclusion — the cell, the subject
    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.
  2. The live arms pinned in the same file as the dead onesreference_to and
    reference resolving, the full field list deriving, the relation still marked readonly.
    Without them, a drawer that simply stopped resolving anything would pass the refusals
    too.
  3. A named refusal case per deleted keydoes NOT read 'referenceTo' and
    does NOT read 'target', each asserting the honest zero.
  4. A precedence-inversion case only where one exists — and here none does. Both
    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.
  5. The floor restated at the pin — ⛔ do not re-add a spelling arm; a producer emitting
    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_to chain the ingestion normalizer already made redundant #6837's open
    scope.

Two extra cases carry the mechanism rather than just the verdict: a referenceTo-only
def throughnormalizeSchemaReferenceKeys still resolves (so the deletion is lossless
at the ingestion door), while a target-only def does not resolve even through the
normalizer (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/core
is aliased by the root vitest config to packages/core/src. Both legs resolve to
source — no package exports hop, no dist/, therefore no rebuild leg that could
leave the ablation measuring stale output.

RED first — this change is behavioural, and the pin was red

A def carrying only target used to resolve and now does not, so the pin can be red and
was. The fact was mutated, never the assertion — the chain in the source was put back,
under a trap ... EXIT INT TERM with absolute paths resolved from
git rev-parse --show-toplevel, restore pinned to git checkout HEAD -- path (never the
bare form, which restores from a possibly-mutated index).

legmutation (the fact)predictedmeasured
A?? def.referenceTo restoredreferenceTo refusal red, rest green1 failed / 7 passed
B?? def.target restoredtarget refusal and the through-the-normalizer case red2 failed / 6 passed
Cthe original four-arm chain restoredall 3 refusals red, all 5 live-arm controls green3 failed / 5 passed
this branch, unmutatedall green8 passed

Leg 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) andgit hash-object diverging from the HEAD blob
(ec43039c…6ebae4f5… / d75723ec… / f05f5819…).

Restore proven both ways, scoped to the mutated path only, after every leg:
git diff HEAD -- path empty andgit hash-object equal to the HEAD blob
(ec43039c…), with the whole tree clean at the end.

The rest

whatcommandresult
the new pinvitest run the pin file8 passed
plugin-detail full suitevitest run packages/plugin-detail/src/115 files / 1053 tests passed
every consumer that mounts the drawer12 explicit paths across plugin-gantt, plugin-calendar, plugin-kanban, plugin-dashboard12 files / 71 tests passed
type-check--filter @object-ui/plugin-detail run type-checkexit 0, script echoed: tsc --noEmit && tsc -p tsconfig.test.json
pin is a program inputtsc -p tsconfig.test.json --listFiles1 — measured, not assumed
lint, the real gate--filter @object-ui/plugin-detail run lintexit 0, 0 errors (880 pre-existing warnings)
gatescheck: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-majorall exit 0, each quoted by its own verdict line

--listFiles was read carefully, because "type-check is clean" can be a true sentence
that says nothing about a new test file.
tsconfig.jsonexcludes**/*.test.tsx, so
the build project sees the pin 0 times; tsconfig.test.json exists precisely to cover
tests 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 blob ec43039c…, counts read from
--format json: 0 errors / 8 warnings, identical both ways. Blob ids were verified in
both directions and the restore was proven by an empty git status. The new pin file has
no 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 closest
house analogue expandableFamily.identity-5874.test.tsx (5 of the same).

NOT MEASURED — reported as such rather than as a colour

check:spec-floors exits 1 with 12 findings, all [no-artifact], zero of them naming
plugin-detail. The gate prints its own remedy: "Build the workspace before running this
gate: pnpm exec turbo run build --filter=!@object-ui/site"
. This worktree built only
plugin-detail's dependency closure, so 12 packages have no dist/ to judge. This is a
PREREQUISITE 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 the
farm exactly once regardless. Type-aware linting is not enabled (no projectService /
project: in eslint.config.js), so this diff cannot move the verdict of any file it does
not touch.

Tree provenance. The ablation and its blob hashes were produced on commit 04cada79.
origin/main then moved under this branch and #6915 landed, so origin/main was merged in
— see the next section — and the suite plus type-check were re-run on the merged head
7856fa8c
, both green, with git status clean.


6. ⚠️ The flagged file overlap with #6915 — resolved, and nothing of theirs was touched

RecordDetailDrawer.tsx was flagged as also touched by then-unlanded PR #6915 (card
#6584). It landed first. origin/main was merged into this branch: clean, no
conflict.
Their hunk is a comment-only addition to the width docblock at ~:77-87
recording 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 :267 was still exact on origin/main at 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 says Refs.

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:

reference_field: def.reference_field??def.referenceField,

FieldSchema refuses both spellings by name, and referenceField has 0 in-cell
producers — but a first census pass returns 0 for the control reference_field as
well
. ⇒ 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 an
edited PR body drops the session form from the footer).

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.0 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-vWzpMzCA.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.13KB116.43KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.40KB62.44KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

CONTRACT_REVIEW_TIER review — head 7856fa8c

Verdict: ACCEPT — no blocking follow-ups. The load-bearing measurements are real: I re-derived the census with my own scanner under two independently chosen cell definitions, re-ran the spec probes two-directionally against the installed spec, re-ran ablation leg C from scratch with hash proof, and verified the #6915 merge file-by-file. One non-blocking observation for the record, below.

⭐ Routing conclusion — clause ② applies; the door is NOT total; the PM did not over-gate

I measured the ingestion-door question rather than inheriting #6916's answer, and it comes out the same way, for the same structural reason:

  • normalizeSchemaReferenceKeys has exactly two production call sites — MetadataProvider.tsx:657 and ObjectStackAdapter.getObjectSchema (data-objectstack/src/index.ts:3753). The fold is a private property of those two implementations, not of the seam.
  • The seam is published twice over. (1) getObjectSchema(objectName): Promise<any> is a required member of the published DataSource interface (types/src/data.ts:519 — "Required for all DataSource implementations"), and all three in-repo drawer mounts feed the drawer from it on the genericdataSource (ObjectKanban.tsx:239, ObjectCalendar.tsx:402, ObjectGantt.tsx:706) — a third-party DataSource implementation reaches the chain raw through supported mount paths, no fold in between. (2) RecordDetailDrawer is itself a public export of @object-ui/plugin-detail with objectSchema as a public prop; this PR's own pin demonstrates raw ingress by passing a bare { name, fields } straight in.
  • The in-repo non-normalizing implementations are harmless by construction, and I checked each: ValueDataSource:395 infers { type: typeof value } fields and ApiDataSource:326 returns an empty stub — neither can emit any reference spelling; the site gallery and console workbench fixtures fall inside the census (0 target, no in-cell referenceTo).

⇒ A def carrying only referenceTo or only targetcan reach this chain from outside the repo, so the reject direction is real at a seam beyond this repo's reach — exactly the #6916 situation, and clause ② was the right gate. Not over-gated. To the PR's credit, it never claims totality: the changeset bounds itself to defs that did enter through the door, and the edited comment retains "the drawer can receive a raw schema from any DataSource". The cost lands only on off-contract producers emitting spellings the spec refuses by name — the card's sanctioned intent (AGENTS.md #0.1).

The census — reproduced independently, both cells

My own structure-walk scanner (TS compiler API + parsed JSON/YAML, PropertyAssignment emits only, shorthand tracked separately, 5854 tracked files):

termrepo-wide (mine)in-cell strictin-cell loosePR claim
target1311 (+19 shorthand)001329 / 0
referenceTo781280 / 2
reference (control)1928992194 / 92 ✓
reference_to (control)1375252136 / 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 api 136, url 35, script 32, form 15, flow 13, back 9, modal 6, fault 5, ~1046 with no sibling type (DOM event/link targets) — sampled hits are action-node endpoint URLs and the action renderers forwarding schema.target. The singlelookup-sibling target emit 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 referenceTo hits are what the PR says they are — I read both: MetadataFieldsPage.specKeyReference.test.tsx:75 poisons legacy_id to assert the read door strips it; object-fields-io.spec-keys.test.ts:235 feeds referenceTo through roundTrip() 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 69b27fb6b022e83fd): 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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 16:39
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 9409eb9Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6837-recorddrawer-invented-arms branch August 30, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain - #6920

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms
Aug 30, 2026
Merged

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain#6920
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Refs #6837

Refs, deliberately — not a closing keyword. This is the first slice only
of #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.

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. 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, plus
parsed 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 PropertyAssignment is a producer; a PropertyAccessExpression
(def.target) is a reader and is never counted. PropertySignature — a type
declaration — 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 fields container — literally what
this component reads, objectSchema.fields[name].

Sweep A — repo-wide, every emit position, any depth

termrolehitsfiles
targetSUBJECT1329366
referenceToSUBJECT8041
referenceCONTROL19475
reference_toCONTROL13687

Sweep B — THE CELL (emit inside a fields container)

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT22
referenceCONTROL9236
reference_toCONTROL5236

The 2 in-cell referenceTo hits are negative fixtures of the retirement machinery
itself
object-fields-io.spec-keys.test.ts:235 and
MetadataFieldsPage.specKeyReference.test.tsx:75 both poison a draft with the retired
key 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 a fields ancestor was
run — the enclosing object's own type value being reference-bearing
(EXPANDABLE_FIELD_TYPES = lookup / master_detail / tree / user, the same family
the drawer's own isExpandableFieldType reads):

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT2913
referenceCONTROL15558
reference_toCONTROL10064

The two tests agree on target: zero, both ways. All 29 referenceTo hits are test
fixtures at other seams — action params (paramToField, resolveActionParams,
ActionParamDialog), the filter builder, the retirement machinery — none in
plugin-detail, and none reaching this drawer.
The one non-test emitter,
resolveActionParams.ts:532, writes referenceTo onto an ActionParamDef, which is the
action-parameter contract's own live key and a different tier; it never becomes a fields
container. (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 target and referenceTo were counted as. Had the
question 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 target is
itself 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 type value, all 1329 belong to a different
tier
: api (137), url (35), script (32), form (16), flow (13), back (9),
modal (6), fault (5) — action and navigation nodes — plus 1062 with no sibling type
at 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/*.json are
{ type: 'form', fields: [...] }UI component schemas — ObjectUI's own view/field
tier, 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 target shown a producer at this
seam, 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 target is "not in the spec's alias table". That is
not accurate, and the record should say so.
targetis in the alias table — it sits
in FieldSchema's own aliases map in @objectstack/spec as target: "reference".

What the table does, however, is the opposite of declaring the key. Measured
two-directionally against the installed spec:

probeverdict
reference: 'crm_account'ACCEPT
reference_to: 'crm_account'REFUSE — unrecognized_keys, "Did you mean reference_toreference?"
referenceTo: 'crm_account'REFUSE — unrecognized_keys, "Did you mean referenceToreference?"
target: 'crm_account'REFUSE — unrecognized_keys, "Did you mean targetreference?"

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 target explicitly, in order to refuse it. The
premise holds; only its supporting detail needed correcting.


3. Why these two were invented tolerance surface, not redundancy

⇒ 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 only
target, stops resolving a target. Bounded by the ingestion door — a referenceTo-only
def that came through normalizeSchemaReferenceKeys still resolves, because the choke
point stamps both snake_case keys before the drawer sees it. Only a def that bypassed that
door entirely is affected, and for target not even that door ever helped.

⚠️reference_to and reference are deliberately untouched. Choosing between them per
reader 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.)

  1. A header stating the measurement, not just the conclusion — the cell, the subject
    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.
  2. The live arms pinned in the same file as the dead onesreference_to and
    reference resolving, the full field list deriving, the relation still marked readonly.
    Without them, a drawer that simply stopped resolving anything would pass the refusals
    too.
  3. A named refusal case per deleted keydoes NOT read 'referenceTo' and
    does NOT read 'target', each asserting the honest zero.
  4. A precedence-inversion case only where one exists — and here none does. Both
    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.
  5. The floor restated at the pin — ⛔ do not re-add a spelling arm; a producer emitting
    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_to chain the ingestion normalizer already made redundant #6837's open
    scope.

Two extra cases carry the mechanism rather than just the verdict: a referenceTo-only
def throughnormalizeSchemaReferenceKeys still resolves (so the deletion is lossless
at the ingestion door), while a target-only def does not resolve even through the
normalizer (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/core
is aliased by the root vitest config to packages/core/src. Both legs resolve to
source — no package exports hop, no dist/, therefore no rebuild leg that could
leave the ablation measuring stale output.

RED first — this change is behavioural, and the pin was red

A def carrying only target used to resolve and now does not, so the pin can be red and
was. The fact was mutated, never the assertion — the chain in the source was put back,
under a trap ... EXIT INT TERM with absolute paths resolved from
git rev-parse --show-toplevel, restore pinned to git checkout HEAD -- path (never the
bare form, which restores from a possibly-mutated index).

legmutation (the fact)predictedmeasured
A?? def.referenceTo restoredreferenceTo refusal red, rest green1 failed / 7 passed
B?? def.target restoredtarget refusal and the through-the-normalizer case red2 failed / 6 passed
Cthe original four-arm chain restoredall 3 refusals red, all 5 live-arm controls green3 failed / 5 passed
this branch, unmutatedall green8 passed

Leg 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) andgit hash-object diverging from the HEAD blob
(ec43039c…6ebae4f5… / d75723ec… / f05f5819…).

Restore proven both ways, scoped to the mutated path only, after every leg:
git diff HEAD -- path empty andgit hash-object equal to the HEAD blob
(ec43039c…), with the whole tree clean at the end.

The rest

whatcommandresult
the new pinvitest run the pin file8 passed
plugin-detail full suitevitest run packages/plugin-detail/src/115 files / 1053 tests passed
every consumer that mounts the drawer12 explicit paths across plugin-gantt, plugin-calendar, plugin-kanban, plugin-dashboard12 files / 71 tests passed
type-check--filter @object-ui/plugin-detail run type-checkexit 0, script echoed: tsc --noEmit && tsc -p tsconfig.test.json
pin is a program inputtsc -p tsconfig.test.json --listFiles1 — measured, not assumed
lint, the real gate--filter @object-ui/plugin-detail run lintexit 0, 0 errors (880 pre-existing warnings)
gatescheck: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-majorall exit 0, each quoted by its own verdict line

--listFiles was read carefully, because "type-check is clean" can be a true sentence
that says nothing about a new test file.
tsconfig.jsonexcludes**/*.test.tsx, so
the build project sees the pin 0 times; tsconfig.test.json exists precisely to cover
tests 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 blob ec43039c…, counts read from
--format json: 0 errors / 8 warnings, identical both ways. Blob ids were verified in
both directions and the restore was proven by an empty git status. The new pin file has
no 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 closest
house analogue expandableFamily.identity-5874.test.tsx (5 of the same).

NOT MEASURED — reported as such rather than as a colour

check:spec-floors exits 1 with 12 findings, all [no-artifact], zero of them naming
plugin-detail. The gate prints its own remedy: "Build the workspace before running this
gate: pnpm exec turbo run build --filter=!@object-ui/site"
. This worktree built only
plugin-detail's dependency closure, so 12 packages have no dist/ to judge. This is a
PREREQUISITE 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 the
farm exactly once regardless. Type-aware linting is not enabled (no projectService /
project: in eslint.config.js), so this diff cannot move the verdict of any file it does
not touch.

Tree provenance. The ablation and its blob hashes were produced on commit 04cada79.
origin/main then moved under this branch and #6915 landed, so origin/main was merged in
— see the next section — and the suite plus type-check were re-run on the merged head
7856fa8c
, both green, with git status clean.


6. ⚠️ The flagged file overlap with #6915 — resolved, and nothing of theirs was touched

RecordDetailDrawer.tsx was flagged as also touched by then-unlanded PR #6915 (card
#6584). It landed first. origin/main was merged into this branch: clean, no
conflict.
Their hunk is a comment-only addition to the width docblock at ~:77-87
recording 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 :267 was still exact on origin/main at 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 says Refs.

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:

reference_field: def.reference_field??def.referenceField,

FieldSchema refuses both spellings by name, and referenceField has 0 in-cell
producers — but a first census pass returns 0 for the control reference_field as
well
. ⇒ 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 an
edited PR body drops the session form from the footer).

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.0 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-vWzpMzCA.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.13KB116.43KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.40KB62.44KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

CONTRACT_REVIEW_TIER review — head 7856fa8c

Verdict: ACCEPT — no blocking follow-ups. The load-bearing measurements are real: I re-derived the census with my own scanner under two independently chosen cell definitions, re-ran the spec probes two-directionally against the installed spec, re-ran ablation leg C from scratch with hash proof, and verified the #6915 merge file-by-file. One non-blocking observation for the record, below.

⭐ Routing conclusion — clause ② applies; the door is NOT total; the PM did not over-gate

I measured the ingestion-door question rather than inheriting #6916's answer, and it comes out the same way, for the same structural reason:

  • normalizeSchemaReferenceKeys has exactly two production call sites — MetadataProvider.tsx:657 and ObjectStackAdapter.getObjectSchema (data-objectstack/src/index.ts:3753). The fold is a private property of those two implementations, not of the seam.
  • The seam is published twice over. (1) getObjectSchema(objectName): Promise<any> is a required member of the published DataSource interface (types/src/data.ts:519 — "Required for all DataSource implementations"), and all three in-repo drawer mounts feed the drawer from it on the genericdataSource (ObjectKanban.tsx:239, ObjectCalendar.tsx:402, ObjectGantt.tsx:706) — a third-party DataSource implementation reaches the chain raw through supported mount paths, no fold in between. (2) RecordDetailDrawer is itself a public export of @object-ui/plugin-detail with objectSchema as a public prop; this PR's own pin demonstrates raw ingress by passing a bare { name, fields } straight in.
  • The in-repo non-normalizing implementations are harmless by construction, and I checked each: ValueDataSource:395 infers { type: typeof value } fields and ApiDataSource:326 returns an empty stub — neither can emit any reference spelling; the site gallery and console workbench fixtures fall inside the census (0 target, no in-cell referenceTo).

⇒ A def carrying only referenceTo or only targetcan reach this chain from outside the repo, so the reject direction is real at a seam beyond this repo's reach — exactly the #6916 situation, and clause ② was the right gate. Not over-gated. To the PR's credit, it never claims totality: the changeset bounds itself to defs that did enter through the door, and the edited comment retains "the drawer can receive a raw schema from any DataSource". The cost lands only on off-contract producers emitting spellings the spec refuses by name — the card's sanctioned intent (AGENTS.md #0.1).

The census — reproduced independently, both cells

My own structure-walk scanner (TS compiler API + parsed JSON/YAML, PropertyAssignment emits only, shorthand tracked separately, 5854 tracked files):

termrepo-wide (mine)in-cell strictin-cell loosePR claim
target1311 (+19 shorthand)001329 / 0
referenceTo781280 / 2
reference (control)1928992194 / 92 ✓
reference_to (control)1375252136 / 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 api 136, url 35, script 32, form 15, flow 13, back 9, modal 6, fault 5, ~1046 with no sibling type (DOM event/link targets) — sampled hits are action-node endpoint URLs and the action renderers forwarding schema.target. The singlelookup-sibling target emit 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 referenceTo hits are what the PR says they are — I read both: MetadataFieldsPage.specKeyReference.test.tsx:75 poisons legacy_id to assert the read door strips it; object-fields-io.spec-keys.test.ts:235 feeds referenceTo through roundTrip() 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 69b27fb6b022e83fd): 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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 16:39
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 9409eb9Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6837-recorddrawer-invented-arms branch August 30, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain - #6920

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms
Aug 30, 2026
Merged

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain#6920
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Refs #6837

Refs, deliberately — not a closing keyword. This is the first slice only
of #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.

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. 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, plus
parsed 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 PropertyAssignment is a producer; a PropertyAccessExpression
(def.target) is a reader and is never counted. PropertySignature — a type
declaration — 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 fields container — literally what
this component reads, objectSchema.fields[name].

Sweep A — repo-wide, every emit position, any depth

termrolehitsfiles
targetSUBJECT1329366
referenceToSUBJECT8041
referenceCONTROL19475
reference_toCONTROL13687

Sweep B — THE CELL (emit inside a fields container)

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT22
referenceCONTROL9236
reference_toCONTROL5236

The 2 in-cell referenceTo hits are negative fixtures of the retirement machinery
itself
object-fields-io.spec-keys.test.ts:235 and
MetadataFieldsPage.specKeyReference.test.tsx:75 both poison a draft with the retired
key 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 a fields ancestor was
run — the enclosing object's own type value being reference-bearing
(EXPANDABLE_FIELD_TYPES = lookup / master_detail / tree / user, the same family
the drawer's own isExpandableFieldType reads):

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT2913
referenceCONTROL15558
reference_toCONTROL10064

The two tests agree on target: zero, both ways. All 29 referenceTo hits are test
fixtures at other seams — action params (paramToField, resolveActionParams,
ActionParamDialog), the filter builder, the retirement machinery — none in
plugin-detail, and none reaching this drawer.
The one non-test emitter,
resolveActionParams.ts:532, writes referenceTo onto an ActionParamDef, which is the
action-parameter contract's own live key and a different tier; it never becomes a fields
container. (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 target and referenceTo were counted as. Had the
question 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 target is
itself 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 type value, all 1329 belong to a different
tier
: api (137), url (35), script (32), form (16), flow (13), back (9),
modal (6), fault (5) — action and navigation nodes — plus 1062 with no sibling type
at 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/*.json are
{ type: 'form', fields: [...] }UI component schemas — ObjectUI's own view/field
tier, 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 target shown a producer at this
seam, 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 target is "not in the spec's alias table". That is
not accurate, and the record should say so.
targetis in the alias table — it sits
in FieldSchema's own aliases map in @objectstack/spec as target: "reference".

What the table does, however, is the opposite of declaring the key. Measured
two-directionally against the installed spec:

probeverdict
reference: 'crm_account'ACCEPT
reference_to: 'crm_account'REFUSE — unrecognized_keys, "Did you mean reference_toreference?"
referenceTo: 'crm_account'REFUSE — unrecognized_keys, "Did you mean referenceToreference?"
target: 'crm_account'REFUSE — unrecognized_keys, "Did you mean targetreference?"

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 target explicitly, in order to refuse it. The
premise holds; only its supporting detail needed correcting.


3. Why these two were invented tolerance surface, not redundancy

⇒ 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 only
target, stops resolving a target. Bounded by the ingestion door — a referenceTo-only
def that came through normalizeSchemaReferenceKeys still resolves, because the choke
point stamps both snake_case keys before the drawer sees it. Only a def that bypassed that
door entirely is affected, and for target not even that door ever helped.

⚠️reference_to and reference are deliberately untouched. Choosing between them per
reader 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.)

  1. A header stating the measurement, not just the conclusion — the cell, the subject
    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.
  2. The live arms pinned in the same file as the dead onesreference_to and
    reference resolving, the full field list deriving, the relation still marked readonly.
    Without them, a drawer that simply stopped resolving anything would pass the refusals
    too.
  3. A named refusal case per deleted keydoes NOT read 'referenceTo' and
    does NOT read 'target', each asserting the honest zero.
  4. A precedence-inversion case only where one exists — and here none does. Both
    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.
  5. The floor restated at the pin — ⛔ do not re-add a spelling arm; a producer emitting
    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_to chain the ingestion normalizer already made redundant #6837's open
    scope.

Two extra cases carry the mechanism rather than just the verdict: a referenceTo-only
def throughnormalizeSchemaReferenceKeys still resolves (so the deletion is lossless
at the ingestion door), while a target-only def does not resolve even through the
normalizer (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/core
is aliased by the root vitest config to packages/core/src. Both legs resolve to
source — no package exports hop, no dist/, therefore no rebuild leg that could
leave the ablation measuring stale output.

RED first — this change is behavioural, and the pin was red

A def carrying only target used to resolve and now does not, so the pin can be red and
was. The fact was mutated, never the assertion — the chain in the source was put back,
under a trap ... EXIT INT TERM with absolute paths resolved from
git rev-parse --show-toplevel, restore pinned to git checkout HEAD -- path (never the
bare form, which restores from a possibly-mutated index).

legmutation (the fact)predictedmeasured
A?? def.referenceTo restoredreferenceTo refusal red, rest green1 failed / 7 passed
B?? def.target restoredtarget refusal and the through-the-normalizer case red2 failed / 6 passed
Cthe original four-arm chain restoredall 3 refusals red, all 5 live-arm controls green3 failed / 5 passed
this branch, unmutatedall green8 passed

Leg 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) andgit hash-object diverging from the HEAD blob
(ec43039c…6ebae4f5… / d75723ec… / f05f5819…).

Restore proven both ways, scoped to the mutated path only, after every leg:
git diff HEAD -- path empty andgit hash-object equal to the HEAD blob
(ec43039c…), with the whole tree clean at the end.

The rest

whatcommandresult
the new pinvitest run the pin file8 passed
plugin-detail full suitevitest run packages/plugin-detail/src/115 files / 1053 tests passed
every consumer that mounts the drawer12 explicit paths across plugin-gantt, plugin-calendar, plugin-kanban, plugin-dashboard12 files / 71 tests passed
type-check--filter @object-ui/plugin-detail run type-checkexit 0, script echoed: tsc --noEmit && tsc -p tsconfig.test.json
pin is a program inputtsc -p tsconfig.test.json --listFiles1 — measured, not assumed
lint, the real gate--filter @object-ui/plugin-detail run lintexit 0, 0 errors (880 pre-existing warnings)
gatescheck: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-majorall exit 0, each quoted by its own verdict line

--listFiles was read carefully, because "type-check is clean" can be a true sentence
that says nothing about a new test file.
tsconfig.jsonexcludes**/*.test.tsx, so
the build project sees the pin 0 times; tsconfig.test.json exists precisely to cover
tests 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 blob ec43039c…, counts read from
--format json: 0 errors / 8 warnings, identical both ways. Blob ids were verified in
both directions and the restore was proven by an empty git status. The new pin file has
no 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 closest
house analogue expandableFamily.identity-5874.test.tsx (5 of the same).

NOT MEASURED — reported as such rather than as a colour

check:spec-floors exits 1 with 12 findings, all [no-artifact], zero of them naming
plugin-detail. The gate prints its own remedy: "Build the workspace before running this
gate: pnpm exec turbo run build --filter=!@object-ui/site"
. This worktree built only
plugin-detail's dependency closure, so 12 packages have no dist/ to judge. This is a
PREREQUISITE 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 the
farm exactly once regardless. Type-aware linting is not enabled (no projectService /
project: in eslint.config.js), so this diff cannot move the verdict of any file it does
not touch.

Tree provenance. The ablation and its blob hashes were produced on commit 04cada79.
origin/main then moved under this branch and #6915 landed, so origin/main was merged in
— see the next section — and the suite plus type-check were re-run on the merged head
7856fa8c
, both green, with git status clean.


6. ⚠️ The flagged file overlap with #6915 — resolved, and nothing of theirs was touched

RecordDetailDrawer.tsx was flagged as also touched by then-unlanded PR #6915 (card
#6584). It landed first. origin/main was merged into this branch: clean, no
conflict.
Their hunk is a comment-only addition to the width docblock at ~:77-87
recording 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 :267 was still exact on origin/main at 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 says Refs.

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:

reference_field: def.reference_field??def.referenceField,

FieldSchema refuses both spellings by name, and referenceField has 0 in-cell
producers — but a first census pass returns 0 for the control reference_field as
well
. ⇒ 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 an
edited PR body drops the session form from the footer).

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.0 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-vWzpMzCA.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.13KB116.43KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.40KB62.44KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

CONTRACT_REVIEW_TIER review — head 7856fa8c

Verdict: ACCEPT — no blocking follow-ups. The load-bearing measurements are real: I re-derived the census with my own scanner under two independently chosen cell definitions, re-ran the spec probes two-directionally against the installed spec, re-ran ablation leg C from scratch with hash proof, and verified the #6915 merge file-by-file. One non-blocking observation for the record, below.

⭐ Routing conclusion — clause ② applies; the door is NOT total; the PM did not over-gate

I measured the ingestion-door question rather than inheriting #6916's answer, and it comes out the same way, for the same structural reason:

  • normalizeSchemaReferenceKeys has exactly two production call sites — MetadataProvider.tsx:657 and ObjectStackAdapter.getObjectSchema (data-objectstack/src/index.ts:3753). The fold is a private property of those two implementations, not of the seam.
  • The seam is published twice over. (1) getObjectSchema(objectName): Promise<any> is a required member of the published DataSource interface (types/src/data.ts:519 — "Required for all DataSource implementations"), and all three in-repo drawer mounts feed the drawer from it on the genericdataSource (ObjectKanban.tsx:239, ObjectCalendar.tsx:402, ObjectGantt.tsx:706) — a third-party DataSource implementation reaches the chain raw through supported mount paths, no fold in between. (2) RecordDetailDrawer is itself a public export of @object-ui/plugin-detail with objectSchema as a public prop; this PR's own pin demonstrates raw ingress by passing a bare { name, fields } straight in.
  • The in-repo non-normalizing implementations are harmless by construction, and I checked each: ValueDataSource:395 infers { type: typeof value } fields and ApiDataSource:326 returns an empty stub — neither can emit any reference spelling; the site gallery and console workbench fixtures fall inside the census (0 target, no in-cell referenceTo).

⇒ A def carrying only referenceTo or only targetcan reach this chain from outside the repo, so the reject direction is real at a seam beyond this repo's reach — exactly the #6916 situation, and clause ② was the right gate. Not over-gated. To the PR's credit, it never claims totality: the changeset bounds itself to defs that did enter through the door, and the edited comment retains "the drawer can receive a raw schema from any DataSource". The cost lands only on off-contract producers emitting spellings the spec refuses by name — the card's sanctioned intent (AGENTS.md #0.1).

The census — reproduced independently, both cells

My own structure-walk scanner (TS compiler API + parsed JSON/YAML, PropertyAssignment emits only, shorthand tracked separately, 5854 tracked files):

termrepo-wide (mine)in-cell strictin-cell loosePR claim
target1311 (+19 shorthand)001329 / 0
referenceTo781280 / 2
reference (control)1928992194 / 92 ✓
reference_to (control)1375252136 / 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 api 136, url 35, script 32, form 15, flow 13, back 9, modal 6, fault 5, ~1046 with no sibling type (DOM event/link targets) — sampled hits are action-node endpoint URLs and the action renderers forwarding schema.target. The singlelookup-sibling target emit 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 referenceTo hits are what the PR says they are — I read both: MetadataFieldsPage.specKeyReference.test.tsx:75 poisons legacy_id to assert the read door strips it; object-fields-io.spec-keys.test.ts:235 feeds referenceTo through roundTrip() 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 69b27fb6b022e83fd): 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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 16:39
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 9409eb9Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6837-recorddrawer-invented-arms branch August 30, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain - #6920

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms
Aug 30, 2026
Merged

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain#6920
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Refs #6837

Refs, deliberately — not a closing keyword. This is the first slice only
of #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.

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. 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, plus
parsed 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 PropertyAssignment is a producer; a PropertyAccessExpression
(def.target) is a reader and is never counted. PropertySignature — a type
declaration — 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 fields container — literally what
this component reads, objectSchema.fields[name].

Sweep A — repo-wide, every emit position, any depth

termrolehitsfiles
targetSUBJECT1329366
referenceToSUBJECT8041
referenceCONTROL19475
reference_toCONTROL13687

Sweep B — THE CELL (emit inside a fields container)

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT22
referenceCONTROL9236
reference_toCONTROL5236

The 2 in-cell referenceTo hits are negative fixtures of the retirement machinery
itself
object-fields-io.spec-keys.test.ts:235 and
MetadataFieldsPage.specKeyReference.test.tsx:75 both poison a draft with the retired
key 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 a fields ancestor was
run — the enclosing object's own type value being reference-bearing
(EXPANDABLE_FIELD_TYPES = lookup / master_detail / tree / user, the same family
the drawer's own isExpandableFieldType reads):

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT2913
referenceCONTROL15558
reference_toCONTROL10064

The two tests agree on target: zero, both ways. All 29 referenceTo hits are test
fixtures at other seams — action params (paramToField, resolveActionParams,
ActionParamDialog), the filter builder, the retirement machinery — none in
plugin-detail, and none reaching this drawer.
The one non-test emitter,
resolveActionParams.ts:532, writes referenceTo onto an ActionParamDef, which is the
action-parameter contract's own live key and a different tier; it never becomes a fields
container. (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 target and referenceTo were counted as. Had the
question 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 target is
itself 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 type value, all 1329 belong to a different
tier
: api (137), url (35), script (32), form (16), flow (13), back (9),
modal (6), fault (5) — action and navigation nodes — plus 1062 with no sibling type
at 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/*.json are
{ type: 'form', fields: [...] }UI component schemas — ObjectUI's own view/field
tier, 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 target shown a producer at this
seam, 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 target is "not in the spec's alias table". That is
not accurate, and the record should say so.
targetis in the alias table — it sits
in FieldSchema's own aliases map in @objectstack/spec as target: "reference".

What the table does, however, is the opposite of declaring the key. Measured
two-directionally against the installed spec:

probeverdict
reference: 'crm_account'ACCEPT
reference_to: 'crm_account'REFUSE — unrecognized_keys, "Did you mean reference_toreference?"
referenceTo: 'crm_account'REFUSE — unrecognized_keys, "Did you mean referenceToreference?"
target: 'crm_account'REFUSE — unrecognized_keys, "Did you mean targetreference?"

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 target explicitly, in order to refuse it. The
premise holds; only its supporting detail needed correcting.


3. Why these two were invented tolerance surface, not redundancy

⇒ 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 only
target, stops resolving a target. Bounded by the ingestion door — a referenceTo-only
def that came through normalizeSchemaReferenceKeys still resolves, because the choke
point stamps both snake_case keys before the drawer sees it. Only a def that bypassed that
door entirely is affected, and for target not even that door ever helped.

⚠️reference_to and reference are deliberately untouched. Choosing between them per
reader 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.)

  1. A header stating the measurement, not just the conclusion — the cell, the subject
    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.
  2. The live arms pinned in the same file as the dead onesreference_to and
    reference resolving, the full field list deriving, the relation still marked readonly.
    Without them, a drawer that simply stopped resolving anything would pass the refusals
    too.
  3. A named refusal case per deleted keydoes NOT read 'referenceTo' and
    does NOT read 'target', each asserting the honest zero.
  4. A precedence-inversion case only where one exists — and here none does. Both
    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.
  5. The floor restated at the pin — ⛔ do not re-add a spelling arm; a producer emitting
    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_to chain the ingestion normalizer already made redundant #6837's open
    scope.

Two extra cases carry the mechanism rather than just the verdict: a referenceTo-only
def throughnormalizeSchemaReferenceKeys still resolves (so the deletion is lossless
at the ingestion door), while a target-only def does not resolve even through the
normalizer (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/core
is aliased by the root vitest config to packages/core/src. Both legs resolve to
source — no package exports hop, no dist/, therefore no rebuild leg that could
leave the ablation measuring stale output.

RED first — this change is behavioural, and the pin was red

A def carrying only target used to resolve and now does not, so the pin can be red and
was. The fact was mutated, never the assertion — the chain in the source was put back,
under a trap ... EXIT INT TERM with absolute paths resolved from
git rev-parse --show-toplevel, restore pinned to git checkout HEAD -- path (never the
bare form, which restores from a possibly-mutated index).

legmutation (the fact)predictedmeasured
A?? def.referenceTo restoredreferenceTo refusal red, rest green1 failed / 7 passed
B?? def.target restoredtarget refusal and the through-the-normalizer case red2 failed / 6 passed
Cthe original four-arm chain restoredall 3 refusals red, all 5 live-arm controls green3 failed / 5 passed
this branch, unmutatedall green8 passed

Leg 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) andgit hash-object diverging from the HEAD blob
(ec43039c…6ebae4f5… / d75723ec… / f05f5819…).

Restore proven both ways, scoped to the mutated path only, after every leg:
git diff HEAD -- path empty andgit hash-object equal to the HEAD blob
(ec43039c…), with the whole tree clean at the end.

The rest

whatcommandresult
the new pinvitest run the pin file8 passed
plugin-detail full suitevitest run packages/plugin-detail/src/115 files / 1053 tests passed
every consumer that mounts the drawer12 explicit paths across plugin-gantt, plugin-calendar, plugin-kanban, plugin-dashboard12 files / 71 tests passed
type-check--filter @object-ui/plugin-detail run type-checkexit 0, script echoed: tsc --noEmit && tsc -p tsconfig.test.json
pin is a program inputtsc -p tsconfig.test.json --listFiles1 — measured, not assumed
lint, the real gate--filter @object-ui/plugin-detail run lintexit 0, 0 errors (880 pre-existing warnings)
gatescheck: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-majorall exit 0, each quoted by its own verdict line

--listFiles was read carefully, because "type-check is clean" can be a true sentence
that says nothing about a new test file.
tsconfig.jsonexcludes**/*.test.tsx, so
the build project sees the pin 0 times; tsconfig.test.json exists precisely to cover
tests 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 blob ec43039c…, counts read from
--format json: 0 errors / 8 warnings, identical both ways. Blob ids were verified in
both directions and the restore was proven by an empty git status. The new pin file has
no 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 closest
house analogue expandableFamily.identity-5874.test.tsx (5 of the same).

NOT MEASURED — reported as such rather than as a colour

check:spec-floors exits 1 with 12 findings, all [no-artifact], zero of them naming
plugin-detail. The gate prints its own remedy: "Build the workspace before running this
gate: pnpm exec turbo run build --filter=!@object-ui/site"
. This worktree built only
plugin-detail's dependency closure, so 12 packages have no dist/ to judge. This is a
PREREQUISITE 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 the
farm exactly once regardless. Type-aware linting is not enabled (no projectService /
project: in eslint.config.js), so this diff cannot move the verdict of any file it does
not touch.

Tree provenance. The ablation and its blob hashes were produced on commit 04cada79.
origin/main then moved under this branch and #6915 landed, so origin/main was merged in
— see the next section — and the suite plus type-check were re-run on the merged head
7856fa8c
, both green, with git status clean.


6. ⚠️ The flagged file overlap with #6915 — resolved, and nothing of theirs was touched

RecordDetailDrawer.tsx was flagged as also touched by then-unlanded PR #6915 (card
#6584). It landed first. origin/main was merged into this branch: clean, no
conflict.
Their hunk is a comment-only addition to the width docblock at ~:77-87
recording 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 :267 was still exact on origin/main at 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 says Refs.

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:

reference_field: def.reference_field??def.referenceField,

FieldSchema refuses both spellings by name, and referenceField has 0 in-cell
producers — but a first census pass returns 0 for the control reference_field as
well
. ⇒ 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 an
edited PR body drops the session form from the footer).

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.0 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-vWzpMzCA.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.13KB116.43KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.40KB62.44KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

CONTRACT_REVIEW_TIER review — head 7856fa8c

Verdict: ACCEPT — no blocking follow-ups. The load-bearing measurements are real: I re-derived the census with my own scanner under two independently chosen cell definitions, re-ran the spec probes two-directionally against the installed spec, re-ran ablation leg C from scratch with hash proof, and verified the #6915 merge file-by-file. One non-blocking observation for the record, below.

⭐ Routing conclusion — clause ② applies; the door is NOT total; the PM did not over-gate

I measured the ingestion-door question rather than inheriting #6916's answer, and it comes out the same way, for the same structural reason:

  • normalizeSchemaReferenceKeys has exactly two production call sites — MetadataProvider.tsx:657 and ObjectStackAdapter.getObjectSchema (data-objectstack/src/index.ts:3753). The fold is a private property of those two implementations, not of the seam.
  • The seam is published twice over. (1) getObjectSchema(objectName): Promise<any> is a required member of the published DataSource interface (types/src/data.ts:519 — "Required for all DataSource implementations"), and all three in-repo drawer mounts feed the drawer from it on the genericdataSource (ObjectKanban.tsx:239, ObjectCalendar.tsx:402, ObjectGantt.tsx:706) — a third-party DataSource implementation reaches the chain raw through supported mount paths, no fold in between. (2) RecordDetailDrawer is itself a public export of @object-ui/plugin-detail with objectSchema as a public prop; this PR's own pin demonstrates raw ingress by passing a bare { name, fields } straight in.
  • The in-repo non-normalizing implementations are harmless by construction, and I checked each: ValueDataSource:395 infers { type: typeof value } fields and ApiDataSource:326 returns an empty stub — neither can emit any reference spelling; the site gallery and console workbench fixtures fall inside the census (0 target, no in-cell referenceTo).

⇒ A def carrying only referenceTo or only targetcan reach this chain from outside the repo, so the reject direction is real at a seam beyond this repo's reach — exactly the #6916 situation, and clause ② was the right gate. Not over-gated. To the PR's credit, it never claims totality: the changeset bounds itself to defs that did enter through the door, and the edited comment retains "the drawer can receive a raw schema from any DataSource". The cost lands only on off-contract producers emitting spellings the spec refuses by name — the card's sanctioned intent (AGENTS.md #0.1).

The census — reproduced independently, both cells

My own structure-walk scanner (TS compiler API + parsed JSON/YAML, PropertyAssignment emits only, shorthand tracked separately, 5854 tracked files):

termrepo-wide (mine)in-cell strictin-cell loosePR claim
target1311 (+19 shorthand)001329 / 0
referenceTo781280 / 2
reference (control)1928992194 / 92 ✓
reference_to (control)1375252136 / 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 api 136, url 35, script 32, form 15, flow 13, back 9, modal 6, fault 5, ~1046 with no sibling type (DOM event/link targets) — sampled hits are action-node endpoint URLs and the action renderers forwarding schema.target. The singlelookup-sibling target emit 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 referenceTo hits are what the PR says they are — I read both: MetadataFieldsPage.specKeyReference.test.tsx:75 poisons legacy_id to assert the read door strips it; object-fields-io.spec-keys.test.ts:235 feeds referenceTo through roundTrip() 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 69b27fb6b022e83fd): 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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 16:39
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 9409eb9Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6837-recorddrawer-invented-arms branch August 30, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain by os-sam · Pull Request #6920 · objectstack-ai/objectui · GitHub
Skip to content

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain - #6920

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms
Aug 30, 2026
Merged

fix(plugin-detail): drop the two undeclared arms from RecordDetailDrawer's relationship-target chain#6920
os-sam merged 2 commits into
mainfrom
claude/issue-6837-recorddrawer-invented-arms

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Refs #6837

Refs, deliberately — not a closing keyword. This is the first slice only
of #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.

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. 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, plus
parsed 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 PropertyAssignment is a producer; a PropertyAccessExpression
(def.target) is a reader and is never counted. PropertySignature — a type
declaration — 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 fields container — literally what
this component reads, objectSchema.fields[name].

Sweep A — repo-wide, every emit position, any depth

termrolehitsfiles
targetSUBJECT1329366
referenceToSUBJECT8041
referenceCONTROL19475
reference_toCONTROL13687

Sweep B — THE CELL (emit inside a fields container)

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT22
referenceCONTROL9236
reference_toCONTROL5236

The 2 in-cell referenceTo hits are negative fixtures of the retirement machinery
itself
object-fields-io.spec-keys.test.ts:235 and
MetadataFieldsPage.specKeyReference.test.tsx:75 both poison a draft with the retired
key 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 a fields ancestor was
run — the enclosing object's own type value being reference-bearing
(EXPANDABLE_FIELD_TYPES = lookup / master_detail / tree / user, the same family
the drawer's own isExpandableFieldType reads):

termrolehitsfiles
targetSUBJECT00
referenceToSUBJECT2913
referenceCONTROL15558
reference_toCONTROL10064

The two tests agree on target: zero, both ways. All 29 referenceTo hits are test
fixtures at other seams — action params (paramToField, resolveActionParams,
ActionParamDialog), the filter builder, the retirement machinery — none in
plugin-detail, and none reaching this drawer.
The one non-test emitter,
resolveActionParams.ts:532, writes referenceTo onto an ActionParamDef, which is the
action-parameter contract's own live key and a different tier; it never becomes a fields
container. (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 target and referenceTo were counted as. Had the
question 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 target is
itself 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 type value, all 1329 belong to a different
tier
: api (137), url (35), script (32), form (16), flow (13), back (9),
modal (6), fault (5) — action and navigation nodes — plus 1062 with no sibling type
at 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/*.json are
{ type: 'form', fields: [...] }UI component schemas — ObjectUI's own view/field
tier, 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 target shown a producer at this
seam, 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 target is "not in the spec's alias table". That is
not accurate, and the record should say so.
targetis in the alias table — it sits
in FieldSchema's own aliases map in @objectstack/spec as target: "reference".

What the table does, however, is the opposite of declaring the key. Measured
two-directionally against the installed spec:

probeverdict
reference: 'crm_account'ACCEPT
reference_to: 'crm_account'REFUSE — unrecognized_keys, "Did you mean reference_toreference?"
referenceTo: 'crm_account'REFUSE — unrecognized_keys, "Did you mean referenceToreference?"
target: 'crm_account'REFUSE — unrecognized_keys, "Did you mean targetreference?"

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 target explicitly, in order to refuse it. The
premise holds; only its supporting detail needed correcting.


3. Why these two were invented tolerance surface, not redundancy

⇒ 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 only
target, stops resolving a target. Bounded by the ingestion door — a referenceTo-only
def that came through normalizeSchemaReferenceKeys still resolves, because the choke
point stamps both snake_case keys before the drawer sees it. Only a def that bypassed that
door entirely is affected, and for target not even that door ever helped.

⚠️reference_to and reference are deliberately untouched. Choosing between them per
reader 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.)

  1. A header stating the measurement, not just the conclusion — the cell, the subject
    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.
  2. The live arms pinned in the same file as the dead onesreference_to and
    reference resolving, the full field list deriving, the relation still marked readonly.
    Without them, a drawer that simply stopped resolving anything would pass the refusals
    too.
  3. A named refusal case per deleted keydoes NOT read 'referenceTo' and
    does NOT read 'target', each asserting the honest zero.
  4. A precedence-inversion case only where one exists — and here none does. Both
    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.
  5. The floor restated at the pin — ⛔ do not re-add a spelling arm; a producer emitting
    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_to chain the ingestion normalizer already made redundant #6837's open
    scope.

Two extra cases carry the mechanism rather than just the verdict: a referenceTo-only
def throughnormalizeSchemaReferenceKeys still resolves (so the deletion is lossless
at the ingestion door), while a target-only def does not resolve even through the
normalizer (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/core
is aliased by the root vitest config to packages/core/src. Both legs resolve to
source — no package exports hop, no dist/, therefore no rebuild leg that could
leave the ablation measuring stale output.

RED first — this change is behavioural, and the pin was red

A def carrying only target used to resolve and now does not, so the pin can be red and
was. The fact was mutated, never the assertion — the chain in the source was put back,
under a trap ... EXIT INT TERM with absolute paths resolved from
git rev-parse --show-toplevel, restore pinned to git checkout HEAD -- path (never the
bare form, which restores from a possibly-mutated index).

legmutation (the fact)predictedmeasured
A?? def.referenceTo restoredreferenceTo refusal red, rest green1 failed / 7 passed
B?? def.target restoredtarget refusal and the through-the-normalizer case red2 failed / 6 passed
Cthe original four-arm chain restoredall 3 refusals red, all 5 live-arm controls green3 failed / 5 passed
this branch, unmutatedall green8 passed

Leg 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) andgit hash-object diverging from the HEAD blob
(ec43039c…6ebae4f5… / d75723ec… / f05f5819…).

Restore proven both ways, scoped to the mutated path only, after every leg:
git diff HEAD -- path empty andgit hash-object equal to the HEAD blob
(ec43039c…), with the whole tree clean at the end.

The rest

whatcommandresult
the new pinvitest run the pin file8 passed
plugin-detail full suitevitest run packages/plugin-detail/src/115 files / 1053 tests passed
every consumer that mounts the drawer12 explicit paths across plugin-gantt, plugin-calendar, plugin-kanban, plugin-dashboard12 files / 71 tests passed
type-check--filter @object-ui/plugin-detail run type-checkexit 0, script echoed: tsc --noEmit && tsc -p tsconfig.test.json
pin is a program inputtsc -p tsconfig.test.json --listFiles1 — measured, not assumed
lint, the real gate--filter @object-ui/plugin-detail run lintexit 0, 0 errors (880 pre-existing warnings)
gatescheck: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-majorall exit 0, each quoted by its own verdict line

--listFiles was read carefully, because "type-check is clean" can be a true sentence
that says nothing about a new test file.
tsconfig.jsonexcludes**/*.test.tsx, so
the build project sees the pin 0 times; tsconfig.test.json exists precisely to cover
tests 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 blob ec43039c…, counts read from
--format json: 0 errors / 8 warnings, identical both ways. Blob ids were verified in
both directions and the restore was proven by an empty git status. The new pin file has
no 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 closest
house analogue expandableFamily.identity-5874.test.tsx (5 of the same).

NOT MEASURED — reported as such rather than as a colour

check:spec-floors exits 1 with 12 findings, all [no-artifact], zero of them naming
plugin-detail. The gate prints its own remedy: "Build the workspace before running this
gate: pnpm exec turbo run build --filter=!@object-ui/site"
. This worktree built only
plugin-detail's dependency closure, so 12 packages have no dist/ to judge. This is a
PREREQUISITE 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 the
farm exactly once regardless. Type-aware linting is not enabled (no projectService /
project: in eslint.config.js), so this diff cannot move the verdict of any file it does
not touch.

Tree provenance. The ablation and its blob hashes were produced on commit 04cada79.
origin/main then moved under this branch and #6915 landed, so origin/main was merged in
— see the next section — and the suite plus type-check were re-run on the merged head
7856fa8c
, both green, with git status clean.


6. ⚠️ The flagged file overlap with #6915 — resolved, and nothing of theirs was touched

RecordDetailDrawer.tsx was flagged as also touched by then-unlanded PR #6915 (card
#6584). It landed first. origin/main was merged into this branch: clean, no
conflict.
Their hunk is a comment-only addition to the width docblock at ~:77-87
recording 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 :267 was still exact on origin/main at 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 says Refs.

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:

reference_field: def.reference_field??def.referenceField,

FieldSchema refuses both spellings by name, and referenceField has 0 in-cell
producers — but a first census pass returns 0 for the control reference_field as
well
. ⇒ 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 an
edited PR body drops the session form from the footer).

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3179.0 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-vWzpMzCA.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.13KB116.43KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)175.69KB48.80KB
fields (index.js)243.65KB61.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.40KB62.44KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.32KB32.69KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.83KB21.11KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-samClaude

Copy link
Copy Markdown
CollaboratorAuthor

CONTRACT_REVIEW_TIER review — head 7856fa8c

Verdict: ACCEPT — no blocking follow-ups. The load-bearing measurements are real: I re-derived the census with my own scanner under two independently chosen cell definitions, re-ran the spec probes two-directionally against the installed spec, re-ran ablation leg C from scratch with hash proof, and verified the #6915 merge file-by-file. One non-blocking observation for the record, below.

⭐ Routing conclusion — clause ② applies; the door is NOT total; the PM did not over-gate

I measured the ingestion-door question rather than inheriting #6916's answer, and it comes out the same way, for the same structural reason:

  • normalizeSchemaReferenceKeys has exactly two production call sites — MetadataProvider.tsx:657 and ObjectStackAdapter.getObjectSchema (data-objectstack/src/index.ts:3753). The fold is a private property of those two implementations, not of the seam.
  • The seam is published twice over. (1) getObjectSchema(objectName): Promise<any> is a required member of the published DataSource interface (types/src/data.ts:519 — "Required for all DataSource implementations"), and all three in-repo drawer mounts feed the drawer from it on the genericdataSource (ObjectKanban.tsx:239, ObjectCalendar.tsx:402, ObjectGantt.tsx:706) — a third-party DataSource implementation reaches the chain raw through supported mount paths, no fold in between. (2) RecordDetailDrawer is itself a public export of @object-ui/plugin-detail with objectSchema as a public prop; this PR's own pin demonstrates raw ingress by passing a bare { name, fields } straight in.
  • The in-repo non-normalizing implementations are harmless by construction, and I checked each: ValueDataSource:395 infers { type: typeof value } fields and ApiDataSource:326 returns an empty stub — neither can emit any reference spelling; the site gallery and console workbench fixtures fall inside the census (0 target, no in-cell referenceTo).

⇒ A def carrying only referenceTo or only targetcan reach this chain from outside the repo, so the reject direction is real at a seam beyond this repo's reach — exactly the #6916 situation, and clause ② was the right gate. Not over-gated. To the PR's credit, it never claims totality: the changeset bounds itself to defs that did enter through the door, and the edited comment retains "the drawer can receive a raw schema from any DataSource". The cost lands only on off-contract producers emitting spellings the spec refuses by name — the card's sanctioned intent (AGENTS.md #0.1).

The census — reproduced independently, both cells

My own structure-walk scanner (TS compiler API + parsed JSON/YAML, PropertyAssignment emits only, shorthand tracked separately, 5854 tracked files):

termrepo-wide (mine)in-cell strictin-cell loosePR claim
target1311 (+19 shorthand)001329 / 0
referenceTo781280 / 2
reference (control)1928992194 / 92 ✓
reference_to (control)1375252136 / 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 api 136, url 35, script 32, form 15, flow 13, back 9, modal 6, fault 5, ~1046 with no sibling type (DOM event/link targets) — sampled hits are action-node endpoint URLs and the action renderers forwarding schema.target. The singlelookup-sibling target emit 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 referenceTo hits are what the PR says they are — I read both: MetadataFieldsPage.specKeyReference.test.tsx:75 poisons legacy_id to assert the read door strips it; object-fields-io.spec-keys.test.ts:235 feeds referenceTo through roundTrip() 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 69b27fb6b022e83fd): 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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 16:39
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 9409eb9Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6837-recorddrawer-invented-arms branch August 30, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-sam@claude