Skip to content

Field designer offers a referenceTo control for lookup fields — a key FieldSchema rejects by name, so saving a lookup field hard-blocks the object #6041

Description

@yinlianghui-tw

Surfaced by the key-level parity gate built for #5761 (scripts/check-designer-field-key-parity.mjs). Filed rather than fixed: #5761's ruling is that the gate surfaces instances and each surfaced key gets its own card, because the three prior instances (#4644 indexed, #4687 distance_metric, #4676 placeholder) took three different correct resolutions.

The key

referenceTo is not in FieldSchema's accept set. Measured against the installed @objectstack/spec 17.2.0:

FieldSchema.safeParse({ type:'lookup', label:'Account', referenceTo:'account' })
  => success = false
  => [{ code: 'unrecognized_keys', keys: ['referenceTo'] }]

The spec's spelling is reference:

reference — Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects.

Why this is live, not latent

Unlike sortOrder (declared but never populated), referenceTo has a live form control and two live writers:

  • packages/plugin-designer/src/FieldDesigner.tsx:311 — the field editor renders an input for it, visibleWhen: "record.type == 'lookup'".
  • packages/plugin-designer/src/FieldDesigner.tsx:221,242 — both the create and the update path write referenceTo: data.referenceTo ? String(data.referenceTo) : undefined.
  • packages/plugin-designer/src/MetadataFieldsPage.tsx — fromDesignerField writes referenceTo: designed.referenceTo onto the ServerFieldSchema that is PUT.
  • packages/app-shell/src/services/MetadataService.ts:104 — toFieldPayload writes referenceTo: field.referenceTo onto FieldMetadataPayload, which saveFields PUTs as fields.map(toFieldPayload).

So an author who creates a lookup field in the designer and fills in the referenced object writes a key the API refuses. This is the #4644 shape exactly: PUT /api/v1/meta/object/:name returns a hard 422 INVALID_METADATA, which then blocks every subsequent save of that object until the key is cleared — and the author has no way to tell from the designer UI which key did it.

Also note the read direction is broken symmetrically: toDesignerField reads raw.referenceTo from the server payload, but a spec-parsed server sends reference, so an existing lookup field loads into the designer with an empty reference box.

Why this is not a mechanical rename in this card

reference is the obvious candidate, but choosing it is an adjudication:

Both directions (read and write), both designers, and the i18n label key appDesigner.fieldDesigner.referenceTo are in scope for whoever takes it.

Verification

pnpm check:designer-field-key-parity reports it. The key is recorded in that gate's KNOWN_UNPARSEABLE_KEYS ledger pointing here; removing it from the payload shapes without removing the ledger entry is also red, so the ledger cannot go stale.

Refs: #5761 (the gate) · #4644 · #4687 · #4676

Activity

  1. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    Contributor

    Triage: lands in packages/plugin-designer (FieldDesigner.tsx, MetadataFieldsPage.tsx) + app-shell/services/MetadataService.ts; routed pm:queue + domain:ui, type Bug — live hard-block: authoring a lookup field through the field designer 422-blocks every later save of the object, and the read side loads existing lookups with an empty reference box. Family + fold-or-serial (answered): #6041/#6043/#6044 all come from the #5761 parity gate and touch the same files — #6041 + #6044 are a fold candidate (both are spelling-parity repairs toward the spec's declared key, each independently verifiable per key against the gate's ledger; all members now graded); #6043 is excluded from the fold (its resolution runs through the CEL expression-language question — a different repair shape) and serializes behind whichever lands first on these files. Per #5761's ruling each key keeps its own card either way — a fold shares the branch/PR with per-card commits and one Fixes line per member. Repair-direction note: the spec's reference is required for relationship types, so the rename changes what a half-filled draft does — that edge is in scope, not a surprise.


    Generated by Claude Code

  2. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 25, 2026
  3. added theissue type on Aug 25, 2026
  4. self-assigned this
    on Aug 25, 2026
  5. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    Collaborator

    Claim: PM loop round 39 — FOLD: #6041 + #6044
    Session: session_01CSoz9uGhaaSgiq3hshtN7L
    Branch: claude/issue-6041-designer-spec-key-parity
    Worktree: objectui-issue-6041
    Domain: domain:ui
    Container & model: L, mode:subagent, model: opus
    Clause-②: yes — the designer stops emitting keys FieldSchema refuses by name, so what a save accepts changes. Declared; Opus under this seat's recorded Fable-quota exemption.

    File surface: packages/plugin-designer (FieldDesigner.tsx, MetadataFieldsPage.tsx) + packages/app-shell/src/services/MetadataService.ts, plus tests and a changeset.

    裁决 — triage (2026-08-25 00:26Z), binding

    lands in packages/plugin-designer (FieldDesigner.tsx, MetadataFieldsPage.tsx) + app-shell/services/MetadataService.ts; pm:queue + domain:ui, type Bug — live hard-block: authoring a lookup field through the field designer 422-blocks every later save of the object, and the read side loads existing lookups with an empty reference box.
    Family + fold-or-serial (answered): #6041/#6043/#6044 all come from the #5761 parity gate and touch the same files — #6041 + #6044 are a fold candidate (both are spelling-parity repairs toward the spec's declared key, each independently verifiable per key against the gate's ledger; all members now graded); #6043 is EXCLUDED from the fold (its resolution runs through the CEL expression-language question — a different repair shape) and serializes behind whichever lands first on these files.
    Per #5761's ruling each key keeps its own card either way — a fold shares the branch/PR with per-card commits and one Fixes line per member.
    Repair-direction note: the spec's reference is required for relationship types, so the rename changes what a half-filled draft does — that edge is in scope, not a surprise.

    The two members

    card designer emits spec declares consequence today
    #6041 referenceTo on lookup fields reference FieldSchema refuses by name → saving a lookup field hard-blocks the object
    #6044 isSystem system system-field detection reads a key the server never sends, and the key round-trips back out as a 422

    ⛔ #6043 (formula → expression) is NOT in this fold. ⛔ Do not touch it, do not "fix it while you're there" — triage excluded it because a bare rename would ship non-CEL expressions under a valid key, i.e. a silent runtime failure strictly worse than the loud 422.

    PM mechanism assumptions — measure, do not inherit

    1. ⛔ Re-derive every site. These were filed 2026-08-24 and main has moved a great deal overnight. Report each read/write site as you find it, ⛔ not as the card names it. (On [Decision] schema.onNavigate is a function value read off the grid schema — the fifth key #5091's ruling did not cover #5234 tonight a card's line number was stale by 147 lines.)
    2. ⚠️ The rename has a behavioural edge the note names, and it is yours to handle: reference is required for relationship types, so a half-filled draft that was previously savable (carrying referenceTo, ignored) may now fail validation, or vice-versa. ✅ Measure what a half-filled draft does before and after, and state it. ⛔ Do not discover this in review.
    3. ⚠️ There are two directions per key and they can be fixed independently — check both. The write side (designer emits the wrong key → 422) and the read side (existing records load with an empty reference box, because the reader looks for the wrong key). ⚠️ Fixing only the write side leaves every already-saved field still unreadable. ✅ Say explicitly, per key, that both directions are covered.
    4. ⚠️ MetadataService.ts is shared with the read path — confirm whether the round-trip (isSystem out → 422) is a separate site from the detection read, or the same one. The Both designer field shapes spell the spec's system flag as isSystem — so system-field detection reads a key the server never sends, and the key round-trips back out as a 422 #6044 card describes both symptoms; they may or may not be one line.
    5. ⚠️ The finding: nothing guards the CLASS "a field designer authors a key FieldSchema refuses" — three per-key tombstones, no key-level parity check #5761 parity gate has a ledger. ✅ Each key is "independently verifiable per key against the gate's ledger" — so use the ledger as your oracle and quote its entry for each of the two keys, before and after. ⛔ If the ledger does not move when you fix a key, your fix is not the one it measures.

    Fold mechanics — ⚠️ get these exactly right

    Verification

    ⚠️ Reproduce both hard-blocks before fixing — the 422 on save and the empty reference box on load — red for a value reason, ⛔ not a mount error. Predict the direction before running.

    ⚠️ Commit before you ablate. ⛔ git checkout <ref> -- <path> writes the INDEX as well as the worktree, so reading a baseline that way and restoring with git checkout -- <path> restores the baseline over your work.

    ⚠️ Seven phantom-class defects were self-caught by devs in the last two days, all one root: an assertion or control that cannot distinguish the two states of the world. Among them: a shared mock object carrying a previous test's write into the next case; an entire harness whose two principals degenerated to the same value; a grep -oE with an unescaped ( returning 0 for every pattern; and a probe reporting its verdict through a console.log vitest never surfaced, so it printed the same answer in both states. ✅ State explicitly which of your assertions would still pass if your change were reverted, and make each control a string present in both versions of the file.

    ⚠️ Ablate per key, not per PR — removing #6041's fix must red only #6041's cases and leave #6044's green, and vice-versa. That is what "independently verifiable per key" means, and it is the fold's whole justification.

    Prove every mutation on disk by grepping injected and separately removed text; print the landing site; ⚠️ assert anchor uniqueness before writing, bounded to the right span — a file-wide anchor with 2 hits was caught by that assert on #5234 tonight; restore under trap … EXIT INT TERM with a cwd-independent command; confirm git diff HEAD --stat empty.

    Gates by name, with exit codes: type-check for @object-ui/plugin-designer and @object-ui/app-shell (⚠️ hyphenated; echo the script name; distinguish unbuilt-closure red from missing-node_modules red; ⚠️ build ./examples/** too), root-form vitest quoting per-test names, the downstream prefix-form sweep with direction demonstrated, eslint with merge-base delta (⚠️ plain eslint .; ⛔ no --no-inline-config), and the #5761 parity gate itself — that one is your oracle, ⛔ not optional.

    ⚠️ The gate set grew overnight: runs now carry 25 checks, up from 19 yesterday. New ones include Pre-Install Import Graph Check, Inert vi.mock Specifier Check and Doc Fence Language Check. ✅ Derive your set by enumerating each CI job's own step list, ⛔ not top-level script names.

    ⚠️ Use pnpm exec vitest run [paths] from the repo root — ⛔ not pnpm --filter … test -- [paths], which silently drops everything after -- (guarded, objectui#3288). ⚠️ Zero-match pnpm trap: a filter matching nothing prints "No projects matched the filters" and exits 0. ⚠️ A cached turbo green is not a measurement — use --force on any turbo gate you treat as evidence. Run node scripts/check-changeset-presence.mjs rather than guessing. Changeset patch unless a published signature moves; ⛔ never major.

    Deliverable

    Draft PR, body with both Fixes #6041 and Fixes #6044. ⛔ Do not mark ready, ⛔ do not enqueue, ⛔ no auto-merge — the PM lands it. Post the terminal report on this issue (plain-text first line os-dev-report; ⛔ avoid short angle-bracket placeholders) and return it. State per gate: command, exit code, what a red would have meant; every re-derived site; the ledger entry for each key before and after; what a half-filled draft does before and after; that both directions (write and read) are covered per key; which of your assertions would still pass on a revert; the per-key ablation result; and any deviation with its reason.


    Generated by Claude Code

  6. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    Collaborator

    os-dev-report

    {
      "issue": 6041,
      "status": "done",
      "branch": "claude/issue-6041-designer-spec-key-parity",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6225",
      "premise_still_valid": true,
      "summary": "FOLD #6041 + #6044 delivered on one branch, one draft PR, per-card commits (1eabaa2f6 = #6041, 8c09d055d = #6044), both `Fixes` lines present, #6043 untouched and still open. #6041: `referenceTo` -> `reference` on both wire-bound shapes (FieldMetadataPayload.toFieldPayload, ServerFieldSchema.fromDesignerField), the read `toDesignerField` now reads raw.reference, and `referenceTo` joins RETIRED_FIELD_KEYS so a stored misspelling cannot ride back out through carryOver's verbatim spread and keep a blocked object blocked. #6044: read now reads raw.system, and the write half — which has NO emit site, only the carryOver spread — is closed by a tombstone paired with the read fix, never as a substitute for it. RE-DERIVED SITES (card line numbers were stale by 6-10 lines everywhere): #6041 write = FieldDesigner.tsx:221,242 (create/update, UI model only), :311 (control), :350 (initialValues), MetadataFieldsPage.tsx:148 fromDesignerField -> now :154, MetadataService.ts:106 toFieldPayload -> now :111; #6041 declarations = MetadataService.ts:65, MetadataFieldsPage.tsx:54, types/designer.ts:817; #6041 read = MetadataFieldsPage.tsx:98 -> now :104. #6044 read = MetadataFieldsPage.tsx:95 -> now :108; #6044 declaration = MetadataFieldsPage.tsx:60 -> now :73; #6044 write = NOT a named line at all, it is the `...carryOver(prev)` spread. PM ASSUMPTION 4 ANSWERED: the round-trip and the detection read are SEPARATE sites, and NEITHER is in MetadataService.ts — FieldMetadataPayload never declared isSystem, so toFieldPayload had nothing to fix; #6044 is entirely inside plugin-designer. PM ASSUMPTION 2 PARTLY FALSIFIED (measured, not inherited): the spec's prose calls `reference` required for relationship types, but that is NOT enforced by the zod parse at @objectstack/spec 17.2.0 — FieldSchema.safeParse({type:'lookup',label:'L'}) and ObjectSchema with a reference-less lookup or master_detail both parse GREEN. DESIGN CHOICE: the in-memory DesignerFieldDefinition keeps `referenceTo`/`isSystem` — that is the internal prop name every other UI surface here already uses (LookupField, filter-builder, ObjectChart, ListView, UserFilters), it reaches no wire-bound shape, the gate classifies both as `uiOnly` beside the pre-existing `id`/`validationRules`, and it leaves the published type signature unmoved, so both changesets are `patch`. The object-shape isSystem in MetadataObjectsPage/ObjectManager was measured separately as the card asked: ObjectSchema ACCEPTS isSystem (42-key accept set), so it is not the same defect and was not touched.",
      "tests": "All at HEAD 8c09d055d (final commit), tree clean, each exit read from the runner's own verdict line and never a bare $? behind a pipe. REPRODUCTION FIRST, value-reason red not a mount error, direction predicted before running: ObjectSchema.safeParse of the real PUT document with a designer-authored lookup => success=false, unrecognized_keys at [\"fields\",\"rel\"] keys=[\"referenceTo\"]; same with isSystem => unrecognized_keys keys=[\"isSystem\"]; both spec spellings (reference / system) parse green — a key-by-key result, not a schema that refuses everything. LEDGER (the oracle, `pnpm check:designer-field-key-parity`, exit 0 before AND after). BEFORE: referenceTo objectui#6041 (spec spells it `reference`) / formula objectui#6043 / isSystem objectui#6044 (spec spells it `system`) / sortOrder objectui#6045. AFTER: formula objectui#6043 / sortOrder objectui#6045 only — referenceTo and isSystem left the ledger and appear in the gate's UI-only list beside id and validationRules. Both entries had to be REMOVED, not edited: the ratchet reds an entry that no longer applies. HALF-FILLED DRAFT, BEFORE AND AFTER: identical — a lookup with the target box empty serialises to byte-identical wire bytes under either spelling (JSON.stringify drops the undefined) and saves in both; the in-memory literal disagrees (zod's strict object counts a key whose value is undefined), which is why every assertion is on the captured PUT bytes. BOTH DIRECTIONS COVERED PER KEY: #6041 write = MetadataService.toFieldPayload + MetadataFieldsPage.fromDesignerField emit `reference`, read = toDesignerField reads raw.reference, plus the carryOver tombstone for already-stored misspellings; #6044 read = toDesignerField reads raw.system, write = tombstone on the carryOver spread, with `system` itself deliberately NOT stripped so the repaired read keeps being fed. GATES: check:designer-field-key-parity exit 0 (red = a refused key on a wire shape or a stale ledger entry); `pnpm --filter @object-ui/plugin-designer --filter @object-ui/app-shell type-check` exit 0 with `type-check$ tsc --noEmit && tsc -p tsconfig.test.json` and `type-check: Done` echoed for BOTH packages, so not a zero-match no-op (red = the narrowed payload types do not compile); root-form `pnpm exec vitest run` over plugin-designer + app-shell services/utils/metadata-admin + the gate's own self-test = 240 files, 2570 passed, 1 skipped (red = a regression, or the self-test rejecting the edited ledger); downstream PREFIX-form sweep `--filter '...@object-ui/app-shell' --filter '...@object-ui/plugin-designer' build` exit 0, 38 packages — DIRECTION DEMONSTRATED by enumerating both forms: prefix selects app-shell/console/both example consoles (consumers), suffix selects types/core/components (dependencies); its first run was red with `Cannot find module '@object-ui/plugin-map'`, diagnosed as UNBUILT-CLOSURE (apps/console/node_modules/@object-ui/plugin-map resolves to packages/plugin-map, which had no dist/) rather than missing-node_modules, and green after building each consumer's own ^... closure. eslint: `pnpm --filter @object-ui/plugin-designer --filter @object-ui/app-shell lint`, each a full plain `eslint .` (no --no-inline-config), exit 0, 0 ERRORS in both (67 and 2673 pre-existing warnings), plus `pnpm exec eslint scripts/check-designer-field-key-parity.mjs` exit 0 — a complete run for every file in the diff, not a narrowing. check:phantom-deps 0 (this is the gate that judges the tests' @objectstack/spec import: it is a tooling file and root devDependencies declares the package), check:vi-mock-specifiers 0, check:control-bytes 0, check:doc-fences 0, check:entry-guard 0, check:pre-install-import-graph 0, check-changeset-presence 0 (2 changesets for 5 source files of 2 released packages), check-changeset-no-major 0, check-changeset-fixed 0, check-type-check-coverage 0, check-lint-coverage 0. REVERSE VERIFICATION (direction predicted: turn red): pasting `referenceTo: field.referenceTo` back into the FieldMetadataPayload literal gives `src/services/MetadataService.ts(112,5): error TS2561: Object literal may only specify known properties, but 'referenceTo' does not exist in type 'FieldMetadataPayload'. Did you mean to write 'reference'?`, Exit status 2; restored under trap, git diff HEAD --stat empty. Independently the REBUILT packages/app-shell/dist/services/MetadataService.d.ts:58 reads `reference?: string;`, so this is a fresh build and not a cached declaration. PER-KEY ABLATION (commit-first; anchors asserted unique before writing; every mutation proven on disk by grepping the injected AND the removed text separately, never an editor exit code; landing sites printed; restore under `trap ... EXIT INT TERM` with cwd-independent `git -C`; git diff HEAD --stat empty after each): revert #6041's fix ALONE => 6 failures, ALL of them in MetadataFieldsPage.specKeyReference.test.tsx and MetadataService.specKeyReference.test.ts, while specKeySystem.test.tsx stayed fully green 7/7. Revert #6044's fix ALONE => 3 failures, ALL in MetadataFieldsPage.specKeySystem.test.tsx, while both reference files stayed fully green 13/13. Each mutated tree was asserted to still contain the OTHER card's fix as a literal string present in both versions of the file, so neither run could pass by accident. WHICH ASSERTIONS WOULD STILL PASS ON A REVERT (measured in the ablation runs, not guessed): both files' instrument blocks (schema is strict, refuses the misspelling, accepts the spec spelling) — they pin the oracle; 'a half-filled draft still saves' in both files — deliberately revert-insensitive, it asserts the unchanged half; 'an ordinary business field is NOT flagged' (#6044) — undefined is falsy in both worlds, it is the negative principal that stops the harness degenerating; 'keeps the spec-spelled system' (#6044) — system is a real FieldSchema key carryOver always spread, it asserts the tombstone does not over-reach; and 'PUTs the target under the spec spelling' (#6041, owner_id) — measured green under ablation because with the read dead carryOver still carries the server's own `reference` through, so the revert-sensitive form of that claim is the NEWLY AUTHORED lookup field case, which does fail on revert. Everything else fails on a revert of its own card. ONE FIXTURE DEFECT SELF-CAUGHT: the first run of the #6041 test failed on `helpText`, a key copied from the #4644 precedent fixture that FieldSchema also refuses — the instrument caught my own fixture; swapped to `inlineHelpText`, a real spec key the designer does not render, so the 'unknown key survives carryOver' control still means what it says.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6223: object-level metadata payloads emit `group`, `sortOrder` and `relationships` — three keys ObjectSchema refuses by name (measured, with isSystem/pluralLabel as accepted controls); same class one level up, outside the #5761 gate's reach because its PAYLOAD_SHAPES are field shapes only, and `group` has a live control in the Object Manager. No label, left for PM triage.",
        "filed as #6224 (label `finding`): packages/app-shell/src/utils/metadataConverters.ts has zero importers repo-wide and is not in app-shell's published entry — a dead second copy of the read converter that still derives the system flag from a name heuristic (readonly && name in id/createdAt/updatedAt) instead of the spec `system` flag, and declares a three-way referenceTo tolerance. Notes the adjacent live `def.isSystem === true || def.system === true` tolerance in plugin-dashboard/src/recordFields.tsx:56."
      ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions