Skip to content

fix(rest): export lookup cells name the record by the target object's title field - #24980

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24944-export-lookup-name-field
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24944-export-lookup-name-field

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24944

Clause-②: no

What changes

GET /api/v1/data/:object/export (Display → Export as CSV / XLSX) wrote a lookup, master_detail, user or tree cell from the lookup field's own displayField. Without one, it took the first scalar key of a fixed list (name, title, label, full_name, …) found on the expanded record. It never read the target object's nameField. A HotCRM contact declares nameField: 'full_name', has no name column and has a job title column, so it exported as its job title.

  • packages/rest/src/export-format.ts
    • New withReferenceTitleFields(metaMap, readSchema). It reads each reference target's schema once per export (deduplicated by target) and stamps titleField on that field's meta. It is best-effort per target and never throws: a target whose schema cannot be read keeps the key-list fallback.
    • ExportCellMeta is an export-only extension of core's ExportFieldMeta with that one key. ExportFieldMeta in @objectstack/core is not touched, and nothing new is exported from the package index.
    • displayFromRecord reads, in order: the lookup's own displayField; else the target's titleField; else, only when the target resolves no title, the key list; else the record id.
  • packages/rest/src/data-action-routes.ts
    • The export route's schema read becomes one local readObjectSchema (the same getMetaItem, then getObjectSchema, order as before). It serves the export's own object and each target.
    • metaMap = await withReferenceTitleFields(metaMap, readObjectSchema) runs before the $expand is built.
  • No key, query parameter, header, column set or payload shape moves. Only the text of a lookup cell changes, in csv, xlsx and json alike.

The title rule: resolveDisplayField, not titleFieldOf

  • titleFieldOf (packages/objectql/src/record-title.ts) is not reachable from @objectstack/rest without a new runtime dependency. @objectstack/objectql is only a devDependency here.

  • resolveDisplayField (@objectstack/spec/data, display-name.ts) is reachable through the existing dependency. It is ADR-0079's single resolver, and plugin-audit and plugin-approvals use it to name a related record. Its rungs are nameField, then displayNameField, then a derivation. The first two rungs are exactly titleFieldOf's rule. The formatter receives the resolved field and holds no rule of its own.

  • On every body the protocol serves, the two answer the same. At registration the registry runs provisionPrimary(schema, { synthesize: false }), which is resolveDisplayField, and stamps the result as nameField. Measured through protocol.getMetaItem on a real engine (a throwaway probe, not committed):

    Registered object (no pointer declared) Served nameField Old key-list pick
    id, subject (text), email subject email
    id, label (select) id label (the raw option code)
    title, full_name (text) title title
    amount (number) only none none (id floor)
  • They differ only on a served body that carries no pointer but has a title-eligible field. There resolveDisplayField gives ADR-0079's derivation where titleFieldOf gives nothing. None of the measured registry bodies is in that state.

  • Consequence: an object that declares no title is still served with a designated one. Its lookup cells now follow that designated title. In the table above, subject replaces email, and the id replaces a raw select code.

Visible changes beyond the reported case

  • Account. HotCRM's account declares nameField: 'display_title' and has a literal name column. Its lookup cells change from name to display_title. That follows the declaration (pinned: Acme becomes Acme (EMEA)).
  • Field-level security. The $expand sub-read is the engine's own find on the target, so FLS deletes an unreadable title field from the expanded record. The cell then falls to the record id, not to the key list. The card's mechanism assumption 3 expected the fallback "as it does today". Today's fallback for an unreadable full_name is the job title, which is the defect itself. The binding ruling keeps the key list only for an object that declares neither.
  • The lookup field's own displayField still wins (pinned as a control).

Evidence

All readings are on packages/rest. They are taken at 0fb2a82b06 (the fix) and 6dabdfbeab (the unit pins and the changeset), on top of base 693352e4c.

  • Red on main. src/export-lookup-title-field.test.ts run against the unmodified route: Tests 5 failed | 2 passed (7). The contact cell read [ '1', 'VP of Engineering' ] where [ '1', 'John Smith' ] was expected. The two controls (the lookup's own displayField, and a target served with no pointer) were green.
  • Green with the fix. The same file, now 10 tests: Tests 10 passed (10).
  • Ablation 1, the title-field read. withReferenceTitleFields's resolveDisplayField(schema as never) was replaced by undefined through scripts/ablation-replace.mjs (anchor 1 → 0, blob 6fd4e383 → dbe37ef0). Result: Tests 5 failed | 2 passed (7), with the first pin red. Restored to 6fd4e383032f == HEAD, and git diff HEAD was empty.
  • Ablation 2, the key list held back when a title exists. } else { was replaced by } { (blob 6fd4e383 → f6f403cc). Result: Tests 1 failed | 6 passed (7), the FLS pin. Restored hash-equal to HEAD.
  • The subject is this package's own src/, reached by relative import, so the ablations needed no dist/ rebuild.

Gates

All readings below are at the final head 321ea1f2be. That is 6dabdfbeab with origin/main 6440cf1633 merged in; the merged commit touches only plugin-security and its changeset.

  • pnpm lint (eslint . --no-inline-config): exit 0, no findings printed.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 66 derived commands, plus the 10 families under "Declared WIDE population". All 76 were run with the exit code captured before any pipe, and all 76 exited 0.
  • --ran reconciliation: ✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero — all 66 recorded an exit code and none of them is 3).
  • pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2: Test Files 306 passed (306), Tests 6154 passed | 326 skipped (6480).
  • pnpm --filter @objectstack/rest typecheck: exit 0.

Acceptance notes

  • Export, then re-import, of a lookup column. The import's reference resolver (packages/core/src/utils/import-runner.ts, resolveRef) matches a cell against id, the lookup's displayField, then name, title, label, full_name, email and username. It never reads the target's title field. Measured through POST /api/v1/data/xt_case/import on the fixture of this PR:
    • John Smith (a contact named by its formula full_name) answers reference_not_found. So do Wren (a stored displayNameField that is not on the list) and Acme (EMEA) (a formula display_title).
    • The cells main wrote all resolved: VP of Engineering and Chief Procurement Officer by title, and Acme by name.
    • So a file this export writes no longer re-imports those lookup columns. The import side is in packages/core, which is held off for this card. It is reported to the dispatching seat as a finding, and not fixed here.
  • No other import or export path was changed.

Generated by Claude Code

… title field

A lookup cell in GET /data/:object/export read the lookup's own displayField
and then a fixed key list on the expanded record, never the target object's
nameField, so a contact (nameField full_name, no name column) exported as its
job title. The route now resolves each reference target's title field once per
export with ADR-0079's resolveDisplayField and hands it to the formatter on the
field meta; the key list remains only for a target that resolves no title.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 10 documentable anchor(s).

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

  • content/docs/ui/views.mdx (via titleField (symbol, a field of interface ExportCellMeta))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via titleField (symbol, a field of interface ExportCellMeta))
  • content/docs/releases/v17/index.mdx (via ExportFieldMeta (symbol, a top-level type))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f32d795ce511c0644a0bc2b110710f8a181d87bb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f716c1973339263f8eb32b0afa3f976dc5381590 — the merge of head 321ea1f2be2f926af9fd431a82b389a74d20b3a1 into base f32d795ce511c0644a0bc2b110710f8a181d87bb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f716c1973339263f8eb32b0afa3f976dc5381590 && git checkout f716c1973339263f8eb32b0afa3f976dc5381590
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f32d795ce511c0644a0bc2b110710f8a181d87bb 321ea1f2be2f926af9fd431a82b389a74d20b3a1 && git checkout -B drift-repro f32d795ce511c0644a0bc2b110710f8a181d87bb && git merge --no-ff 321ea1f2be2f926af9fd431a82b389a74d20b3a1

node scripts/docs-audit/affected-docs.mjs --json f32d795ce511c0644a0bc2b110710f8a181d87bb

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f32d795ce511c0644a0bc2b110710f8a181d87bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT

domain:cli seat 2 (#22648) · os-justin · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-20T20:51Z · head 321ea1f2be2f926af9fd431a82b389a74d20b3a1 · claim 6299507142 → report 6301167497. No contract face (Clause-②: no: only a lookup cell's text moves), so no review of record is owed.

Check Reading
Shape Draft, base main. First line Fixes #24944; Clause-②: no line-initial. Assignee os-justin = the card's. One changeset, @objectstack/rest: patch.
Surface 4 files, +426 / −23: export-format.ts, the export route in data-action-routes.ts, one new pin file, and the changeset, inside the claim. packages/core/**, packages/objectql/** and packages/spec/** are untouched (core's ExportFieldMeta is extended locally as ExportCellMeta). No new package dependency. check-governed-merges.mjs --pr 24980: NOT governed.
Mechanism Read in the diff and at source.
• The route reads each reference field's target schema once per export, through the same getMetaItem / getObjectSchema read it already used.
• withReferenceTitleFields stamps resolveDisplayField(schema) (packages/spec/src/data/display-name.ts:220: nameField ?? displayNameField ?? derived, ADR-0079) on the field meta.
• displayFromRecord takes the lookup's own displayField, then that title field. The key list is used only for a target that resolves no title. The id is the floor.
• A title field the caller cannot read is deleted by the $expand read, so the cell falls to the id. No other column stands in for the name.
Evidence 5 of 7 pins red on main (VP of Engineering where John Smith is owed); both controls green. Ablating the title read reds the first pin; opening the key-list branch reds the field-level-security pin. Each was restored hash-equal. The rest package in full: 306 files / 6154 tests.
Gates Local at the head: 66/66 derived families and the 10 Declared WIDE population families exit 0, 0 UNRUN; pnpm lint exit 0. Check-runs on this head (38, latest per name), read at this act: 15 success, 7 skipped, 16 in_progress. Landing waits on all of them.

Accepted deviations:

  • The rule is the spec's resolveDisplayField, not objectql's titleFieldOf. The latter is reachable only through a new runtime dependency. The spec function is the single arbiter, and the registry stamps nameField with it at registration, so over a served schema the two answer the same (4 shapes measured).
  • The field-level-security case falls to the id rather than the key list, as Zone 1 binds.
  • main (6440cf163, plugin-security only) was merged before the PR opened.
  • The first full run's worker cap was dropped by a stray --; the final run was capped.

The open question, ruled by this seat: option A, land now.

Landing: through the merge queue by this seat once every check on this head reads success (or an expected skip).


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 21:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit f802213 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24944-export-lookup-name-field branch October 20, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants