@@ -83,6 +83,7 @@ import {
8383 hasPlatformObjectPrefix ,
8484 isPlatformProvidedObjectName ,
8585 PLATFORM_PROVIDED_OBJECT_NAMES ,
86+ SystemObjectName ,
8687} from '@objectstack/spec/system' ;
8788import { referenceCarrierOf , referenceTargetOf } from '@objectstack/spec/data' ;
8889
@@ -107,6 +108,31 @@ const RELATIONSHIP_TARGET_FIELD_TYPES: ReadonlySet<string> = new Set(['lookup',
107108export const OBJECT_REFERENCE_UNKNOWN = 'object-reference-unknown' ;
108109export 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+
110136export type ObjectRefSeverity = 'error' | 'warning' ;
111137
112138export 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+
185260export 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`
0 commit comments