Skip to content

Commit 5636641

Browse files
huangyiireneclaude
andauthored
fix(plugin-audit): resolve a summary reference field's target through referenceTargetOf (#19264) (#19285)
Fixes #19264 Clause-②: no ## The defect `packages/plugins/plugin-audit/src/audit-writers.ts` admits `user` as a reference type (`REFERENCE_FIELD_TYPES`) and then required the **materialized `reference` carrier**, so a `trackHistory`'d `{ type: 'user' }` field authored without an explicit `reference` was planned for nobody and the timeline rendered the raw id. The contract says the opposite for exactly that type. `IMPLICIT_REFERENCE_TARGETS` (`packages/spec/src/data/field-value.zod.ts`), re-read verbatim on `origin/main` at `215840f43` before this branch was cut: > `user` is the only member: `field.zod` defines it as "a lookup specialized to the `sys_user` system object … target fixed to the `sys_user` system object", and the `Field.user()` builder — unlike `Field.lookup(reference, …)` / `Field.masterDetail(reference, …)` — takes NO target argument and writes `reference: 'sys_user'` itself. The target is a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is fully specified, not under-specified. So the one spelling the contract calls **complete** was the one the reader refused, and it refused it **silently**: no refusal, no diagnostic, the field simply absent from the read plan. `referenceTargetOf` lives in that same module to supply the constant, and is the arbiter `plugin-approvals` moved to in PR #19263 / `841a71e51`. ## FOUR sites, not two — the card's site count is falsified The card and the dispatch order both name **two** carrier-gated sites, the two read planners. Re-measured on this branch's own base rather than trusted, the carrier was read at **four**: | line on `215840f43` | function | role | |---|---|---| | `:472` | `planTrackedLookupReads` | builds the read plan, **keyed by target object** | | `:527` | `renderTrackedChangeSummary` | looks the resolved titles back up **under the same key** | | `:629` | `planMilestoneTokenReads` | builds the milestone read plan, keyed by target object | | `:693` | `renderMilestoneSummary` | looks the resolved titles back up under the same key | The target is the KEY of the `objectName -> (id -> title)` map, so it has two ends. Repairing only the two planners issues the read and then looks the result up under a key the plan never used — the timeline still shows the id, and now pays for a read it cannot use. That is measured below, not argued: the renderer-only ablation leaves every read-count assertion green and every rendered-string assertion red. Everything else the card states holds as given: the line ranges `:471-473` and `:628-630`, and the reading that `:434` in `displayFieldValue` does not gate on the carrier and is the consumer where the symptom surfaces rather than a defect site. ## The repair One module-private helper, `referenceTargetForSummary`, asks `referenceTargetOf`, and all four sites use it. - **Nothing widens.** `REFERENCE_FIELD_TYPES` is untouched, so the admitted types stay `lookup` / `master_detail` / `user`. `tree` is deliberately still out: it carries an author-chosen target, so admitting it would widen what the timeline tracks rather than repair what it silently dropped. A `lookup` / `master_detail` whose author-chosen target is absent still names nothing and still issues no read. - **A padded carrier can no longer split the key.** The planners used to `trim()` and the renderers did not, so `reference: ' crm_account '` produced two keys and no title. One helper trims once, for both ends. - **The unreadable-carrier behaviour is unchanged, and the throw is caught deliberately.** `referenceTargetOf` reads the carrier through `referenceCarrierOf`, which throws for an object- or array-valued `reference`. All four call sites run inside `writeAudit`'s summary composition, which is **not** inside the `try` that guards the audit row write — measured: between `writeAudit`'s first line and its end there is exactly one `try`, and it wraps `persistAuditTrailRow` alone. An escaping `TypeError` would therefore leave the hook, turning a display-enrichment miss into a failure on the audited write's own path. Such a carrier is left out of the plan exactly as `typeof field.reference === 'string'` already left it out: byte-identical behaviour for that class, not a new silence. - **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did. ## Tests — silence is the defect, so the read and the rendering are pinned separately New `packages/plugins/plugin-audit/src/implicit-reference-target-summary.test.ts`, 10 cases against the counting driver the sibling summary suites already use (reads counted, rows copied). Every implicit-target case asserts **both** halves: the `sys_user` read delta (the half a happy-value assertion cannot see — before this change that read was never issued at all) and the rendered summary string (the half a read count cannot see — a plan-only repair pays for the read and still prints the id). The target is asserted through `referenceTargetOf({ type: 'user' })`, never a hand-copied `'sys_user'`, so a second de-facto constant in this package would not satisfy it. Both planner sites are covered: the tracked-change branch through a tracked `user` field with no `reference`, and the milestone branch through a milestone token on an **untracked** `user` field with no `reference` (that branch is deliberately not gated on `trackHistory`). Four narrowness controls, so "the implicit target resolves" cannot be read as "everything resolves now": a tracked targetless `lookup` (as a diff row and as a milestone token) stays unresolved and costs nothing; a tracked `tree` with a perfectly readable target stays out of the type gate and renders its raw id; and a write that moves no reference field, on a row that HOLDS one, still pays zero reads. The second commit is that file's `find` double, not the subject: see Gates below. ### Reverse verification — two ablations, both through `scripts/ablation-replace.mjs` Re-run from the final committed state at `7ecf511d2`, after the test file's last edit. The subject is resolved from SOURCE, not from `dist`: the test imports `./audit-writers.js`, a relative specifier inside the package, so vitest transforms the mutated file directly and no build sits between the mutation and the run. **Leg 1 — revert BOTH planner sites** (anchor hit 2 times as declared, blob `9123759bc8e2` to `f6b9e89ac1a7`): ``` Test Files 1 failed (1) Tests 3 failed | 7 passed (10) ``` The three implicit-target cases go red on the READ: `expected +0 to be 1`, three times. The plan is empty again, exactly as before the repair. **Leg 2 — revert BOTH renderer sites, leaving both planners repaired** (anchor hit 2 times as declared, blob `9123759bc8e2` to `90516141da24`). This is the leg that measures the card's two-site reading: ``` Test Files 1 failed (1) Tests 4 failed | 6 passed (10) ``` Every read-count assertion stays **green** and every rendered string goes red — `expected 'Owner: ∅ → usr_1' to be 'Owner: ∅ → Grace Hopper'` and `expected 'Deal won by usr_1' to be 'Deal won by Grace Hopper'`. The reads are issued and thrown away. That is the reported symptom, unrepaired, at a higher read cost. Both legs restored and proven restored by the tool: blob back to `9123759bc8e2`, identical to HEAD, `git diff HEAD` empty. ## The census triage asked for > whoever takes either should sweep for a third: a contract that two consumers read the same wrong way is a contract whose *consumers* need a census, not two point fixes. Corpus: every `referenceCarrierOf` and `referenceTargetOf` call site in the repo's package sources, plus every raw `field.reference` read, at `7ecf511d2`. **Positive control on the same corpus**: `referenceTargetOf` is a real, importable symbol with call sites in `metadata-protocol/src/protocol.ts`, `objectql/src/engine.ts`, `objectql/src/integrity/dangling-reference-audit.ts`, `objectql/src/record-title.ts`, `plugin-approvals/src/approval-service.ts` and `service-analytics/src/dimension-labels.ts` — so a zero below is a reading. A methodology note that is itself a finding. The first pass of this sweep used the pathspec `packages/**/src/**/*.ts`, which under git's default (non-`:(glob)`) matching **does not reach** `packages/plugins/plugin-audit/src/audit-writers.ts` — the very file this card is about. It returned a clean five hits. What caught it was a control: grepping for a string the defect file is known to contain and finding the file absent from the result. Every count below was re-taken without that pathspec. **One third site, same silent shape — reported, deliberately not fixed** (outside this card's file surface): - `packages/metadata-protocol/src/seed-loader.ts:712`, `SeedLoader.buildDependencyGraph`. The type gate is `lookup || master_detail || user` — `user` admitted — and the next line reads the carrier and `continue`s when it is absent. A seed row whose `{ type: 'user' }` field carries no `reference` therefore contributes **no `dependsOn` edge** and is **never pushed onto `references`**, so its natural key is written verbatim instead of being resolved to a record id. That consequence is named in this same function's own docblock, for the case it does handle: "every lookup / master_detail seed value was written verbatim … Dangling references break parent joins." Silent, user-visible, same misread contract. Dedupe words: `seed-loader user implicit reference target` · `buildDependencyGraph referenceCarrierOf user` · `seed natural key written verbatim user field` · `metadata-protocol implicit target census`. **One near-site of a different shape — LOUD, reported for judgment:** - `packages/rest/src/rest-server.ts:10914`, the public-form lookup picker. It has **no type gate at all** (any field the form opts in with `publicPicker`), reads the carrier, and answers `500 LOOKUP_TARGET_MISSING` when it is absent — so a `publicPicker` on a `{ type: 'user' }` field authored without `reference` is refused rather than silently dropped. Same misreading, opposite failure mode. `PUBLIC_FORM_SERVER_MANAGED_FIELDS` already refuses server-managed anchors on this surface, so the reachable case is a hand-authored user field. Dedupe words: `public form lookup picker user implicit target` · `LOOKUP_TARGET_MISSING user field` · `rest picker referenceCarrierOf implicit`. **The card's two unverified analytics readings — both CONFIRMED:** - `service-analytics/src/dimension-labels.ts:142` already delegates to `referenceTargetOf`. Correct today; no action. - `service-analytics/src/plugin.ts:748` (`relationshipResolver`) gates on `lookup || master_detail`, so `user` never enters the gate at all — materialized carrier or not — and falls through to `return engine ? undefined : relationshipName`. With an engine present that `undefined` makes the dataset compiler reject the relationship. **LOUD**, and a different shape: a type-gate exclusion, not an implicit-target misread. No action. **Cleared, each with its reason** — every other carrier consumer in the corpus: - Type gate excludes `user`, so the implicit target cannot arise: `cli/src/commands/doctor.ts:726` and `:930` (`lookup` only), `objectql/src/engine.ts:13113` and `:13568` (cascade; `master_detail` / `lookup`), `lint/src/validate-expressions.ts:395` and `lint/src/validate-sharing-rule-enforceability.ts:267` (`master_detail` only), `verify/src/derive.ts:148` (its `RELATIONAL` set is the `lookup` / `master_detail` spellings plus `tree`), and `spec/src/kernel/functional-completeness.ts:177` — whose `RELATIONSHIP_TYPES` is `lookup` / `master_detail`, so `field/relationship-without-reference` does **not** fire on a spec-complete `user` field. - Reads the carrier as the AUTHOR'S DECLARED KEY, which is their actual subject rather than "what does this field point at": the five generic lint readers (`data-model-rules.ts:250`, `object-graph.ts:242`, `validate-field-consumers.ts:560`, `validate-object-references.ts:306`, `validate-security-posture.ts:292`). ## Gates Every exit code below was captured **before** any pipe. The derived set was re-derived at the final head `7ecf511d2` and is byte-identical to the one derived at `dce04112d`. - `pnpm --filter @objectstack/plugin-audit test` — exit 0, **25 files / 363 tests passed**, at the final head. - `pnpm --filter @objectstack/plugin-audit typecheck` — exit 0. The leg count was read from the package, not assumed: its `typecheck` script is `tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json && pnpm check:test-typecheck`, **three legs**, all run. The test-layer ledger is unmoved — `0 file(s) / 0 error(s) / 0 pinned signature(s)` — and this package carries no `test-typecheck-debt.json` of its own, so nothing was touched there. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived **61 families** from a change set of **3 paths** (three-dot semantics, merge base `215840f43`), **542 changed lines**, under the 5,000 human-merge threshold. All 61 were run. - **One family went genuinely RED and was repaired, not baselined.** `pnpm check:objectql-double-limit` exit 1: the new test file's counting `find` double was UNJUDGED, because the gate lifts the double and drives it with a row stub in place of every non-function declaration, and `(reads.findOn[object] ?? 0) + 1` throws on that stub before the body can be judged. The double now reads its counter by TYPE and applies the caller's `limit` by PRESENCE, after the filter and before the rows are copied. Re-run: exit 0, and the census moved the right way — **393 graded (was 392), 195 applying the bound (was 194), 39 proving the bound precedes a row-touching stage (was 38), 55 unjudged (was 56)**, with `baseline key set verified against 215840f: no files added`. - **Three families first answered exit 3 = PREREQUISITE NOT MET = NOT MEASURED**: `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`. Each named the same prerequisite ("this gate reads built output, and some package has no dist/ … Run `pnpm build` first. ⛔ This is NOT a pass: nothing was measured"). The prerequisite was cleared with a whole-tree `pnpm build` (exit 0, 73/73 tasks) and each re-ran to **exit 0**. None is reported as a zero. - The remaining **57** families ran to exit 0 on the first pass. After the second commit, every family whose subject is the changed test file was re-run at the final head — `check:objectql-double-limit`, `check:type-check-debt`, `check:type-check-coverage`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:nul-bytes`, `check:tier-file-adoption`, `check:engine-double-contract`, `check:driver-memory-census`, `check:where-matcher`, `check:published-files`, `check-empty-changeset`, `check-changeset-no-major`, `check-adr-0087-registration`, `check-closing-keyword-parity` — all **exit 0**. - `pnpm lint` (`eslint . --no-inline-config`, the repo's only style authority) — **exit 0 over the whole tree**, so no narrowing is being claimed and no narrowing evidence is owed. - `grep os-regen .gitattributes`, read on the spot: every merge-driver-managed path is under `packages/spec/`, `docs/`, `content/docs/`, `skills/` or `scripts/platform-object-tenancy-census.json`. **No path in this diff is driver-managed.** - A control-byte self-scan beyond `check:nul-bytes`, over all three changed files: `grep -naP` for the C0 range plus DEL — no hits. - Changeset: decided by reading, not habit. `packages/plugins/plugin-audit/package.json` carries no `private` key and ships `files: ["dist", "README.md", "CHANGELOG.md"]`, so a fix here publishes. A `patch` changeset is included. ### Verdict lines transcribed verbatim, because those logs die with the worktree From `pnpm check:engine-double-contract` (exit 0): ``` UNRECOGNISED [engine-double-contract]: 9 construct(s) in packages, examples declare a scanned verb (delete, update, findOne) alongside engine siblings, and this gate could not read the implementation -- so they are in NEITHER the pinned population nor the ledger. 213 further construct(s) are SCOPED OUT by a stated criterion and are not counted here. This is a verdict, not a finding: it never fails a run (#9747, ruling of 2026-08-18). ``` From `node scripts/check-changeset-no-major.mjs --base origin/main` (exit 0): ``` ℹ️ LEVEL AXIS: NOT APPLICABLE — this run has no `pull_request` to read a declaration from, so the clause-② axis has no input by construction rather than by omission. It is a PR-scoped reading: the RC cut (`cut-rc.yml`) and a local run reach here, and neither is a PR that could have declared. · no `pull_request` payload was available to read a declaration from ``` That second one is a local-run artefact by construction: on this PR the axis has its input, and the declaration it will read is the `Clause-②: no` line at the top of this body. ## Not this PR `packages/spec` is read-only on this lane: `referenceTargetOf` is imported and nothing in that package is edited. No release-notes page is touched. `plugin-approvals` was repaired in PR #19263 and is out of scope here. The two census findings above are reported for the seat to file and deliberately **not** fixed here — that is the difference between two bugs and one systemic gap. ## Acceptance notes - `noted, not filed` — the sibling summary suites (`audit-lookup-summary.test.ts`, `audit-milestone-summary.test.ts`, `audit-option-label-summary.test.ts`, `audit-bound-previous.test.ts`) each hold the same counting driver in its pre-repair, UNJUDGED shape and are grandfathered in `scripts/objectql-double-limit.baseline.json`. The one-line change made here would shrink that ledger by four entries. Not done in this PR: it is unrelated to the card and would put four files this card has no business in on the diff. Carrier: any later PR editing those suites. - `noted, not filed` — `renderTrackedChangeSummary` and `renderMilestoneSummary` both build an `optionLabelFor` closure per field per write. Ordinary allocation on a hot path; no defect, no repro, no contract text. Carrier: none — no open PR or queued card touches those two functions. - `noted, not filed` — `referenceTargetOf` answers a whitespace-only carrier (`reference: ' '`) with that whitespace rather than falling through to the type's constant, because `referenceCarrierOf` treats only the empty string as absence. The helper added here trims afterwards, so the audit path's behaviour is unchanged in both directions; making the arbiter itself trim is a `packages/spec` question and this lane is read-only there. Carrier: none observed. --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4efb988 commit 5636641

3 files changed

Lines changed: 537 additions & 5 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
The activity-timeline summary resolves a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `trackHistory`'d `{ type: 'user' }` field authored without one is planned, read and rendered as a name instead of silently showing the raw id (#19264).
6+
7+
`audit-writers.ts` admitted `user` as a reference type and then required an EXPLICIT `reference` on it. The spec declares exactly the opposite for that type: `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." So the one spelling the contract calls complete was the one the reader refused — and it refused it **silently**: the field was simply absent from the read plan, and the timeline rendered `usr_1` where every other reference field showed a name.
8+
9+
- **Four sites, not two.** The target is the key of the `id → title` map, so it has two ends: the two read planners (`planTrackedLookupReads`, `planMilestoneTokenReads`) build the plan under it and the two renderers (`renderTrackedChangeSummary`, `renderMilestoneSummary`) look the resolved titles back up under it. All four now ask one helper, so repairing the plan alone cannot pay for a read whose result the renderer then fails to find.
10+
- **Nothing else widens.** The admitted types are unchanged (`lookup`, `master_detail`, `user`), so a `lookup` / `master_detail` whose author-chosen target is absent still names nothing, is still left out, and still issues no read — `tree` is deliberately not added.
11+
- **A padded carrier can no longer split the key.** The planners used to `trim()` and the renderers did not, so `reference: ' crm_account '` produced two keys and no title; one helper trims once for both ends.
12+
- **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf`, which throws for an object- or array-valued `reference`; that throw is caught at the helper because this code runs inside `writeAudit`'s summary composition, which is not inside the `try` that guards the audit row write — an escaping `TypeError` would turn a display-enrichment miss into a failure on the audited write's own path. Such a carrier is left out exactly as it was before.
13+
- **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant.

‎packages/plugins/plugin-audit/src/audit-writers.ts‎

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { SECRET_MASK, collectMaskedReadFields } from '@objectstack/objectql/core
1919
// heuristic here would be a second de-facto contract that disagrees with the
2020
// picker, the search companion and the approval inbox the day an author sets
2121
// `nameField` — the same argument the SECRET_MASK import above makes.
22-
import { resolveDisplayField } from '@objectstack/spec/data';
22+
import { referenceTargetOf, resolveDisplayField } from '@objectstack/spec/data';
2323
// [#8707 / #10101] The platform-row organization resolver, imported rather
2424
// than owned. It started life in THIS file (#8707, honouring #8287's ruling)
2525
// and was promoted to `@objectstack/metadata-core` by the maintainer ruling
@@ -363,6 +363,76 @@ function safeStringify(v: any): string {
363363
*/
364364
const REFERENCE_FIELD_TYPES: ReadonlySet<string> = new Set(['lookup', 'master_detail', 'user']);
365365

366+
/**
367+
* [#19264] The TARGET OBJECT of a reference-typed field — the key both halves
368+
* of a summary's title resolution are keyed on.
369+
*
370+
* Read through `referenceTargetOf`, the spec's SINGLE arbiter of "what does
371+
* this field point at", and NOT through the materialized `reference` carrier
372+
* this file used to read raw. For `user` the two differ, and
373+
* `IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) is explicit about
374+
* which one answers: the target of a `user` field is "a CONSTANT OF THE TYPE,
375+
* so `reference` on a `user` field materializes that constant; it does not
376+
* supply it. Metadata authored without it (hand-written JSON, an AI author, a
377+
* Studio form) is fully specified, not under-specified."
378+
*
379+
* Gating on the carrier therefore dropped the spelling the contract calls
380+
* COMPLETE, and dropped it SILENTLY: a `trackHistory`'d `{ type: 'user' }`
381+
* field with no `reference` was planned for nobody, so the timeline rendered
382+
* the raw 32-char id where every other reference field showed a name — no
383+
* refusal, no diagnostic. `plugin-approvals` had the identical defect in
384+
* `resolveLookupFields` and was moved to this same arbiter; the docblock on
385+
* {@link REFERENCE_FIELD_TYPES} declares the two packages share one vocabulary
386+
* for "this value is a reference", so they resolve the target one way too.
387+
*
388+
* ## Both halves, because the key has two ends
389+
*
390+
* The plan (`planTrackedLookupReads` / `planMilestoneTokenReads`) keys the read
391+
* by target object and the renderers (`renderTrackedChangeSummary` /
392+
* `renderMilestoneSummary`) look the resolved titles back up by the same key.
393+
* Repairing only the plan would issue the read and then fail to find it again,
394+
* so the timeline would still show the id — the symptom this fixes — while
395+
* paying for the read. One function answers for all four, which is also why a
396+
* padded carrier can no longer make the two ends disagree: the plan used to
397+
* `trim()` and the renderers did not, so `reference: ' crm_account '` produced
398+
* a key neither side could match.
399+
*
400+
* ## The type gate is unchanged, deliberately
401+
*
402+
* `tree` is the fourth member of `REFERENCE_VALUE_TYPES` and stays OUT: it
403+
* carries an author-chosen target, so admitting it would WIDEN what the
404+
* timeline tracks rather than repair what it silently dropped.
405+
* {@link REFERENCE_FIELD_TYPES} remains the one gate, applied by the callers
406+
* before they ask this.
407+
*
408+
* ## Why the throw is swallowed here rather than propagated
409+
*
410+
* `referenceTargetOf` reads the carrier through `referenceCarrierOf`, which
411+
* THROWS for an object- or array-valued `reference` — a shape `safeParse`
412+
* refuses at the contract door and only an unparsed literal can reach. Every
413+
* caller below runs inside `writeAudit`'s summary composition, which is NOT
414+
* inside the `try` that guards the audit row write: a `TypeError` escaping
415+
* here would leave the hook, i.e. turn a display-enrichment miss into a
416+
* failure on the audited write's own path. So an unreadable carrier keeps the
417+
* answer it has always had on this path — absent, the field left out of the
418+
* plan — and the audited write is untouched. Absence is not a new silence: it
419+
* is byte-identical to what `typeof field.reference === 'string'` already
420+
* answered for that shape, and this repair is about the target a `user` field
421+
* legitimately declines to restate, not about unreadable metadata.
422+
*
423+
* Returns `''` — never `undefined` — so the callers' existing falsy gate reads
424+
* exactly as it did.
425+
*/
426+
function referenceTargetForSummary(field: any): string {
427+
let target: string | undefined;
428+
try {
429+
target = referenceTargetOf(field);
430+
} catch {
431+
return '';
432+
}
433+
return typeof target === 'string' ? target.trim() : '';
434+
}
435+
366436
/**
367437
* [#7230] The referenced record ids carried by one lookup-field value.
368438
*
@@ -469,7 +539,7 @@ function planTrackedLookupReads(
469539
const field = fields[key];
470540
if (!field || field.trackHistory !== true) continue;
471541
if (typeof field.type !== 'string' || !REFERENCE_FIELD_TYPES.has(field.type)) continue;
472-
const reference = typeof field.reference === 'string' ? field.reference.trim() : '';
542+
const reference = referenceTargetForSummary(field);
473543
if (!reference) continue;
474544
for (const raw of [oldVals ? oldVals[key] : undefined, newVals[key]]) {
475545
for (const id of referenceIdsOf(raw)) {
@@ -524,7 +594,7 @@ function renderTrackedChangeSummary(
524594
const label =
525595
translate(`objects.${objectName}.fields.${key}.label`) ??
526596
(typeof field.label === 'string' && field.label.length > 0 ? field.label : key);
527-
const reference = typeof field.reference === 'string' ? field.reference : undefined;
597+
const reference = referenceTargetForSummary(field);
528598
const titlesFor = reference ? lookupTitles?.get(reference) : undefined;
529599
// Only a field that DECLARES options can consume this, so the ordinary
530600
// tracked scalar allocates no closure; both calls below share the one.
@@ -626,7 +696,7 @@ function planMilestoneTokenReads(
626696
const key = match[1];
627697
const field = fields[key];
628698
if (!field || typeof field.type !== 'string' || !REFERENCE_FIELD_TYPES.has(field.type)) continue;
629-
const reference = typeof field.reference === 'string' ? field.reference.trim() : '';
699+
const reference = referenceTargetForSummary(field);
630700
if (!reference) continue;
631701
for (const id of referenceIdsOf(after[key])) {
632702
let set = byObject.get(reference);
@@ -690,7 +760,7 @@ function renderMilestoneSummary(
690760
if (v === null || v === undefined || v === '') return '';
691761
const field = fields ? fields[key] : undefined;
692762
if (!field) return String(v);
693-
const reference = typeof field.reference === 'string' ? field.reference : undefined;
763+
const reference = referenceTargetForSummary(field);
694764
const titlesFor = reference ? lookupTitles?.get(reference) : undefined;
695765
return displayFieldValue(field, v, titlesFor);
696766
});

0 commit comments

Comments
 (0)