Skip to content

Commit e7befa7

Browse files
feat(lint,spec): warn on a user field whose reference names an object other than sys_user (18.x loud half; refusal is the next major's) (#24942)
Closes #24808 Clause-②: yes (widening) The claim's `Clause-②: no` line was provisional. It is reset here, and in the changeset, from the measurement below. Under 「甲」 no conversion carries the refusal, so this PR is warning-only: no accept set narrows. It does publish one new rule id constant and its `os explain` entry from `@objectstack/lint`, and that is a widening of the public surface. That follows the lint precedent, where publishing rule-id constants was declared `yes (widening)`. The changeset is `minor` for `@objectstack/lint` and `patch` for `@objectstack/spec`. ## What this does - **`@objectstack/lint`: new advisory `user-field-foreign-reference`.** It fires on a `user` field, on an object or an object extension, whose `reference` names an object other than `sys_user`. It is a warning on `os validate`, `os build` and `os lint` (through the reference-integrity suite in the authoring registry), so no command's exit code changes. The hint is the canonical spelling: `{ type: 'lookup', reference: 'TARGET' }`, or drop `reference`. `USER_FIELD_FOREIGN_REFERENCE` is exported from the barrel, and `RULE_EXPLANATIONS` gains its entry. - **`@objectstack/spec`: `Field.user()`'s config types `reference` as `@deprecated`.** The accept set and the output are unchanged. The docblocks on `Field.user()` and `IMPLICIT_REFERENCE_TARGETS` now say what is declared and what is enforced. - **In-repo producers.** One fixture was incidental and is now a `lookup`: `packages/lint/src/object-graph.test.ts`, the module fixture `owner`. Two pins of the 18.x resolution are kept on purpose, each with a comment: `packages/spec/src/data/field-value.test.ts` (`my_people`), and the object-graph "explicit carrier wins" control, which now builds its own inline fixture. ## Step 1: measuring producers (tree `objectstack-ai/objectstack` @ `b42c621c05`) Scan: every object literal with `type: 'user'` (TS or JSON quoting, nested-brace-free) whose `reference` is not `'sys_user'` or `SystemObjectName.USER`, plus `Field.user({ … reference … })`, plus a window scan of six lines either side of 413 `type: 'user'` lines in 176 files. | where | count | paths | |---|---|---| | `examples/**` (all example apps) | 0 | none | | package source (non-test) | 0 | none | | `Field.user({ reference })` calls | 0 | none (67 `Field.user(` lines, none pass `reference`) | | test fixtures | 2 | `packages/spec/src/data/field-value.test.ts:91` (`my_people`), `packages/lint/src/object-graph.test.ts:39` (`crm_person`) | | YAML | 0 | none | The window scan's other 81 candidates were all neighbouring `lookup` fields or unreadable-carrier refusal probes (`reference: { object: 'sys_user' }`). Read by hand, none is a foreign-target `user`. ## Step 2: can an ADR-0087 conversion carry the refusal? No ADR-0087 D2 scopes conversions to "losslessly mappable changes only". Rewriting `{ type: 'user', reference: X }` to `{ type: 'lookup', reference: X }` keeps the stored shape and every reader that follows the target. It changes three behaviours that are measured in this tree: 1. **Delete semantics: the blocker.** `user` is outside the referential delete walk. `DELETE_SEMANTICS_REFERENCE_TYPES` in `packages/objectql/src/engine.ts` is `master_detail`, `lookup` and `tree`. `FieldSchema`'s overwrite materializes `deleteBehavior: 'set_null'` on `lookup` / `tree` only (`field.zod.ts`, the #9784 block). So today, deleting an X record leaves a `user(X)` column untouched. After the rewrite, it would null that column, and `planCascadeAtomicity` would plan X's deletes differently. `deleteBehavior` is `set_null | cascade | restrict`, and none of these means "do nothing". No rewrite preserves the behaviour. 2. **Sharing.** `fieldHoldsUsers` (`plugin-sharing/src/sharing-rule-service.ts`) answers true for any `user` field. After the rewrite it is false for a lookup to X. 3. **`current_user` default.** It is legal on `user` whatever the reference. On `lookup(X)` with X not `sys_user` it is refused at parse (`default-value-shape.ts`), so that subset would convert into a refused shape. So under 「甲」 rule 2, 18.x ships the loud half only, and the refusal is filed for 19.0. The follow-up text is in the report for the seat. ## Step 3: what was not done in 18.x, and why - **`referenceTargetOf` for `user` still returns the authored carrier.** Making it return `sys_user` would silently re-target a field that still loads (expand, import, seed and FK resolution would flip from X to `sys_user`). That is a behaviour change with no conversion and no refusal in front of it, which is what 「甲」 keeps out of an 18.x minor. It moves with the 19.0 refusal: once parse refuses a foreign carrier, the carrier can only ever be `sys_user`. The dispatch asked for this flip "either way". This PR does not make it, and the report records the deviation as an open question. - **`Field.user()` is not type-narrowed to `reference?: 'sys_user'`.** That would be a compile-time refusal of the TS authoring surface with no conversion, so it is a 19.0 narrowing too. Keeping `...config` last is deliberate. Moving `reference: 'sys_user'` after the spread would silently re-target an untyped caller's field. With the spread last, the lint sees the foreign carrier and warns. ## Tests Reproduction first: `ObjectSchema.safeParse({ name: 'probe_task', fields: { owner: { type: 'user', label: 'Owner', reference: 'probe_account' } } })` succeeds, and `referenceTargetOf` answers `probe_account`. The 18.x half pins that this shape still loads. - New `describe('[#24808] …')` in `packages/lint/src/validate-object-references.test.ts`. It covers the warning (rule, severity, path, where, message, hint), object extensions, the map form, one-line message and hint of at most 200 characters, the `os explain` entry, and the authoring registry on `validate` / `build` / `lint` (advisory, never an error). Controls: no `reference`, `reference: 'sys_user'` and `Field.user()` stay silent; `Field.lookup('probe_account')` and a literal lookup stay silent; an unreadable carrier still throws. - Ablation: `node scripts/ablation-replace.mjs` deleted the `findings.push(...userFieldForeignReferenceFindings(stack))` line (anchor 1 → 0, blob `bc1ff6a17b` → `3390aeb770`), and 7 tests went red. Restored with `git checkout HEAD`: the blob matched `HEAD:` (`bc1ff6a17b`) and `git diff HEAD` was empty. Lint tests import `src` directly, so no `dist` leg applies. - `pnpm --filter @objectstack/lint test`: 151 files / 7005 tests passed. `pnpm --filter @objectstack/lint typecheck` and `pnpm --filter @objectstack/spec typecheck` exit 0. `pnpm --filter @objectstack/spec exec vitest run src/data`: 132 files / 4169 passed. - Gates: At head `3815407620`, the derivation `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gives 88 families. All ran, and `--ran` reconciles them: 86 exit 0, and 2 are NOT MEASURED. Those two are `check:dual-build-cjs-loads` and `check:lean-entry-closure`, each exit 3, PREREQUISITE NOT MET: they read a full-monorepo `dist` this worktree did not build, so they are declared to CI. Also exit 0: `pnpm --filter @objectstack/spec check:generated` (all 14 artifacts up to date), `check:adr-0087-registration` (no declared-breaking changeset), `check:changeset-no-major` and `check:nul-bytes`. Package `integration` tiers do not apply: the diff touches no CLI or driver spawn path. ## Acceptance notes - objectui's checkout here is old (`b434f5f47`), and in it `packages/fields/src/__tests__/userCell.readGate-10535.test.tsx:157` renders `{ type: 'user', reference: 'crm_member' }`. That is a renderer fixture, not a metadata producer. Carrier: whoever lands the 19.0 refusal. - No runtime publish-gate (Studio, REST `/meta`, MCP) object-write leg: the reference suite member runs on `flow` / `dataset` snapshots only. Crossing it onto `object` writes is that member's own false-positive measurement (its `runtimeTypes` docblock). Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 644e4b2 commit e7befa7

9 files changed

Lines changed: 273 additions & 6 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(lint,spec): `os validate`, `os build` and `os lint` warn on a `user` field whose `reference` names an object other than `sys_user`, and `Field.user()` marks `reference` deprecated
7+
8+
Clause-②: yes (widening)
9+
10+
- **What was wrong.** The spec declares a `user` field's target fixed to `sys_user`, and nothing enforced it. `{ type: 'user', reference: 'probe_account' }` parsed, `os validate` passed it, and `referenceTargetOf` resolved it to `probe_account`. So every reader that follows the target (`$expand`, imports, seed loading, the object graph) treated the field as a link to `probe_account`, while every reader that branches on the type (the person picker, the `current_user` default, a sharing rule over the users a field holds) treated its values as users.
11+
- **What changed.** A new advisory, `user-field-foreign-reference`, fires on such a field on an object or an object extension. It names the field and the target, and its hint is the canonical spelling: `{ type: 'lookup', reference: '<target>' }` (`Field.lookup('<target>', …)`), or drop `reference` if a person was meant. It is a warning, so no command's exit code changes. `Field.user()`'s config now types `reference` as `@deprecated`. The accepted values are unchanged.
12+
- **Why only a warning in 18.x.** A narrowing ships its refusal in an 18.x minor only when an ADR-0087 conversion can rewrite the old shape. This one cannot, because the rewrite is not lossless. A `user` field is outside the referential delete walk, while a `lookup` gets `deleteBehavior: 'set_null'` and joins it. `deleteBehavior` has no value that means "do nothing", so a converted field would start clearing child columns when its target record is deleted, which a `user` field never does. The refusal is planned for the next major.
13+
- **Published.** `USER_FIELD_FOREIGN_REFERENCE` (`user-field-foreign-reference`) is exported from `@objectstack/lint` beside `OBJECT_REFERENCE_UNKNOWN`, and `RULE_EXPLANATIONS` gains its entry, so `os explain user-field-foreign-reference` prints why the field is half a link and half a user, and why the rewrite to a `lookup` is left to the author.
14+
- **Unchanged.** Parsing, the runtime's resolution of such a field (it still follows the authored `reference`), `Field.user()`'s output, and `user` fields written without `reference` or with `reference: 'sys_user'`.
15+
- **What to do.** Change a `user` field that points at another object to a `lookup` on that object. No in-repo app or package wrote this shape. Two test fixtures did: one is now a `lookup`, and the other deliberately pins the 18.x resolution.

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ export {
426426
validateObjectReferences,
427427
OBJECT_REFERENCE_UNKNOWN,
428428
OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
429+
USER_FIELD_FOREIGN_REFERENCE,
429430
} from './validate-object-references.js';
430431
export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-references.js';
431432

‎packages/lint/src/object-graph.test.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ const stack = {
3636
name: 'crm_account',
3737
fields: {
3838
region: { type: 'text', label: 'Region' },
39-
owner: { type: 'user', label: 'Owner', reference: 'crm_person' },
39+
// [#24808] A link to a person OBJECT is a `lookup`: a `user` field's
40+
// target is fixed to `sys_user`, and naming another object with it is
41+
// what `user-field-foreign-reference` warns on.
42+
owner: { type: 'lookup', label: 'Owner', reference: 'crm_person' },
4043
},
4144
},
4245
{ name: 'crm_person', fields: { email: { type: 'email', label: 'Email' } } },
@@ -418,9 +421,18 @@ describe('[#19289] object-graph — a `user` field takes its target from the TYP
418421

419422
it('control: an EXPLICIT carrier still wins over the constant', () => {
420423
// `reference` MATERIALIZES the constant for `user`; where an author named a
421-
// different object the arbiter returns what they wrote, unchanged. The
422-
// module fixture above relies on this (`owner` → `crm_person`).
423-
expect(resolveFieldPath(graph, 'crm_opportunity', 'account.owner.email'))
424+
// different object the arbiter returns what they wrote, unchanged.
425+
// [#24808] The shape is declared wrong and warned on
426+
// (`user-field-foreign-reference`); this pins its protocol-18 resolution
427+
// until the next major refuses it, so it is built here rather than kept as
428+
// an incidental module fixture.
429+
const foreign = indexObjectGraph({
430+
objects: [
431+
{ name: 'crm_account', fields: { owner: { type: 'user', label: 'Owner', reference: 'crm_person' } } },
432+
{ name: 'crm_person', fields: { email: { type: 'email', label: 'Email' } } },
433+
],
434+
});
435+
expect(resolveFieldPath(foreign, 'crm_account', 'owner.email'))
424436
.toMatchObject({ kind: 'ok', object: 'crm_person', field: 'email' });
425437
});
426438

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3562,6 +3562,28 @@ const OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION: RuleExplanation = {
35623562
],
35633563
};
35643564

3565+
const USER_FIELD_FOREIGN_REFERENCE_EXPLANATION: RuleExplanation = {
3566+
rule: 'user-field-foreign-reference',
3567+
covers: 'why a user field cannot point at another object',
3568+
paragraphs: [
3569+
'A `user` field is a person picker: its target is fixed to the `sys_user` object by the type, ' +
3570+
'which is why `Field.user()` takes no target argument. Writing `reference` with any other ' +
3571+
'object name contradicts the type, and today nothing refuses it.',
3572+
'Such a field is half one thing and half another. Every reader that follows the target ' +
3573+
'(`$expand`, imports, seed loading, field paths) treats it as a link to the named object, ' +
3574+
'while every reader that branches on the type treats its values as users: the person picker ' +
3575+
'searches people, a `current_user` default stores the acting user\'s id, and a sharing rule over ' +
3576+
'the users a field holds shares the record with those ids.',
3577+
'A link to another object is a `lookup`: `{ type: \'lookup\', reference: \'<object>\' }`, or ' +
3578+
'`Field.lookup(\'<object>\', …)`. If a person was meant, drop `reference`.',
3579+
'It warns rather than refuses because no automatic rewrite keeps the field\'s behaviour: a `lookup` ' +
3580+
'takes part in the delete walk with `deleteBehavior: \'set_null\'` by default, so it would start ' +
3581+
'clearing the column when the target record is deleted, which a `user` field never does. Make ' +
3582+
'the change by hand and check that delete behaviour is what you want. Refusing the shape at ' +
3583+
'parse is planned for the next major.',
3584+
],
3585+
};
3586+
35653587
// ── Capability references (`validate-capability-references.ts`) ───────────
35663588

35673589
const CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION: RuleExplanation = {
@@ -4858,6 +4880,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
48584880
[OBJECT_FIELD_REF_UNKNOWN_EXPLANATION.rule]: OBJECT_FIELD_REF_UNKNOWN_EXPLANATION,
48594881
[OBJECT_REFERENCE_UNKNOWN_EXPLANATION.rule]: OBJECT_REFERENCE_UNKNOWN_EXPLANATION,
48604882
[OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION.rule]: OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION,
4883+
[USER_FIELD_FOREIGN_REFERENCE_EXPLANATION.rule]: USER_FIELD_FOREIGN_REFERENCE_EXPLANATION,
48614884
[CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION.rule]: CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION,
48624885
[LIST_VIEW_FILTERS_IN_VIEWS_MODE_EXPLANATION.rule]: LIST_VIEW_FILTERS_IN_VIEWS_MODE_EXPLANATION,
48634886
[LIVENESS_LEDGER_UNREADABLE_EXPLANATION.rule]: LIVENESS_LEDGER_UNREADABLE_EXPLANATION,

‎packages/lint/src/validate-object-references.test.ts‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
validateObjectReferences as validateObjectReferencesUnrecorded,
66
OBJECT_REFERENCE_UNKNOWN,
77
OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
8+
USER_FIELD_FOREIGN_REFERENCE,
89
} from './validate-object-references.js';
10+
import { Field, ObjectSchema, referenceTargetOf } from '@objectstack/spec/data';
911
import { runAuthoringRules, splitBySeverity } from './authoring-rules.js';
1012
import { PLATFORM_OBJECT_PREFIXES } from '@objectstack/spec/system';
1113
import { explainRule } from './rule-explanations.js';
@@ -707,10 +709,99 @@ describe('[#19289] validateObjectReferences — a `user` target comes from the T
707709
it('control: the rule still REPORTS a real miss — an explicit carrier naming an unknown object errors', () => {
708710
// Without this, "no findings" above reads equally well as a rule that
709711
// stopped judging `user` fields altogether.
712+
// [#24808] A `user` field naming any object but `sys_user` also draws the
713+
// foreign-target warning, so the unknown-object error is read by rule.
710714
const findings = validateObjectReferences(userField({ reference: 'zzz_nope' }));
715+
expect(findings.filter((f) => f.rule === OBJECT_REFERENCE_UNKNOWN)).toHaveLength(1);
716+
expect(findings.map((f) => f.rule).sort()).toEqual([OBJECT_REFERENCE_UNKNOWN, USER_FIELD_FOREIGN_REFERENCE].sort());
717+
});
718+
});
719+
720+
/**
721+
* [#24808] A `user` field's target is FIXED to `sys_user` by the spec, and
722+
* nothing enforced it: the reproduction below parses through `ObjectSchema`
723+
* and resolves through `referenceTargetOf` to the authored object. Under the
724+
* 「甲」 ruling the 18.x half is this warning (no ADR-0087 conversion can carry
725+
* the refusal: a `lookup` joins the referential delete walk a `user` field
726+
* sits outside), so the parse half below pins that the shape still LOADS.
727+
*/
728+
describe('[#24808] validateObjectReferences — a `user` field naming another object warns', () => {
729+
const stackWith = (assignee: Record<string, unknown>) => ({
730+
objects: [
731+
{ name: 'probe_account', fields: { name: { type: 'text', label: 'Name' } } },
732+
{ name: 'probe_task', fields: { name: { type: 'text', label: 'Name' }, owner: assignee } },
733+
],
734+
});
735+
const PROBE = { type: 'user', label: 'Owner', reference: 'probe_account' };
736+
737+
it('reproduction: the shape parses and the arbiter follows the authored object', () => {
738+
const parsed = ObjectSchema.safeParse({ name: 'probe_task', fields: { owner: PROBE } });
739+
expect(parsed.success).toBe(true);
740+
expect(referenceTargetOf(PROBE)).toBe('probe_account');
741+
});
742+
743+
it('warns — never errors — and names the field, the target and the lookup spelling', () => {
744+
const findings = validateObjectReferences(stackWith(PROBE));
711745
expect(findings).toHaveLength(1);
712-
expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNKNOWN);
746+
const [f] = findings;
747+
expect(f.rule).toBe(USER_FIELD_FOREIGN_REFERENCE);
748+
expect(f.severity).toBe('warning');
749+
expect(f.path).toBe('objects[1].fields.owner.reference');
750+
expect(f.where).toBe('object "probe_task" · field "owner"');
751+
expect(f.message).toContain('"probe_account"');
752+
expect(f.hint).toContain("{ type: 'lookup', reference: 'probe_account' }");
753+
});
754+
755+
it('warns on an object extension too — the verdict needs no artifact context', () => {
756+
const findings = validateObjectReferences({
757+
objectExtensions: [{ extend: 'sys_user_profile', fields: { mentor: { ...PROBE } } }],
758+
});
759+
expect(findings.filter((f) => f.rule === USER_FIELD_FOREIGN_REFERENCE)).toEqual([
760+
expect.objectContaining({ path: 'objectExtensions[0].fields.mentor.reference', severity: 'warning' }),
761+
]);
713762
});
763+
764+
it('warns on the map form of `fields` and on the array form of `objects` alike', () => {
765+
const findings = validateObjectReferences({
766+
objects: { probe_task: { fields: { owner: PROBE } }, probe_account: { fields: {} } },
767+
});
768+
expect(findings.map((f) => f.rule)).toEqual([USER_FIELD_FOREIGN_REFERENCE]);
769+
});
770+
771+
it('control: the two legal spellings — no `reference`, and `reference: "sys_user"` — stay silent', () => {
772+
expect(validateObjectReferences(stackWith({ type: 'user', label: 'Owner' }))).toEqual([]);
773+
expect(validateObjectReferences(stackWith({ type: 'user', label: 'Owner', reference: 'sys_user' }))).toEqual([]);
774+
expect(validateObjectReferences(stackWith(Field.user({ label: 'Owner' })))).toEqual([]);
775+
});
776+
777+
it('control: a `lookup` to the same object is the canonical spelling and draws nothing', () => {
778+
expect(validateObjectReferences(stackWith(Field.lookup('probe_account', { label: 'Owner' })))).toEqual([]);
779+
expect(validateObjectReferences(stackWith({ type: 'lookup', reference: 'probe_account' }))).toEqual([]);
780+
});
781+
782+
it('control: an unreadable carrier still REFUSES rather than reading as a foreign target', () => {
783+
expect(() => validateObjectReferences(stackWith({ type: 'user', reference: { object: 'sys_user' } })))
784+
.toThrow(/`reference` is an object/);
785+
});
786+
787+
it('prints one line each, and `os explain` carries the reasoning the verdict leaves out', () => {
788+
const [f] = validateObjectReferences(stackWith(PROBE));
789+
for (const text of [f.message, f.hint]) {
790+
expect(text).not.toContain('\n');
791+
expect(text.length, text).toBeLessThanOrEqual(200);
792+
}
793+
const entry = explainRule(USER_FIELD_FOREIGN_REFERENCE);
794+
expect(entry?.rule).toBe(USER_FIELD_FOREIGN_REFERENCE);
795+
expect(entry!.paragraphs.join('\n')).toContain("deleteBehavior: 'set_null'");
796+
});
797+
798+
for (const command of ['validate', 'build', 'lint'] as const) {
799+
it(`reaches \`os ${command}\` through the authoring registry, as an advisory and never a gate`, () => {
800+
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stackWith(PROBE) as never }));
801+
expect(errors.filter((f) => f.rule === USER_FIELD_FOREIGN_REFERENCE)).toEqual([]);
802+
expect(advisories.filter((f) => f.rule === USER_FIELD_FOREIGN_REFERENCE)).toHaveLength(1);
803+
});
804+
}
714805
});
715806

716807
/**

‎packages/lint/src/validate-object-references.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
hasPlatformObjectPrefix,
8484
isPlatformProvidedObjectName,
8585
PLATFORM_PROVIDED_OBJECT_NAMES,
86+
SystemObjectName,
8687
} from '@objectstack/spec/system';
8788
import { referenceCarrierOf, referenceTargetOf } from '@objectstack/spec/data';
8889

@@ -107,6 +108,31 @@ const RELATIONSHIP_TARGET_FIELD_TYPES: ReadonlySet<string> = new Set(['lookup',
107108
export const OBJECT_REFERENCE_UNKNOWN = 'object-reference-unknown';
108109
export const OBJECT_REFERENCE_UNREGISTERED_PLATFORM = 'object-reference-unregistered-platform';
109110

111+
/**
112+
* [#24808 — ruled A on #24854, timed by the 「甲」 standing ruling] A `user`
113+
* field whose `reference` names an object other than `sys_user`.
114+
*
115+
* The spec declares a `user` field's target FIXED to `sys_user`
116+
* (`Field.user()`'s docblock, `IMPLICIT_REFERENCE_TARGETS` in
117+
* `field-value.zod.ts`), and nothing enforces it: `FieldSchema` admits any
118+
* string, and `referenceTargetOf` returns the authored carrier first, so the
119+
* runtime's target-following readers (`$expand`, the import runner, the seed
120+
* loader, the object graph) resolve the field against the authored object
121+
* while every reader that branches on the TYPE treats its values as users —
122+
* the person picker, the `current_user` default, a sharing rule's
123+
* `fieldHoldsUsers`. The field is half a lookup and half a user.
124+
*
125+
* WARNING, not error, by the 「甲」 rule: a narrowing ships its refusal in an
126+
* 18.x minor only when an ADR-0087 conversion carries it, and this one has no
127+
* lossless rewrite — `user` sits outside the referential delete walk
128+
* (`DELETE_SEMANTICS_REFERENCE_TYPES`, objectql) while a `lookup` materializes
129+
* `deleteBehavior: 'set_null'` and joins it, and `deleteBehavior` has no value
130+
* meaning "do nothing", so `{ type: 'lookup', reference }` would start nulling
131+
* child columns on a delete that leaves them untouched today. The refusal is
132+
* the next protocol major's; this is its loud half.
133+
*/
134+
export const USER_FIELD_FOREIGN_REFERENCE = 'user-field-foreign-reference';
135+
110136
export type ObjectRefSeverity = 'error' | 'warning';
111137

112138
export interface ObjectRefFinding {
@@ -182,6 +208,55 @@ function artifactProvidedObjectNames(stack: AnyRec): string[] {
182208
* Validate every object-name reference on the surfaces listed in the module
183209
* header. Returns findings (empty = clean).
184210
*/
211+
/**
212+
* The {@link USER_FIELD_FOREIGN_REFERENCE} findings for one stack: every
213+
* `user` field, on an object or an object extension, whose `reference` names
214+
* an object other than `sys_user`.
215+
*
216+
* Reads the CARRIER (`referenceCarrierOf`), not `referenceTargetOf`: the
217+
* question is what the author wrote, and the arbiter would answer the same
218+
* string for this shape anyway. An absent carrier and `reference: 'sys_user'`
219+
* are the two legal spellings and produce nothing; an unreadable carrier
220+
* throws through the accessor exactly as it does for the resolution leg.
221+
* Interpolated targets are not exempted: the type fixes the target, so no
222+
* render-time value can make another object right.
223+
*/
224+
function userFieldForeignReferenceFindings(stack: AnyRec): ObjectRefFinding[] {
225+
const out: ObjectRefFinding[] = [];
226+
const legs: Array<{ key: 'objects' | 'objectExtensions'; ownName: (rec: AnyRec) => string | undefined }> = [
227+
{ key: 'objects', ownName: (rec) => strName(rec.name) },
228+
{ key: 'objectExtensions', ownName: (rec) => strName(rec.extend) },
229+
];
230+
for (const { key, ownName } of legs) {
231+
const containers = recordsOf(stack[key]);
232+
for (let ci = 0; ci < containers.length; ci++) {
233+
const container = containers[ci];
234+
const containerName = ownName(container) ?? `#${ci}`;
235+
const fields = recordsOf(container.fields);
236+
for (let fi = 0; fi < fields.length; fi++) {
237+
const field = fields[fi];
238+
if (field.type !== 'user') continue;
239+
const target = referenceCarrierOf(field, 'validateObjectReferences');
240+
if (!target || target === SystemObjectName.USER) continue;
241+
const fieldName = strName(field.name) ?? `#${fi}`;
242+
out.push({
243+
severity: 'warning',
244+
rule: USER_FIELD_FOREIGN_REFERENCE,
245+
where: `${key === 'objects' ? 'object' : 'object extension'} "${containerName}" · field "${fieldName}"`,
246+
path: `${key}[${ci}].fields.${fieldName}.reference`,
247+
message:
248+
`user field "${fieldName}" names reference "${target}", but a user field's target is fixed to ` +
249+
`sys_user; it acts as a link to "${target}" whose values user-only readers take for users.`,
250+
hint:
251+
`Declare a lookup instead, { type: 'lookup', reference: '${target}' }, or drop \`reference\` ` +
252+
'if a person was meant.',
253+
});
254+
}
255+
}
256+
}
257+
return out;
258+
}
259+
185260
export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] {
186261
const findings: ObjectRefFinding[] = [];
187262
if (!stack || typeof stack !== 'object') return findings;
@@ -340,6 +415,13 @@ export function validateObjectReferences(stack: AnyRec): ObjectRefFinding[] {
340415
}
341416
}
342417

418+
// ── `user` fields → a target other than `sys_user` (#24808) ──
419+
// See {@link userFieldForeignReferenceFindings}. Walks `objectExtensions`
420+
// too: unlike the resolution leg above, this verdict needs no artifact
421+
// context — a `user` field naming any object but `sys_user` is the same
422+
// contradiction wherever the field is declared.
423+
findings.push(...userFieldForeignReferenceFindings(stack));
424+
343425
// ── View containers → the container's own `object` (#20216) ──
344426
// `ViewSchema.object` is `z.string()`, and nothing resolved it: `defineStack`'s
345427
// `validateCrossReferences` reads a container's `list.data` / `form.data`

‎packages/spec/src/data/field-value.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ describe('semantic type classes', () => {
8888
expect(referenceTargetOf({ type: 'user' })).toBe('sys_user');
8989
expect(referenceTargetOf({ type: 'user', reference: 'sys_user' })).toBe('sys_user');
9090
// An explicit target still wins — nothing here overrides authored metadata.
91+
// [#24808] This pins the protocol-18 behaviour on purpose, not a producer:
92+
// the shape is declared wrong (the target is fixed) and `@objectstack/lint`
93+
// warns on it (`user-field-foreign-reference`), but no ADR-0087 conversion
94+
// carries its refusal, so under the 「甲」 ruling it keeps resolving as
95+
// written until the next protocol major refuses it at parse.
9196
expect(referenceTargetOf({ type: 'user', reference: 'my_people' })).toBe('my_people');
9297

9398
// Genuinely targetless: the types whose target IS author-chosen, unwritten.

0 commit comments

Comments
 (0)