Repository navigation
rest: list export writes a lookup cell from a fixed key list on the expanded record and never reads the target's nameField — HotCRM contacts export as their job title #24944
Description
Filing gate ③ a task the maintainer set: objectstack-ai/hotcrm#2207, the maintainer's browser round on ObjectStack 18.0.0 (「hotcrm 升级到 objectstack 18.0 然后重新安排一轮浏览器测试」). Each defect gets its own card, by the maintainer's ruling 「按照按照具体的issue单独提交卡片,不受数量限制。」 (recorded on objectstack-ai/hotcrm#2188, 6283467378). This is finding F-01 of track 1 (the full functional pass), by the cloud tester session_019R3C7cvMNrmxf1AwtEt7N2. It was measured on HotCRM main 16c28664 with @objectstack/* 18.0.0 (framework 06cab4849, console objectui 616df6598b42), in Chromium at 1440×900 on a locally booted, seeded install with demo staff. The evidence is on the branch claude/qa-v18-full-pass at dda91814d (REPORT.md), which is never merged. Filed by the repo:hotcrm seat 2 (os-support-ai, session session_01KqkTQNLcxVc588eZpJmp1r). ⛔ Not a claim. Grade: triage's.
Reader (who acts): objectstack triage routes it. HotCRM's declaration is right: crm_contact declares nameField: 'full_name' (src/sales/objects/contact.object.ts:319).
The finding, as measured
- Persona / URL / steps: service.manager,
/_console/apps/app.objectstack.hotcrm/crm_case(All Cases) → Display → Export as CSV. The console sentGET /api/v1/data/crm_case/export?format=csv&fields=case_number,subject,crm_account,crm_contact,…→200 text/csv. - Expected: the Contact column names the contact, as the grid does ("John Smith").
- Actual:
CASE-00001,Login issues after platform upgrade,Acme Corporation,VP of Engineering,…. "VP of Engineering" is John Smith's jobtitle. CASE-00002 and CASE-00007 export "Chief Procurement Officer".- The same happens through the REST door as other personas.
crm_lead/export?fields=converted_contactgave "VP of Operations" for Wren Halvorsen (na.rep).crm_opportunity/export?fields=primary_contactgave "Director of Commercial Systems" (manager). - Lookups to
crm_accountandsys_userexport the right names.
- Evidence:
81-ADM-005-export-csv-content.png(the downloaded file's first lines),81-ADM-005-svcmgr-crm_case-export-csv.png. - Reach: every list export (Display → Export as CSV / XLSX) and
GET /api/v1/data/:object/export, for any lookup whose target has no literalnamecolumn.
Seat review (source at 18.0.0, 06cab4849)
packages/rest/src/export-format.ts:336-341displayFromRecorduses the lookup field's owndisplayField. Without one, it falls back to the first scalar key fromNAME_KEY_FALLBACKS(:107-109):['name', 'title', 'label', 'full_name', …].- The target object's
nameFieldis never read:packages/core/src/utils/import-field-meta.ts:94carries only the lookup's owndisplayField;- the expand loads the whole target record (
packages/rest/src/data-action-routes.ts:1227-1231).
crm_contacthas nonamecolumn, andtitle(Job Title) comes beforefull_name, so the job title wins.- Account and sys_user come out right only because each has a literal
namecolumn. Account's ownnameFieldisdisplay_title(src/sales/objects/account.object.ts:19), so it is ignored there too. - The engine already has the rule:
packages/objectql/src/record-title.ts:79-84titleFieldOf(schema)returnsnameField ?? displayNameField. The export does not use it. - Unchanged on
main. The file's last commit is from 2026-10-01.
Dedupe
Method: every objectstack-ai/objectstack issue title (every state, #1–#24940) and every objectstack-ai/objectui issue title (#7–#13272), listed through the repo-scoped issues?state=all endpoint (refreshed with since=2026-10-20T16:00Z) and matched case-insensitively. The search API is refused for this session.
- Related, closed: feat(rest): 列表导出支持 XLSX + 类型感知字段格式化(lookup/user/select/boolean/date) #2481 (the feature that added this formatter; its body promises
nameFieldresolution); bulk confirm step names a record by another text field when its nameField is not a grid column: HotCRM's contact list lists ticked contacts by job title ("VP of Engineering") objectui#13107 (the same "VP of Engineering" symptom in the console's bulk confirm, fixed client-side); fix(fields): a lookup option's label ranks displayField, then nameField, then titleFormat (ADR-0079) objectui#10479 (the client's lookup label ranksdisplayField, thennameField). - New.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 14 ·
domain:cliseat 2 · p1 · claimed under exception 2 (filer-labelled; the lane fits the table, and the body carries the filing gate's three answers: gate ③, objectstack-ai/hotcrm#2207) · 2026-10-20T19:30ZSession:
session_01LmjU2x1faLZ39DVTFyVca6Account:
os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24944-export-lookup-name-fieldWorktree:
objectstack-issue-24944Domain:
domain:cliSeat:
domain:cli#2(seat post #22648)File surface (read on
origin/main693352e4c; line numbers move; stop on a breach and explain it in the report):packages/rest/src/export-format.ts.displayFromRecord(about:336) uses the lookup field's owndisplayField, thenNAME_KEY_FALLBACKS(about:107), and never the target object'snameField. The cell names the target record by the target object's declared title field, the rulepackages/objectql/src/record-title.tstitleFieldOfalready states (nameField ?? displayNameField). The key list stays only as a fallback for an object that declares neither.packages/rest/src/data-action-routes.ts, about:1227) as far as it must hand the target object's title field to the formatter.@objectstack/restdoes not depend on@objectstack/objectqltoday. If the only way is a new package dependency, or an edit inpackages/core(import-field-meta.ts) orpackages/objectql, stop and report. Those are the engine lane's.packages/core/**,packages/objectql/**,packages/spec/**.@objectstack/restchangeset atpatch.Container & model:
S,mode:subagent,model: default tier(dispatch-gates.mjs --tier: "no path-derived mandate")Clause-②: no
Basis for the Clause-② line: a lookup cell's value in the export follows the target object's declared title field. No key, accept set or payload shape moves.
Responsibility: this lane's REST door (
GET /api/v1/data/:object/export). No platform path covers it. It reaches every list export of a lookup whose target object has no literalnamecolumn (HotCRM's contacts export as their job title).Thread-read: none
Serial constraints cleared:
export-format.ts,data-action-routes.tsorrecord-title.ts. All 14 open PRs were read in this act.@objectstack/hono'screateHonoApp, loopback beside a configured list, all-host patterns) — family closure, detail withheld under the disclosure ruling #24809 (PR fix(cors)!: one CORS admission rule at every door — createHonoApp's default, loopback beside a list, all-host patterns, auth's trusted origins #24873, the CORS doors), is disjoint. rest: list export writes every boolean cell as the literal 是 / 否, whatever the caller's language #24945 (same file) waits behind this card.objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{
"issue": 24944,
"status": "done",
"branch": "claude/issue-24944-export-lookup-name-field",
"pr": "#24980",
"session": "session_01LmjU2x1faLZ39DVTFyVca6 — subagent of the dispatching seat, so the parent's id; the relay route read it from the container (CLAUDE_CODE_REMOTE_SESSION_ID)",
"premise_still_valid": true,
"summary": "The export route now reads each reference field's TARGET schema once per export (the same getMetaItem / getObjectSchema read it already used, factored into readObjectSchema) and stamps the target's title field on the field meta (withReferenceTitleFields, ExportCellMeta in export-format.ts; core's ExportFieldMeta untouched). displayFromRecord reads the lookup's own displayField, then that titleField, then the key list only when the target resolves no title, then the id. The rule is @objectstack/spec/data resolveDisplayField (ADR-0079), because titleFieldOf in objectql is reachable only through a new runtime dependency. On every body the protocol serves, the two answer the same: the registry stamps nameField at registration with that same function (measured 4 shapes through getMetaItem). Zone 2: (1) confirmed, main writes 'VP of Engineering'; (2) the target schema is reachable through the existing protocol read, with no new dependency and no core or objectql edit; (3) refined: FLS deletes the unreadable title from the expanded record, and the cell falls to the record id, not to the key list (today's fallback would be the job title, the defect itself; Zone 1 binds); (4) confirmed: Account goes from name to display_title, stated in the PR body.",
"tests": "Red on main (base 693352e, new file only): vitest run --project local src/export-lookup-title-field.test.ts -> 'Tests 5 failed | 2 passed (7)', with "expected [ '1', 'VP of Engineering' ] to deeply equal [ '1', 'John Smith' ]"; both controls (the lookup's own displayField, a target served with no pointer) green. Green after the fix: 'Tests 10 passed (10)'. Ablation 1 (resolveDisplayField read replaced by undefined, via scripts/ablation-replace.mjs: anchor 1 -> 0, blob 6fd4e383 -> dbe37ef0): 'Tests 5 failed | 2 passed (7)', first pin red; restored blob == HEAD 6fd4e383032f, git diff HEAD empty. Ablation 2 (key-list else-branch opened): 'Tests 1 failed | 6 passed (7)' (the FLS pin); restored hash-equal. The subject is rest's own src reached by relative import, so no dist rebuild per leg. Full package at final head 321ea1f: vitest run --project local --maxWorkers=2 -> 'Test Files 306 passed (306)', 'Tests 6154 passed | 326 skipped (6480)'; pnpm --filter @objectstack/rest typecheck exit 0 ('check:test-typecheck: OK'). Integration layer: n/a (not packages/cli). test:repo (the vitest repo project) is not run locally: the diff touches none of its five files; it is CI's (Test Core).",
"gates": "At final head 321ea1f (6dabdfb + origin/main 6440cf1 merged, plugin-security only): dispatch-gates --commands --repo objectstack-ai/objectstack -> 66 commands, plus the 10 'Declared WIDE population' families; all 76 run with the exit code captured before any pipe, all 76 exit 0. --ran: '✓ 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 lint (eslint . --no-inline-config) exit 0, no findings. Earlier sweep at 6dabdfb: check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (workspace dist absent), re-measured green at the final head ('112 published require entry point(s) across 69 package(s) load'). CI check-runs on 321ea1f at report time: 'No other open PR may claim the same issue' success; '18.x upgrade harness (opt-in)' and 'Packed-tarball smoke (opt-in)' skipped; every other run in_progress: Lint · ESLint, Lint · docs & code guards, Lint · CI, release & engine gates, Lint · repo & tooling gates, Lint · PM dispatch-gates, Lint · scoped (tooling self-tests, slot lookup & corpus, query-options erasure), Type Check (workspace, debt ledger, consumer gates, source gates), Check Changeset, Check PR Size, Governed Surface Queue Guard, Check Documentation Links, Flag docs affected by code changes, filter, the two claim-path checks, and 'Part-of PR must not also close its card'. Test Core and Build Core had not appeared yet.",
"mcp_calls": "0 — no MCP tool used",
"api_writes": "2 relay strokes (3 REST requests), each sent as one POST /repos/objectstack-ai/objectstack/dispatches and executed as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24980/assignees [os-justin]; (2) comment = POST /repos//issues/24944/comments (this report). Plus git push of the branch (not REST). No label write: none named, and a changeset is present.",
"open_questions": [
{
"question": "A file this export writes no longer re-imports a lookup column whose target title is a formula or is not on the import's key list. Should PR #24980 land before the import resolver reads the same title field? Measured through POST /api/v1/data/xt_case/import: 'John Smith', 'Wren' and 'Acme (EMEA)' answer reference_not_found; main's cells 'VP of Engineering', 'Chief Procurement Officer' and 'Acme' resolved, by title and by name.",
"options": [
"A — Land #24980 now. The import mirror (core import-runner resolveRef never reads the target's title field, and a formula title cannot be matched by where) becomes its own engine/core-lane card. Business: fixes the p1 every export shows today; the round trip that 'worked' matched a contact by its job title, which fails as ambiguous as soon as two contacts share a title, and a hand-typed name already fails import on main. Long-term: one title rule (resolveDisplayField) on both doors, each fixed where its resolver lives. AI-safety: the cell is now the declared title, and the import failure is a loud reference_not_found, not a silent wrong link. Startup: no new surface. Effort: this PR as is, plus one card.",
"B — Hold #24980 until the import resolver resolves by the target's title. Business: keeps the job-title cells in every export for the import card's lead time (core, plus engine work for formula titles). Long-term: same end state, later. AI-safety: keeps the misleading cell. Startup: neutral. Effort: blocks on a cross-lane card.",
"C — Widen this card into packages/core import-runner. Business: one landing. Long-term: breaches the dispatch's hold-off; formula-title matching likely needs objectql, which is also held off. AI-safety: same as A once done. Startup: widens a p1 fix. Effort: a cross-lane card in one PR."
],
"recommendation": "A. On all four axes it is no worse than B, and better on business and AI-safety: the defect the maintainer measured is the export cell, and the regression is confined to a round trip that only matched by an unrelated column."
}
],
"out_of_scope_findings": [
"class: a · reach: POST /api/v1/data/:object/import with a lookup cell naming the record by its declared title answers reference_not_found (measured on this PR's fixture: 'John Smith' for nameField full_name (a formula), 'Wren' for displayNameField nickname (stored), 'Acme (EMEA)' for nameField display_title (a formula)) · evidence: packages/core/src/utils/import-runner.ts resolveRef tries id, the lookup's displayField, then name/title/label/full_name/email/username and never the target's nameField; full_name is on the list yet 'John Smith' still misses, so a formula title is not matchable by a where filter there. Same family as #24944 (the target's title not read), engine/core lane; the import-side mirror of this card. · dedupe words: import reference_not_found nameField; resolveRef title field; export re-import lookup round trip; import-runner displayField full_name formula",
"carrier: none (承接者:无) · noted in PR #24980 Acceptance notes, not filed — no export-to-import round-trip contract for lookup cells is declared anywhere measured; the one 're-imports' contract text (packages/rest/src/export-format.ts header, fourth contract) covers date cells only."
],
"line_budget": "changed lines 449 (+426 / -23) across 4 files vs merge base, under the 3000 human-merge threshold (dispatch-gates reading). No skills/** or governed path touched.",
"deviations": [
"Title rule via @objectstack/spec/data resolveDisplayField, not titleFieldOf (unreachable without a new runtime dependency). It differs from Zone 1's 'as titleFieldOf states it' only on a served body with no pointer and a title-eligible field, where it answers ADR-0079's derivation before the key list; none of the 4 measured registry shapes is in that state. Consequence stated in the PR body: an undeclared target follows the registry-designated title (e.g. subject replaces email).",
"Zone 2 assumption 3: on an FLS-hidden title, the cell falls to the record id, not to 'today's fallback' (the key list), because Zone 1 keeps the key list only for a target that declares neither.",
"The first full rest run was spelled 'pnpm --filter @objectstack/rest test -- --maxWorkers=2'. The bare -- dropped the worker cap, so the whole package ran at default workers (306/306 files green). The final-head run used 'exec vitest run --project local --maxWorkers=2'.",
"Merged origin/main 6440cf1 before opening the PR (AGENTS.md §10). It moved only plugin-security; plugin-security was rebuilt, and the rest suite, typecheck, the gate union and lint were re-run at 321ea1f.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. The commits carry the AGENTS.md model-free pair, and the PR body ends with the session-URL footer."
],
"files_changed": [
".changeset/24944-export-lookup-title-field.md",
"packages/rest/src/data-action-routes.ts",
"packages/rest/src/export-format.ts",
"packages/rest/src/export-lookup-title-field.test.ts"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24980 comment
6301221785·domain:cliseat 2 · sessionsession_01LmjU2x1faLZ39DVTFyVca6· 2026-10-20T20:52Z · head321ea1f2b. A list export's lookup cell names the record by the target object's title field (ADR-0079'sresolveDisplayField), and the field author's owndisplayFieldstill wins. An unreadable title falls to the id. The open question was ruled A (land now). The import side (resolveRefnever reads the title field) is filed as #24981 for the engine lane. Landing waits on every check of the head.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Landed: PR #24980 →
f802213c5(Fixes #24944)domain:cliseat 2 ·os-justin· sessionsession_01LmjU2x1faLZ39DVTFyVca6· 2026-10-20T21:46Zorigin/mainf802213c5is on it, with a single parent0603bfa1c(squash). 4 files, +426 / −23. Each file's blob equals the ACCEPTed head321ea1f2b'sgh-readonly-queue/main/*pr-24980*head remainscompletedby the PR'sFixes. In the stroke after this comment,pm:dispatchedcomes off6299507142→ report6301167497→ ACCEPT6301221785on the PR (no contract face; open question ruled A) → pointer on this card.resolveDisplayField. The lookup field's owndisplayFieldstill wins. An unreadable title falls to the id.@objectstack/restships it aspatch.domain:engine) makes the import resolver read the same title field. Until it lands, a lookup column whose target title is a formula, or not on the import's key list, does not re-import from this export.Generated by Claude Code