Skip to content

Commit 841a71e

Browse files
huangyiireneclaude
andauthored
fix(plugin-approvals): resolve a reference field's target through referenceTargetOf (#19263)
Fixes #19198 Clause-②: no ## The defect `ApprovalService.resolveLookupFields` admitted `type: 'user'` fields but resolved their target from the **materialized `reference` carrier**. The spec declares the opposite for exactly that type — `IMPLICIT_REFERENCE_TARGETS` in `packages/spec/src/data/field-value.zod.ts`, re-read verbatim on `origin/main` at `7d0f911da` 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 `payload_display`, and a business reviewer reading a raw user id where every other reference field showed a name. `referenceTargetOf` lives in that same module precisely to supply the constant (Framework#4443 / cloud#983 fixed the identical blindness at the `$expand` gate and in the expansion engine). ## The repair `resolveLookupFields` now asks `referenceTargetOf` — the spec's single arbiter of what a reference field points at — instead of reading the carrier. - **Nothing else widens.** The admitted types are unchanged (`lookup`, `master_detail`, `user`). 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 (see acceptance notes). - **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf`, so the `TypeError` is still raised for an object- or array-valued `reference` and is still caught **per field**, and the existing pins in `src/lookup-field-reference-carrier.test.ts` pass untouched. The warning line now names this reader in its own prefix, because the message the arbiter throws names itself. - **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did. Making authors restate the constant was rejected on the card as the 消费端宽容 direction the charter rejects. ## Tests — silence is the defect, so both halves are pinned New `src/implicit-reference-target-lookup.test.ts` (5 cases): 1. a `{ type: 'user' }` field with no `reference` resolves to `sys_user`, **and** that value is asserted to equal `referenceTargetOf({ type: 'user' })` — so a hand-copied `sys_user` literal in this package would not satisfy it; 2. implicit and materialized spellings answer **identically**; 3. an author-chosen target that names nothing is still left out, with no warning; 4. **the consumer-level pin**: on an object whose ONLY reference field is the implicit-target one, the enrichment now issues exactly one `sys_user` read for the payload's id (`toHaveLength(1)`, ids `['u7']`) and produces `payload_display = { assignee: 'Grace Hopper' }`. The read is the half a happy-value assertion cannot see — before the repair that read was never issued at all; 5. the opposite direction: a genuinely targetless `lookup` produces **no** read against any referenced object and leaves `payload_display` unset, so "the implicit target is enriched" cannot be read as "everything is enriched now". **Reverse verification (ablation), run from the committed state through `scripts/ablation-replace.mjs`** — anchor hit 1 time, blob `703a308132` → `b1fa2e3f0300` on disk, mutation replacing the arbiter call with the pre-repair carrier read: ``` Test Files 1 failed | 1 passed (2) Tests 3 failed | 6 passed (9) ``` Cases 1, 2 and 4 go red (`expected undefined to deeply equal { key: 'owner', reference: 'sys_user' }`; `expected [] to have a length of 1 but got +0`); cases 3 and 5 stay green because they are the controls, and the whole sibling carrier suite stays green — the mutation is narrow. Restored and proven restored: blob back to `703a308132`, identical to HEAD, `git diff HEAD` empty. ## Gates Everything below was captured with the exit code read **before** any pipe, at `aef74f0fa`. - `pnpm --filter @objectstack/plugin-approvals test` — exit 0, **49 files / 780 tests passed**. - `pnpm --filter @objectstack/plugin-approvals typecheck` — exit 0, all three legs this package's script names (`tsc --noEmit`, `tsc --noEmit -p tsconfig.scripts.json`, `check:test-typecheck`). The test-layer ledger is unmoved: `8 file(s) / 324 error(s) / 27 pinned signature(s)` — the new test file adds none and takes no entry. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived **61 families**; all 61 were run and reconciled with `--ran`: *"61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3)."* - Six of those first answered **exit 3 = PREREQUISITE NOT MET = NOT MEASURED** (`check:dts-closure`, `check:dual-build-cjs-loads`, `check:i18n`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, `check:type-check-debt`). Each prerequisite was cleared — the dependency closure, then `turbo run build` over all packages — and each re-run to **exit 0**. None is reported as a zero. - `pnpm lint` (`eslint . --no-inline-config`, the repo's only style authority) — exit 0 over the whole tree, so no narrowing is being claimed. - `grep os-regen .gitattributes` read on the spot: no path in this diff is merge-driver managed. ## Acceptance notes Noted, not filed — each states who would meet it: - **`tree` is a reference type this enrichment still does not admit.** `REFERENCE_VALUE_TYPES` has four members; `resolveLookupFields` carries three, and `plugin-audit` deliberately carries the same three (`audit-writers.ts`: *"`tree` is NOT on the list: it carries no `reference`"*). Admitting it would widen what the inbox resolves rather than repair what it silently dropped, which is not this card. Successor: whoever next touches the shared three-type vocabulary in either package. - **The warning text moved.** The reader's name is now in the approvals prefix rather than inherited from the arbiter's message, because `referenceTargetOf` takes no reader label. The existing assertion on it still passes unchanged. Reported to the dispatching seat rather than filed here (dev agents report findings with dedupe words; they do not file): one class-(b) sibling of this defect in `plugin-audit`, and a reading that partly falsifies this card's own unverified note about the analytics resolver. ## Not this PR `packages/spec` is read-only on this lane — `referenceTargetOf` and `IMPLICIT_REFERENCE_TARGETS` are imported and nothing in them is edited. No release-notes page is touched. --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb33767 commit 841a71e

3 files changed

Lines changed: 238 additions & 24 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
`ApprovalService` inbox display enrichment resolves a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `{ type: 'user' }` field authored without one is enriched instead of silently dropped (#19198).
6+
7+
`resolveLookupFields` admitted `user` fields but required an EXPLICIT `reference` on them. 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 left out of `payload_display`, with no refusal and no diagnostic, and the reviewer read a raw user id where every other reference field showed a name.
8+
9+
- **The target is now the arbiter's answer, not a carrier read.** `referenceTargetOf` is the same single arbiter the `$expand` gate and the expansion engine already ask (Framework#4443 / cloud#983 fixed the identical defect there); approvals was still reading `field.reference` raw.
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+
- **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf`, the throw is still caught per field so one bad carrier cannot drop every reference field of the object, and the warning now names this reader (`ApprovalService.resolveLookupFields`) because the arbiter's own message names itself.
12+
- **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-approvals/src/approval-service.ts‎

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import type {
5656
// fields the caller had already supplied.
5757
import type { ExecutionContext } from '@objectstack/spec/kernel';
5858
import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts';
59-
import { isFileIdToken, referenceCarrierOf } from '@objectstack/spec/data';
59+
import { isFileIdToken, referenceTargetOf } from '@objectstack/spec/data';
6060
// [#11993] The SANCTIONED renderer for OPERATION-level refusal copy. The
6161
// Operation Message Catalog is the ONE seat for these sentences — its own
6262
// header bars both a package-local string table and a second rendering
@@ -5762,40 +5762,64 @@ export class ApprovalService implements IApprovalService {
57625762
return names;
57635763
}
57645764

5765-
/** Lookup-typed fields (key + referenced object) of an object's schema. */
5765+
/**
5766+
* Reference-typed fields (key + TARGET OBJECT) of an object's schema.
5767+
*
5768+
* The target is read through `referenceTargetOf` — the spec's single arbiter
5769+
* of "what does this field point at" — and NOT through the materialized
5770+
* `reference` carrier. For `user` the two differ, and the contract is
5771+
* explicit about which one answers: `IMPLICIT_REFERENCE_TARGETS`
5772+
* (`@objectstack/spec/data`) declares the target of a `user` field "a
5773+
* CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that
5774+
* constant; it does not supply it. Metadata authored without it
5775+
* (hand-written JSON, an AI author, a Studio form) is fully specified, not
5776+
* under-specified." Gating on the carrier therefore dropped the spelling the
5777+
* contract calls COMPLETE: a `{ type: 'user' }` field with no `reference`
5778+
* was left out of inbox display enrichment with no refusal and no
5779+
* diagnostic, so the reviewer read a raw user id where every other reference
5780+
* field showed a name (Framework#4443 / cloud#983 is the same defect at the
5781+
* expand gate, fixed there by the same arbiter).
5782+
*
5783+
* The type gate stays the three types this enrichment has always carried.
5784+
* `tree` is a reference type too but is deliberately not added here: it takes
5785+
* an author-chosen target, so admitting it would widen what the inbox
5786+
* resolves rather than repair what it silently dropped.
5787+
*
5788+
* `referenceTargetOf` reads the carrier through `referenceCarrierOf`, so the
5789+
* unreadable-carrier behaviour below is unchanged. That carrier is read
5790+
* through the ONE arbiter instead of a truthiness gate: `String()` on an
5791+
* object-valued `reference` produced the literal target name
5792+
* `'[object Object]'`, and the sole consumer below hands the target straight
5793+
* to `engine.find(<object name>)` — so an unreadable carrier became a query
5794+
* for an object that can never exist, swallowed by that consumer's own
5795+
* `catch`. Absence is the contract's answer (`FieldSchema.reference` is an
5796+
* optional STRING) and is what this yields.
5797+
*
5798+
* The throw is caught PER FIELD, which is the deliberate difference between
5799+
* this reader and the cascade seams in `@objectstack/objectql` that let the
5800+
* arbiter propagate: those assert something positive about the schema on a
5801+
* write path, while this is a best-effort display enrichment whose outer
5802+
* `catch` returns `[]` — letting the throw reach it would drop EVERY
5803+
* reference field of the object over one unreadable carrier. The entry is
5804+
* dropped rather than pushed with the target absent because the consumer
5805+
* uses it as the object name argument and has nothing to do with an entry
5806+
* that carries none. The warning names THIS reader, because the message the
5807+
* arbiter throws names itself.
5808+
*/
57665809
private resolveLookupFields(object: string): Array<{ key: string; reference: string }> {
57675810
try {
57685811
const schema: any = (this.engine as any).getSchema?.(object);
57695812
const fields = schema?.fields ?? {};
57705813
const out: Array<{ key: string; reference: string }> = [];
57715814
for (const [key, f] of Object.entries<any>(fields)) {
57725815
if (f?.type !== 'lookup' && f?.type !== 'master_detail' && f?.type !== 'user') continue;
5773-
// The carrier is read through the ONE arbiter instead of a truthiness
5774-
// gate. `String()` on an object-valued `reference` produced the literal
5775-
// target name `'[object Object]'`, and the sole consumer below hands
5776-
// `reference` straight to `engine.find(<object name>)` — so an
5777-
// unreadable carrier became a query for an object that can never exist,
5778-
// swallowed by that consumer's own `catch`. Absence is the contract's
5779-
// answer (`FieldSchema.reference` is an optional STRING) and is what
5780-
// this now yields.
5781-
//
5782-
// The throw is caught PER FIELD, which is the deliberate difference
5783-
// between this reader and the cascade seams in `@objectstack/objectql`
5784-
// that let `referenceCarrierOf` propagate: those assert something
5785-
// positive about the schema on a write path, while this is a
5786-
// best-effort display enrichment whose outer `catch` returns `[]` —
5787-
// letting the throw reach it would drop EVERY lookup field of the
5788-
// object over one unreadable carrier. The entry is dropped rather than
5789-
// pushed with `reference` absent because the consumer uses `reference`
5790-
// as the object name argument and has nothing to do with an entry that
5791-
// carries none.
57925816
let reference: string | undefined;
57935817
try {
5794-
reference = referenceCarrierOf(f, 'ApprovalService.resolveLookupFields');
5818+
reference = referenceTargetOf(f);
57955819
} catch (err: any) {
57965820
this.logger?.warn?.(
5797-
`[approvals] lookup field "${object}.${key}" left out of inbox display enrichment: `
5798-
+ `${err?.message ?? err}`,
5821+
`[approvals] ApprovalService.resolveLookupFields: reference field "${object}.${key}" `
5822+
+ `left out of inbox display enrichment: ${err?.message ?? err}`,
57995823
);
58005824
continue;
58015825
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// `resolveLookupFields` used to read the MATERIALIZED `reference` carrier, so a
4+
// `{ type: 'user' }` field authored without one was left out of inbox display
5+
// enrichment — silently. No refusal, no diagnostic: the reviewer just saw a raw
6+
// user id where every other reference field showed a name.
7+
//
8+
// The contract says that metadata is COMPLETE. `IMPLICIT_REFERENCE_TARGETS`
9+
// (`@objectstack/spec/data`) declares a `user` field's target "a CONSTANT OF THE
10+
// TYPE, so `reference` on a `user` field materializes that constant; it does not
11+
// supply it. Metadata authored without it (hand-written JSON, an AI author, a
12+
// Studio form) is fully specified, not under-specified." `referenceTargetOf`
13+
// lives in that same module precisely to supply it, and is now what this reader
14+
// asks.
15+
//
16+
// The SILENCE is the defect, so the consumer-level cases assert what the
17+
// enrichment now DOES — the read it issues and the display value it produces —
18+
// rather than a happy value alone, which could not tell "enriched" apart from
19+
// "was never asked". The controls keep this a repair rather than a widening: a
20+
// `lookup` / `master_detail` whose author-chosen target is absent names nothing,
21+
// nothing can supply it, and it still stays out with no read and no warning.
22+
23+
import { describe, it, expect, vi } from 'vitest';
24+
import { referenceTargetOf } from '@objectstack/spec/data';
25+
import { ApprovalService } from './approval-service.js';
26+
27+
type FieldDefs = Record<string, unknown>;
28+
type Row = Record<string, unknown>;
29+
30+
/** What the enrichment read, in the order it read it. */
31+
interface FindCall { object: string; ids: string[] }
32+
33+
/** The two engine members the enrichment path reads. */
34+
function makeEngine(
35+
schemas: Record<string, { label?: string; fields: FieldDefs }>,
36+
tables: Record<string, Row[]> = {},
37+
) {
38+
const finds: FindCall[] = [];
39+
const engine = {
40+
getSchema: (object: string) => schemas[object],
41+
async find(object: string, options?: { where?: { id?: { $in?: string[] } } }) {
42+
const wanted = options?.where?.id?.$in;
43+
finds.push({ object, ids: wanted ?? [] });
44+
const rows = tables[object] ?? [];
45+
return wanted ? rows.filter(r => wanted.includes(String(r.id))) : rows;
46+
},
47+
};
48+
return { engine, finds };
49+
}
50+
51+
/** An inbox row as `enrichRows` reads and rewrites it. */
52+
interface InboxRow {
53+
object_name: string;
54+
record_id: string;
55+
payload: Record<string, unknown>;
56+
payload_display?: Record<string, string>;
57+
record_title?: string;
58+
}
59+
60+
function makeService(
61+
schemas: Record<string, { label?: string; fields: FieldDefs }>,
62+
tables: Record<string, Row[]> = {},
63+
) {
64+
const warn = vi.fn();
65+
const { engine, finds } = makeEngine(schemas, tables);
66+
const service = new ApprovalService({
67+
engine: engine as any,
68+
logger: { info() {}, warn, error() {}, debug() {} },
69+
});
70+
// Both members are private and neither has a public seam that does not drag
71+
// the whole request lifecycle in: `resolveLookupFields`' sole consumer is
72+
// `enrichRows`, whose every failure is swallowed by design.
73+
const resolve = (object: string) =>
74+
(service as unknown as { resolveLookupFields(o: string): Array<{ key: string; reference: string }> })
75+
.resolveLookupFields(object);
76+
const enrich = (rows: InboxRow[]) =>
77+
(service as unknown as { enrichRows(r: InboxRow[]): Promise<void> }).enrichRows(rows);
78+
return { resolve, enrich, warn, finds };
79+
}
80+
81+
const DEAL = {
82+
deal: {
83+
label: 'Deal',
84+
fields: {
85+
name: {},
86+
// The spelling the contract calls fully specified.
87+
owner: { type: 'user' },
88+
// The same field with the constant materialized — the control that says
89+
// this is one answer, not two.
90+
reviewer: { type: 'user', reference: 'sys_user' },
91+
// An author-chosen target, supplied.
92+
account: { type: 'lookup', reference: 'crm_account' },
93+
// An author-chosen target, absent: nothing can supply it.
94+
partner: { type: 'lookup' },
95+
parent_deal: { type: 'master_detail' },
96+
// Not a reference-typed field at all.
97+
stage: { type: 'text' },
98+
},
99+
},
100+
};
101+
102+
describe('ApprovalService.resolveLookupFields — a target FIXED BY THE TYPE', () => {
103+
it('resolves a `user` field that materializes no `reference` to the type constant', () => {
104+
const { resolve } = makeService(DEAL);
105+
const owner = resolve('deal').find(f => f.key === 'owner');
106+
expect(owner).toEqual({ key: 'owner', reference: 'sys_user' });
107+
// And it is the ARBITER's answer, not a `sys_user` literal this reader owns
108+
// — a hand-copied constant here would be the second list the spec module
109+
// exists to prevent.
110+
expect(owner?.reference).toBe(referenceTargetOf({ type: 'user' }));
111+
});
112+
113+
it('answers identically whether or not the constant is spelled out', () => {
114+
const { resolve } = makeService(DEAL);
115+
const byKey = new Map(resolve('deal').map(f => [f.key, f.reference]));
116+
expect(byKey.get('owner')).toBe(byKey.get('reviewer'));
117+
expect(byKey.get('account')).toBe('crm_account');
118+
});
119+
120+
it('still leaves out an author-chosen target that names nothing, and stays silent about it', () => {
121+
const { resolve, warn } = makeService(DEAL);
122+
const keys = resolve('deal').map(f => f.key);
123+
expect(keys).not.toContain('partner');
124+
expect(keys).not.toContain('parent_deal');
125+
expect(keys).not.toContain('stage');
126+
// Absence is legal for those types (`FieldSchema.reference` is optional), so
127+
// it is reported nowhere — only an UNREADABLE carrier warns.
128+
expect(warn).not.toHaveBeenCalled();
129+
});
130+
});
131+
132+
describe('inbox display enrichment — the previously silent path', () => {
133+
const TICKET = {
134+
ticket: { label: 'Ticket', fields: { name: {}, assignee: { type: 'user' } } },
135+
sys_user: { label: 'User', fields: { name: {} } },
136+
};
137+
138+
it('issues the `sys_user` read and shows the name for an implicit-target field', async () => {
139+
const { enrich, finds } = makeService(TICKET, {
140+
ticket: [{ id: 'tk1', name: 'Printer on fire' }],
141+
sys_user: [{ id: 'u7', name: 'Grace Hopper' }],
142+
});
143+
const rows: InboxRow[] = [{
144+
object_name: 'ticket',
145+
record_id: 'tk1',
146+
payload: { name: 'Printer on fire', assignee: 'u7' },
147+
}];
148+
await enrich(rows);
149+
150+
// The read the silent path never issued. `assignee` is this object's ONLY
151+
// reference field, so before the repair the enrichment asked `sys_user`
152+
// nothing at all — this is the half a happy-value assertion cannot see.
153+
const userReads = finds.filter(f => f.object === 'sys_user');
154+
expect(userReads).toHaveLength(1);
155+
expect(userReads[0]?.ids).toEqual(['u7']);
156+
157+
// …and the value the reviewer actually sees, instead of the raw id.
158+
expect(rows[0]?.payload_display).toEqual({ assignee: 'Grace Hopper' });
159+
});
160+
161+
it('asks nothing and shows nothing when the target is genuinely absent', async () => {
162+
const { enrich, finds } = makeService({
163+
ticket: { label: 'Ticket', fields: { name: {}, partner: { type: 'lookup' } } },
164+
}, { ticket: [{ id: 'tk1', name: 'Printer on fire' }] });
165+
const rows: InboxRow[] = [{
166+
object_name: 'ticket',
167+
record_id: 'tk1',
168+
payload: { name: 'Printer on fire', partner: 'p1' },
169+
}];
170+
await enrich(rows);
171+
172+
// The other direction of the same claim: a field the contract leaves
173+
// under-specified is still resolved by nobody, so "enrichment runs for the
174+
// implicit target" cannot be read as "enrichment now runs for everything".
175+
expect(finds.map(f => f.object)).toEqual(['ticket']);
176+
expect(rows[0]?.payload_display).toBeUndefined();
177+
});
178+
});

0 commit comments

Comments
 (0)