Skip to content

Commit b0131a8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19249-sys-user-set-manager-action
2 parents b0fd5ac + 801415a commit b0131a8

4 files changed

Lines changed: 630 additions & 18 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)