Skip to content

Commit 2069b81

Browse files
feat(spec)!: refuse a user field whose reference names an object other than sys_user; every user field resolves to sys_user (#25074)
Closes #24952 Clause-②: no (narrowing: a `user` field naming an object other than `sys_user` is refused; ships now under the amended 「甲」, #6015 `6303865025`) Metadata-protocol: narrowed The refusal half of #24808 (ruled A on #24854), re-timed to 18.x by the amended 「甲」 ruling. BREAKING: the accept set of `FieldSchema` narrows. Shipped as a `minor` changeset. ## What changed - **`FieldSchema` refuses the shape** (`packages/spec/src/data/field.zod.ts`). A field of `type: 'user'` whose `reference` names anything but `sys_user` gets one `custom` issue at `reference`. The message names the `lookup` spelling (`{ type: 'lookup', reference: 'TARGET' }` / `Field.lookup('TARGET', ...)`) and says that `deleteBehavior` must be chosen. Absent, blank and `'sys_user'` still parse. The check is on `FieldSchema` rather than as an `ObjectSchema` superRefine (the `refuseForeignTreeReference` precedent), because the verdict needs only the field. So objects, object extensions and `defineStack` all refuse it through the same code. - **`referenceTargetOf` answers `sys_user` for every `user` field**, whatever the carrier says (`field-value.zod.ts`). A carrier that cannot be read still throws. A stored body saved before this release therefore resolves to `sys_user` as well. - **`Field.user()`**: `reference` is now typed `'sys_user'` (still `@deprecated`) and is written after the config spread. The output keeps its key order and its literal types. - **ADR-0087 D3 semantic entry** `user-field-foreign-reference-refused` (`packages/spec/src/migrations/entries/semantic/18.user-field-foreign-reference-refused.ts`). It is not a conversion, because the rewrite to a `lookup` is not lossless: a `lookup` joins the referential delete walk with `deleteBehavior: 'set_null'`, and `deleteBehavior` has no value meaning "leave it". `objectstack migrate meta` reports the entry. - **The `@objectstack/lint` advisory `user-field-foreign-reference` is withdrawn.** It was added for 18.x and never published, and the parse refusal replaces it. Removed: the rule, its `RULE_EXPLANATIONS` entry, its export and its tests. - **The 18.x pins are retired.** In `field-value.test.ts` (`my_people`) and the `object-graph.test.ts` explicit-carrier control, both now assert that the hop lands on `sys_user`. The `validate-object-references` unknown-object control is now proven on a `lookup`. - **Changeset:** `.changeset/24952-user-field-reference-refused.md` (breaking, with FROM/TO, the fix and the ADR-0087 marker). ## The pending 24808 changeset (route B, seat decision 2026-10-22) This PR no longer deletes `.changeset/24808-user-field-foreign-reference-warning.md`, the pending release note of #24942. The file is restored byte-identical to `main`, so `check-empty-changeset` is green. Instead, this PR's own `.changeset/24952-user-field-reference-refused.md` names `@objectstack/lint` as well as `@objectstack/spec`, carries `Metadata-protocol: narrowed`, and has a **Supersedes** paragraph. That paragraph says the `user-field-foreign-reference` advisory, the `USER_FIELD_FOREIGN_REFERENCE` export and its `os explain` entry that 24808 announced are withdrawn before release, and that a foreign `reference` on a `user` field is now refused by `FieldSchema`. Both entries land in `@objectstack/lint`'s CHANGELOG together, so a reader can tell which one is current. The packages version in lockstep, so naming lint moves no version number. ## Producers The first act from the re-timing comment was to measure who writes this shape. A source grep over the tree (tests excluded) found no `type: 'user'` with a foreign `reference` in any app, example or package. `pnpm --filter @objectstack/spec test:repo`, which parses the repo's example stacks, is green. So no producer needs fixing in this PR. Two test fixtures used the shape: the `object-graph.test.ts` module fixture was already a `lookup`, and the explicit-carrier control is rewritten here. ## Verification (head `bf738d1f99`, merged with `origin/main` at `63085c3806`) - `pnpm --filter '@objectstack/lint^...' build` (spec and the rest of lint's dependency closure): exit 0. - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date. No regeneration was needed: no `.describe()`, authorable key or public export changed. - `pnpm --filter @objectstack/spec exec vitest run --project local`: 706 files, 21433 tests passed. - `pnpm --filter @objectstack/spec test:repo`: 72 files, 1237 tests passed. - `pnpm --filter @objectstack/spec typecheck`: exit 0. This includes `check:test-typecheck`, which compiles the `expectTypeOf` pins in the new test file. - `pnpm --filter @objectstack/lint exec vitest run`: 153 files, 7045 tests passed. `pnpm --filter @objectstack/lint typecheck`: exit 0. - `node scripts/pm/dispatch-gates.mjs --ran`, round 2 at `1a4eb0cf80`, after merging `origin/main` and restoring 24808: 91 derived, 87 run, 4 NOT MEASURED on the first pass (missing dist). Three of those were re-run green after the closure builds (`check:doc-formula-expressions`, `check:docs-transcript-drift`, `check:lean-entry-closure`), which leaves `check:dual-build-cjs-loads` to CI. `check-empty-changeset`, `check-changeset-no-major --event`, `check:adr-0087-registration`, `check-changeset-fixed` and `check:nul-bytes` all exit 0. spec: 21471 tests and `test:repo` 1247 passed; lint: 7069 passed. ## Acceptance notes - **objectui half of the card's item 6 is not in this PR.** `packages/fields/src/__tests__/userCell.readGate-10535.test.tsx` at the pinned `.objectui-sha` (`cf153eb2e5`) still pins that the user cell's person object is the field's `reference` (`crm_member`). The fixture casts its field and never parses it, so nothing here turns it red. But it now pins a reading the spec refuses. The renderer reads `reference` directly rather than the type's constant, so for a stored body saved before this release it would gate by a different object than the runtime resolves. Retiring it is a change in objectui, outside this claim's file surface. - **The save door is inferred, not measured.** The changeset says the metadata save door refuses the shape. That follows from `object` mapping to `ObjectSchema` in `packages/spec/src/kernel/metadata-type-schemas.ts`. It was not measured over HTTP in this PR. - **Contract review.** The claim says this PR owes a `CONTRACT_REVIEW_TIER` contract review. - Open PRs that touch `field.zod.ts`, `field-value.zod.ts`, `object.zod.ts` or the lint files in this diff: none (all 11 open PRs were read). --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14e7239 commit 2069b81

11 files changed

Lines changed: 270 additions & 254 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec)!: a `user` field whose `reference` names an object other than `sys_user` is refused at parse, and every `user` field resolves to `sys_user`
7+
8+
Clause-②: no (narrowing)
9+
Metadata-protocol: narrowed
10+
11+
<!-- adr-0087: registered user-field-foreign-reference-refused -->
12+
13+
**BREAKING** (the accept set of `FieldSchema` narrows), shipped as `minor` under the repo's launch-window convention for breaking changes.
14+
15+
- **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, and `referenceTargetOf` resolved it to `probe_account`. Readers that follow the target (`$expand`, imports, seed loading, field paths) treated the field as a link to `probe_account`, while readers that branch on the type (the person picker, a `current_user` default, a sharing rule over the users a field holds) treated its values as users.
16+
- **FROM:** a `user` field with `reference` naming any object but `sys_user` parsed, and resolved to the named object.
17+
- **TO:** `FieldSchema` refuses it with an issue at `reference` that names the replacement, so `ObjectSchema`, `ObjectExtensionSchema`, `defineStack`, `os validate` and the metadata save door refuse it too. `referenceTargetOf` answers `sys_user` for every `user` field, whatever `reference` says, so a stored body saved before this release resolves to `sys_user` as well. `Field.user()`'s config types `reference` as `'sys_user'` and writes `sys_user` after the config spread.
18+
- **Unchanged:** a `user` field without `reference`, with `reference: 'sys_user'` or with a blank `reference`, and `Field.user()`'s output for every typed call.
19+
- **Fix:** change such a field to a `lookup` on the object it names, `{ type: 'lookup', reference: 'TARGET' }` (`Field.lookup('TARGET', …)`), and choose its `deleteBehavior` on purpose: a `lookup` defaults to `'set_null'` when the target record is deleted, which a `user` field never did. A `current_user` default is not legal on a `lookup` to an object other than `sys_user`. If a person was meant, drop `reference`.
20+
- **Why no automatic rewrite.** No ADR-0087 conversion can carry the change losslessly: a `lookup` joins the referential delete walk and `deleteBehavior` has no value that means "leave it". `objectstack migrate meta` reports the semantic entry `user-field-foreign-reference-refused` instead.
21+
22+
**Supersedes the `user-field-foreign-reference` warning entry in this release.** That entry announced a `@objectstack/lint` advisory `user-field-foreign-reference`, the `USER_FIELD_FOREIGN_REFERENCE` export and its `os explain` entry, and said such a field would still parse. All three are withdrawn before release and never ship: a foreign `reference` on a `user` field is now refused by `FieldSchema` instead, so there is nothing left for the advisory to warn about.
23+
24+
- **For `@objectstack/lint`:** no `user-field-foreign-reference` rule, no `USER_FIELD_FOREIGN_REFERENCE` export and no `RULE_EXPLANATIONS` entry for it. `OBJECT_REFERENCE_UNKNOWN` and every other export are unchanged. The refusal arrives as a `FieldSchema` issue at `reference`, not as a lint advisory.

‎packages/lint/src/index.ts‎

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

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ const stack = {
3737
fields: {
3838
region: { type: 'text', label: 'Region' },
3939
// [#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.
40+
// target is fixed to `sys_user`, and `FieldSchema` refuses a `user`
41+
// field naming another object (#24952).
4242
owner: { type: 'lookup', label: 'Owner', reference: 'crm_person' },
4343
},
4444
},
@@ -419,21 +419,19 @@ describe('[#19289] object-graph — a `user` field takes its target from the TYP
419419
expect(isUnjudgeable(verdict)).toBe(true);
420420
});
421421

422-
it('control: an EXPLICIT carrier still wins over the constant', () => {
423-
// `reference` MATERIALIZES the constant for `user`; where an author named a
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.
422+
it('[#24952] a foreign carrier on a `user` field no longer wins: the hop lands on `sys_user`', () => {
423+
// `reference` MATERIALIZES the constant for `user`. Naming another object
424+
// with it is refused at parse, so it reaches the graph only unparsed, and
425+
// the arbiter answers the type's constant rather than what was written.
429426
const foreign = indexObjectGraph({
430427
objects: [
431428
{ name: 'crm_account', fields: { owner: { type: 'user', label: 'Owner', reference: 'crm_person' } } },
432429
{ name: 'crm_person', fields: { email: { type: 'email', label: 'Email' } } },
430+
{ name: 'sys_user', fields: { email: { type: 'email', label: 'Email' } } },
433431
],
434432
});
435433
expect(resolveFieldPath(foreign, 'crm_account', 'owner.email'))
436-
.toMatchObject({ kind: 'ok', object: 'crm_person', field: 'email' });
434+
.toMatchObject({ kind: 'ok', object: 'sys_user', field: 'email' });
437435
});
438436

439437
it('control: an UNREADABLE carrier still REFUSES at index time', () => {

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

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3567,28 +3567,6 @@ const OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION: RuleExplanation = {
35673567
],
35683568
};
35693569

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

35943572
const CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION: RuleExplanation = {
@@ -4885,7 +4863,6 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
48854863
[OBJECT_FIELD_REF_UNKNOWN_EXPLANATION.rule]: OBJECT_FIELD_REF_UNKNOWN_EXPLANATION,
48864864
[OBJECT_REFERENCE_UNKNOWN_EXPLANATION.rule]: OBJECT_REFERENCE_UNKNOWN_EXPLANATION,
48874865
[OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION.rule]: OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION,
4888-
[USER_FIELD_FOREIGN_REFERENCE_EXPLANATION.rule]: USER_FIELD_FOREIGN_REFERENCE_EXPLANATION,
48894866
[CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION.rule]: CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION,
48904867
[LIST_VIEW_FILTERS_IN_VIEWS_MODE_EXPLANATION.rule]: LIST_VIEW_FILTERS_IN_VIEWS_MODE_EXPLANATION,
48914868
[LIVENESS_LEDGER_UNREADABLE_EXPLANATION.rule]: LIVENESS_LEDGER_UNREADABLE_EXPLANATION,

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

Lines changed: 10 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import {
55
validateObjectReferences as validateObjectReferencesUnrecorded,
66
OBJECT_REFERENCE_UNKNOWN,
77
OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
8-
USER_FIELD_FOREIGN_REFERENCE,
98
} from './validate-object-references.js';
10-
import { Field, ObjectSchema, referenceTargetOf } from '@objectstack/spec/data';
119
import { runAuthoringRules, splitBySeverity } from './authoring-rules.js';
1210
import { PLATFORM_OBJECT_PREFIXES } from '@objectstack/spec/system';
1311
import { explainRule } from './rule-explanations.js';
@@ -706,104 +704,20 @@ describe('[#19289] validateObjectReferences — a `user` target comes from the T
706704
expect(findings[0].rule).toBe(OBJECT_REFERENCE_UNKNOWN);
707705
});
708706

709-
it('control: the rule still REPORTS a real miss — an explicit carrier naming an unknown object errors', () => {
707+
it('control: the rule still REPORTS a real miss — a `lookup` naming an unknown object errors', () => {
710708
// Without this, "no findings" above reads equally well as a rule that
711-
// 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.
714-
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());
709+
// stopped judging fields altogether. [#24952] A `user` field can no longer
710+
// carry the miss: its target is the type's constant whatever the carrier
711+
// says (`FieldSchema` refuses a foreign name), so the same field leg is
712+
// proven on a `lookup`, and a foreign carrier on `user` resolves to
713+
// `sys_user` and draws nothing.
714+
const lookup = validateObjectReferences(userField({ type: 'lookup', reference: 'zzz_nope' }));
715+
expect(lookup).toHaveLength(1);
716+
expect(lookup[0].rule).toBe(OBJECT_REFERENCE_UNKNOWN);
717+
expect(validateObjectReferences(userField({ reference: 'zzz_nope' }))).toEqual([]);
717718
});
718719
});
719720

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));
745-
expect(findings).toHaveLength(1);
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-
]);
762-
});
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-
}
805-
});
806-
807721
/**
808722
* [#20216] A view CONTAINER's own `object` — the key `getViewsByObject()`
809723
* indexes by. Modelled on an `os init -t app` project (namespace `my_app`),

0 commit comments

Comments
 (0)