Skip to content

Commit 9b3d9db

Browse files
committed
wip: tree reference self-only (#14892) — refinement, predicate, form text, showcase, docs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
1 parent 0467941 commit 9b3d9db

12 files changed

Lines changed: 271 additions & 27 deletions

File tree

‎content/docs/data-modeling/field-type-decision-tree.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ flowchart TD
179179
**lookup vs master_detail:** Use `lookup` when the child can exist independently. Use `master_detail` when deleting the parent should delete all children (e.g., order → line items).
180180
</Callout>
181181

182+
<Callout type="info">
183+
**`tree` and `reference`:** on a `tree` field, `reference` is optional and, if given, must be this object — a `tree` that names another object is refused at parse. A link to a different object is a `lookup`.
184+
</Callout>
185+
182186
### File & Media Types
183187

184188
| Type | Use When | Example |

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ Self-referential hierarchy (e.g., categories, org chart).
357357

358358
| Property | Type | Default | Description |
359359
|:---|:---|:---|:---|
360-
| `reference` | `string` | **required** | Same object (self-reference) |
360+
| `reference` | `string` | — | Optional. If given, must be this object (self-reference) — any other object is refused at parse |
361361

362362
```typescript
363363
{ name: 'parent_category', label: 'Parent Category', type: 'tree', reference: 'category' }

‎content/docs/data-modeling/validation-rules.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,9 @@ application-level emptiness check is needed.
294294

295295
| Property | Type | Default | Validation Behavior |
296296
|:---|:---|:---|:---|
297-
| `reference` | `string` | — | **Required.** Self-referencing object name |
297+
| `reference` | `string` | — | Optional. If given, must be this object's own name — a `tree` whose `reference` names any other object is refused at parse (`fields.<field>.reference`); a link to a different object is a `lookup` |
298298

299-
**Default constraints:** Self-referencing lookup for hierarchical structures. Stored and expanded like a `lookup`; the engine does not run a cycle check on write, so a self-reference chain that loops back on itself is not automatically rejected.
299+
**Default constraints:** Self-referencing lookup for hierarchical structures — `reference` is optional and, if given, must be this object. Stored and expanded like a `lookup`; the engine does not run a cycle check on write, so a self-reference chain that loops back on itself is not automatically rejected.
300300

301301
---
302302

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3196,7 +3196,7 @@
31963196
"fixtures": {
31973197
"app": "showcase",
31983198
"requires": [
3199-
"showcase_field_zoo (examples/app-showcase/src/data/objects/field-zoo.object.ts): name text maxLength 200; f_number min 0 max 1000; f_currency scale 2 currencyConfig{precision 2} min 0; f_percent min 0 max 100; f_select {low,medium,high}; f_multiselect {red,green,blue}; f_lookup→showcase_account; f_master_detail→showcase_project; f_tree→showcase_category; f_autonumber (default counter, NO autonumberFormat); f_formula = f_number*f_percent/100",
3199+
"showcase_field_zoo (examples/app-showcase/src/data/objects/field-zoo.object.ts): name text maxLength 200; f_number min 0 max 1000; f_currency scale 2 currencyConfig{precision 2} min 0; f_percent min 0 max 100; f_select {low,medium,high}; f_multiselect {red,green,blue}; f_lookup→showcase_account; f_master_detail→showcase_project; f_tree→showcase_field_zoo (self-reference); f_autonumber (default counter, NO autonumberFormat); f_formula = f_number*f_percent/100",
32003200
"showcase_invoice (invoice.object.ts): status select {draft,sent,paid,void} required; account lookup with lookupFilters status!=churned; total summary(sum showcase_invoice_line.amount) — the summary carrier the zoo lacks"
32013201
],
32023202
"knownGaps": [

‎examples/app-showcase/src/data/objects/field-zoo.object.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import { cel } from '@objectstack/spec';
1414
* declared as raw `{ type, ... }` literals (the field input is
1515
* `Omit<Partial<Field>, 'type'>`, so any valid type string is accepted).
1616
*
17-
* Relationship types (`lookup`, `master_detail`, `tree`) point at the other
18-
* showcase objects so $expand and hierarchy resolution have real targets.
17+
* Relationship types (`lookup`, `master_detail`) point at the other showcase
18+
* objects so $expand has real targets; `tree` points at THIS object — a
19+
* `tree` field's `reference` is optional and, if given, must be the declaring
20+
* object (#14892), so `f_tree` is the zoo's own parent pointer.
1921
*/
2022
export const FieldZoo = ObjectSchema.create({
2123
name: 'showcase_field_zoo',
@@ -105,7 +107,7 @@ export const FieldZoo = ObjectSchema.create({
105107
// see `f_users` below for the half that a fresh boot cannot seed.
106108
f_lookups: Field.lookup('showcase_account', { label: 'Lookup → Accounts (multiple)', multiple: true }),
107109
f_master_detail: Field.masterDetail('showcase_project', { label: 'Master-Detail → Project', required: true }),
108-
f_tree: { type: 'tree', label: 'Tree (self/category)', reference: 'showcase_category' },
110+
f_tree: { type: 'tree', label: 'Tree (self-reference)', reference: 'showcase_field_zoo' },
109111

110112
// ── User (lookup specialized to sys_user) ────────────────────────────
111113
// NOT seeded, and deliberately so: `sys_user` rows are created by SIGN-UP,

‎packages/qa/dogfood/test/field-zoo.matrix.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export const REFERENCE_PLACEHOLDER = 'zoo_reference_id_resolved_at_runtime';
4848
* account has to exist first and the project has to be given its real id. That
4949
* dependency is itself a small proof of #4441 — seeding these in the wrong
5050
* order now fails loudly instead of writing a project that points at nothing.
51+
* `f_tree` is a SELF-reference (#14892: a `tree` field's `reference`, when
52+
* given, must name the declaring object), so its target is another zoo row,
53+
* which in turn needs the zoo's own required `f_master_detail` — the project
54+
* seeded one entry earlier. Last for that reason.
5155
*/
5256
export const REFERENCE_TARGETS: ReadonlyArray<{
5357
field: string;
@@ -72,8 +76,8 @@ export const REFERENCE_TARGETS: ReadonlyArray<{
7276
},
7377
{
7478
field: 'f_tree',
75-
object: 'showcase_category',
76-
body: () => ({ name: 'zoo-ref-category' }),
79+
object: 'showcase_field_zoo',
80+
body: (seeded) => ({ name: 'zoo-ref-parent', f_master_detail: seeded.f_master_detail }),
7781
},
7882
];
7983

‎packages/spec/src/data/field.zod.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1135,10 +1135,19 @@ export const FieldSchema = lazySchema(() => {
11351135
*
11361136
* For `master_detail` fields, the parent record controls the lifecycle of child records
11371137
* (e.g., cascade delete). For `lookup` fields, the reference is a soft link.
1138+
*
1139+
* On a `tree` field the key is OPTIONAL and, when present, must name the
1140+
* declaring object itself (#14892): a hierarchy is parent/child within one
1141+
* object, so the value is a redundant self-annotation. This schema cannot
1142+
* judge it — a field does not know which object declares it — so the
1143+
* refusal of any other target lives on `ObjectSchema` / `ObjectExtensionSchema`
1144+
* (`object.zod.ts`, `refuseForeignTreeReference`), where the own name is known.
11381145
*/
11391146
reference: z.string().optional().describe(
11401147
'Target object name (snake_case) for lookup/master_detail fields. '
1141-
+ 'Required for relationship types. Used by $expand to resolve foreign key IDs into full objects.'
1148+
+ 'Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. '
1149+
+ 'On a `tree` field it is optional and, if given, must be the declaring object\'s own name — '
1150+
+ 'the object schema refuses any other target.'
11421151
),
11431152
/**
11441153
* Polymorphic pointer declaration (ADR-0052 §5 — the ActivityPointer model).

‎packages/spec/src/data/object.form.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,11 @@ export const objectForm = defineForm({
223223
},
224224

225225
// Relational
226-
{ field: 'reference', type: 'text', helpText: 'Target object name', visibleWhen: "data.type in ['lookup','master_detail','tree']" },
226+
// ONE shared row for the three reference-carrying types (#14892):
227+
// the text carries the `tree` rule the schema enforces — optional,
228+
// and if given this object — so the designer never invites the
229+
// foreign target `ObjectSchema` refuses at publish.
230+
{ field: 'reference', type: 'text', helpText: 'Target object name. For a tree field it is optional and, if given, must be this object (a tree is a hierarchy within its own object — link a different object with a lookup)', visibleWhen: "data.type in ['lookup','master_detail','tree']" },
227231
// `lookupFilters`, not `referenceFilter`: an array of
228232
// {field, operator, value} rules, not a CEL string.
229233
{ field: 'lookupFilters', widget: 'json', helpText: 'Filter rules applied to the picker ({field, operator, value})', visibleWhen: "data.type in ['lookup','master_detail']" },

‎packages/spec/src/data/object.zod.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,45 @@ const UNKNOWN_KEY_GUIDANCE: Record<string, string> = {
15491549
'got a table and was instantiable. Remove the key.',
15501550
};
15511551

1552+
/**
1553+
* [#14892 — maintainer ruling 2026-09-05, option A] A `tree` field's
1554+
* `reference`, when present, must name the object that declares it.
1555+
*
1556+
* A hierarchy is parent/child WITHIN one object, and that is what every reader
1557+
* of the type assumes: the tree renderer's parent-pointer auto-detection takes
1558+
* the first `type: 'tree'` field as this object's own parent column, and
1559+
* `deleteBehavior` materialises on `tree` beside `lookup` because a
1560+
* self-referential hierarchy is a relation whose cascade is exactly the
1561+
* intended semantics. `reference` stays OPTIONAL on a `tree` — under this rule
1562+
* it is a redundant self-annotation — so absence is accepted; a value naming
1563+
* any OTHER object is refused here, where the declaring object's name is known
1564+
* (`FieldSchema` never sees it), rather than parsing green and rendering as a
1565+
* parent pointer into a table it does not point at. A link to a different
1566+
* object is a `lookup`. Applied at both doors that carry a field map —
1567+
* `ObjectSchema` (own name = `name`) and `ObjectExtensionSchema` (own name =
1568+
* `extend`) — so an extension cannot merge the shape the object refuses.
1569+
* Pinned in `tree-reference-self-only.test.ts`; the kernel predicate that
1570+
* reads the same rule is `hasDetectableParentField`
1571+
* (`kernel/functional-completeness.ts`).
1572+
*/
1573+
function refuseForeignTreeReference(ownName: unknown, fields: unknown, ctx: z.RefinementCtx): void {
1574+
if (typeof ownName !== 'string' || fields === null || typeof fields !== 'object') return;
1575+
for (const [fieldName, def] of Object.entries(fields as Record<string, unknown>)) {
1576+
if (def === null || typeof def !== 'object') continue;
1577+
const { type, reference } = def as { type?: unknown; reference?: unknown };
1578+
if (type !== 'tree' || reference === undefined || reference === ownName) continue;
1579+
ctx.addIssue({
1580+
code: 'custom',
1581+
path: ['fields', fieldName, 'reference'],
1582+
message:
1583+
`tree field \`${fieldName}\` on object \`${ownName}\` references \`${String(reference)}\`, `
1584+
+ 'but a `tree` field\'s `reference` must name the declaring object itself — a hierarchy is '
1585+
+ 'parent/child within one object. Drop `reference` (it is optional on a `tree`), set it to '
1586+
+ `'${ownName}', or declare a \`lookup\` if a link to a different object was meant.`,
1587+
});
1588+
}
1589+
}
1590+
15521591
// ⚠️ ORDER IS LOAD-BEARING (#5593). This map used to live ~700 lines BELOW
15531592
// `ObjectSchemaBase`, and the error map that reads it was built lazily
15541593
// (`objectUnknownKeyErrorImpl ??= …`) purely to step around the temporal dead
@@ -2321,6 +2360,12 @@ const ObjectSchemaBase = strictObject(
23212360

23222361
// ADR-0010 — runtime protection envelope (internal — set by loader).
23232362
...MetadataProtectionFields,
2363+
}).superRefine((object, ctx) => {
2364+
// [#14892] A `tree` field's `reference`, when present, must be this object's
2365+
// own name — judged here because only the object knows its name. `.superRefine`
2366+
// keeps this a `ZodObject` (zod 4 attaches checks in place), so `.shape` and
2367+
// `create()`'s unknown-key walk are untouched; see the helper's docblock.
2368+
refuseForeignTreeReference(object.name, object.fields, ctx);
23242369
});
23252370

23262371
/**
@@ -3082,6 +3127,10 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({
30823127

30833128
/** Merge priority. Higher number applied later (wins on conflict). Default: 200 */
30843129
priority: z.number().int().min(0).max(999).default(200).describe('Merge priority (higher = applied later)'),
3130+
}).superRefine((extension, ctx) => {
3131+
// [#14892] The same rule as on `ObjectSchema`: the fields merge into
3132+
// `extend`, so that is the object a `tree` field's `reference` must name.
3133+
refuseForeignTreeReference(extension.extend, extension.fields, ctx);
30853134
}));
30863135

30873136
export type ObjectExtension = z.input<typeof ObjectExtensionSchema>;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { ObjectSchema, ObjectExtensionSchema } from './object.zod';
5+
import { FieldSchema } from './field.zod';
6+
import { classifyDottedFilterHead } from './filter-dotted-head';
7+
8+
// ---------------------------------------------------------------------------
9+
// [#14892] A `tree` field's `reference`, when present, must name the declaring
10+
// object — maintainer ruling 2026-09-05, option A. Four surfaces used to answer
11+
// "what does a tree's `reference` mean" four different ways and nothing read or
12+
// refused any of them; this file pins the ONE answer at the door that can judge
13+
// it (the object schema, where the own name is known) and the shape it leaves
14+
// alone (the field schema, which never sees a name).
15+
//
16+
// The pins bear weight in three directions: the two accepted shapes (self, and
17+
// absent — absent stays `relation` and still materialises `deleteBehavior`, the
18+
// fifth reading the ruling folds in), the refused shape with its located issue,
19+
// and the field-level door that deliberately does NOT refuse.
20+
// ---------------------------------------------------------------------------
21+
22+
const zoo = (reference?: string) => ({
23+
name: 'showcase_field_zoo',
24+
fields: {
25+
name: { type: 'text', label: 'Name' },
26+
f_tree: { type: 'tree', label: 'Tree', ...(reference === undefined ? {} : { reference }) },
27+
},
28+
});
29+
30+
const firstSentence = (message: string): string => message.split(/\.\s/)[0];
31+
32+
describe('[#14892] a `tree` field\'s `reference` must name the declaring object', () => {
33+
it('accepts a self-reference through the object schema, and through create()', () => {
34+
const parsed = ObjectSchema.safeParse(zoo('showcase_field_zoo'));
35+
expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true);
36+
if (!parsed.success) return;
37+
expect(parsed.data.fields.f_tree.reference).toBe('showcase_field_zoo');
38+
// The relational family's delete semantics: a self-referential hierarchy
39+
// is a relation and its cascade default is exactly the intended meaning.
40+
expect(parsed.data.fields.f_tree.deleteBehavior).toBe('set_null');
41+
42+
const created = ObjectSchema.create(zoo('showcase_field_zoo') as never);
43+
expect(created.fields.f_tree.reference).toBe('showcase_field_zoo');
44+
});
45+
46+
it('accepts an ABSENT reference — a redundant self-annotation may be omitted — and it is still a relation', () => {
47+
const parsed = ObjectSchema.safeParse(zoo());
48+
expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true);
49+
if (!parsed.success) return;
50+
expect(parsed.data.fields.f_tree.reference).toBeUndefined();
51+
// The fifth reading (folded from #13928): a reference-less `tree` is
52+
// classified `relation` and still materialises `deleteBehavior` beside
53+
// `lookup` — coherent under this rule, because the hierarchy it declares
54+
// is self-referential by definition.
55+
expect(parsed.data.fields.f_tree.deleteBehavior).toBe('set_null');
56+
expect(classifyDottedFilterHead({ type: 'tree' })).toBe('relation');
57+
});
58+
59+
it('refuses a `tree` naming ANOTHER object, at the field\'s `reference`, naming both objects', () => {
60+
const parsed = ObjectSchema.safeParse(zoo('showcase_category'));
61+
expect(parsed.success).toBe(false);
62+
if (parsed.success) return;
63+
expect(parsed.error.issues).toHaveLength(1);
64+
const [issue] = parsed.error.issues;
65+
expect(issue.code).toBe('custom');
66+
expect(issue.path).toEqual(['fields', 'f_tree', 'reference']);
67+
// The message's first sentence names the declaring object AND the object
68+
// the field wrongly points at — the envelope an author greps for.
69+
const first = firstSentence(issue.message);
70+
expect(first).toContain('`showcase_field_zoo`');
71+
expect(first).toContain('`showcase_category`');
72+
expect(first).toContain('`f_tree`');
73+
// The remedy travels with the refusal: drop it, self-reference, or lookup.
74+
expect(issue.message).toContain("'showcase_field_zoo'");
75+
expect(issue.message).toContain('`lookup`');
76+
77+
// The authoring door throws the same located issue.
78+
expect(() => ObjectSchema.create(zoo('showcase_category') as never)).toThrow(/showcase_category/);
79+
});
80+
81+
it('judges each `tree` field on its own: one foreign pointer beside a self-reference is one issue', () => {
82+
const parsed = ObjectSchema.safeParse({
83+
name: 'category',
84+
fields: {
85+
name: { type: 'text', label: 'Name' },
86+
parent: { type: 'tree', label: 'Parent', reference: 'category' },
87+
stray: { type: 'tree', label: 'Stray', reference: 'department' },
88+
},
89+
});
90+
expect(parsed.success).toBe(false);
91+
if (parsed.success) return;
92+
expect(parsed.error.issues.map((i) => i.path)).toEqual([['fields', 'stray', 'reference']]);
93+
});
94+
95+
it('is scoped to `tree`: a lookup / master_detail to another object is untouched (control)', () => {
96+
const parsed = ObjectSchema.safeParse({
97+
name: 'showcase_field_zoo',
98+
fields: {
99+
name: { type: 'text', label: 'Name' },
100+
f_lookup: { type: 'lookup', label: 'Account', reference: 'showcase_account' },
101+
f_master_detail: { type: 'master_detail', label: 'Project', reference: 'showcase_project' },
102+
},
103+
});
104+
expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true);
105+
});
106+
107+
it('the field schema alone does NOT refuse — it never learns the declaring object\'s name', () => {
108+
// Where the rule lives is part of the contract: `FieldSchema` cannot judge
109+
// a foreign target, so `field.test.ts`'s field-level tree fixtures keep
110+
// parsing and the refusal is the object door's alone.
111+
const parsed = FieldSchema.safeParse({ name: 'parent_id', label: 'Parent', type: 'tree', reference: 'category' });
112+
expect(parsed.success).toBe(true);
113+
});
114+
115+
it('the extension door judges against the object it extends', () => {
116+
const self = ObjectExtensionSchema.safeParse({
117+
extend: 'contact',
118+
fields: { parent_contact: { type: 'tree', label: 'Parent', reference: 'contact' } },
119+
});
120+
expect(self.success, self.success ? '' : JSON.stringify(self.error.issues)).toBe(true);
121+
122+
const foreign = ObjectExtensionSchema.safeParse({
123+
extend: 'contact',
124+
fields: { parent_contact: { type: 'tree', label: 'Parent', reference: 'company' } },
125+
});
126+
expect(foreign.success).toBe(false);
127+
if (foreign.success) return;
128+
expect(foreign.error.issues.map((i) => i.path)).toEqual([['fields', 'parent_contact', 'reference']]);
129+
expect(firstSentence(foreign.error.issues[0].message)).toContain('`contact`');
130+
expect(firstSentence(foreign.error.issues[0].message)).toContain('`company`');
131+
});
132+
});

0 commit comments

Comments
 (0)