diff --git a/.changeset/tree-reference-self-only.md b/.changeset/tree-reference-self-only.md new file mode 100644 index 0000000000..a5eceee52d --- /dev/null +++ b/.changeset/tree-reference-self-only.md @@ -0,0 +1,55 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: a `tree` field's `reference`, when present, must name the declaring object — any other target is refused at parse (#14892) + + + +**BREAKING** in the accept-set sense, landing in the launch window as `minor` +(the lockstep convention). Maintainer ruling 2026-09-05 on #14892, option A. + +**What changes.** `ObjectSchema` (and `ObjectExtensionSchema`, judged against +the object it extends) now refuses a field declared `type: 'tree'` whose +`reference` names any object other than the declaring one. The refusal is a +located parse issue at `fields..reference` whose message names both +objects and the three ways out: drop `reference` (it is optional on a `tree`), +name the object itself, or declare a `lookup` if a link to a different object +was meant. `FieldSchema` alone is unchanged — a field does not know which +object declares it, so the judgment lives on the object door. + +**Why.** A hierarchy is parent/child within one object, and that is what every +reader of the type already assumed: the tree renderer's parent-pointer +auto-detection takes the first `tree` field as the object's own parent column, +four prose surfaces said self-reference, and `deleteBehavior` materialises on +`tree` beside `lookup` because a self-referential hierarchy is a relation whose +cascade is exactly the intended semantics. The designer's shared `reference` +input reused one "Target object name" help text for three types, and the one +shipped `tree` example pointed at another object under a hedging label — two +spellings parsed silently, and an example taught a third. The key is now +enforced with one meaning; `reference` stays optional on a `tree` as a +redundant self-annotation, which is also what makes a reference-less `tree` +being classified `relation` (and materialising `deleteBehavior`) coherent. + +**Alongside.** `checkViewCompleteness`'s parent-pointer predicate reads the +same rule: a `tree` field is a detectable parent pointer only when its +`reference` is absent or the object's own name, so a `tree` view bound to an +object whose only `tree` field points elsewhere is reported `view/tree-without- +parent-field` rather than blessed. The designer help text for the shared +`reference` row now says so for `tree`, the showcase `showcase_field_zoo.f_tree` +is a self-reference, and the data-modeling docs say "optional and, if given, +must be this object". + +```ts +// accepted — a self-reference, or no reference at all +parent: { type: 'tree', reference: 'category' } +parent: { type: 'tree' } +// refused at parse — `fields.parent.reference` on object `category` +parent: { type: 'tree', reference: 'department' } +``` + +**Not measured.** Out-of-repo cross-object trees are NOT MEASURED: no customer +application was surveyed for a `tree` field pointing at a different object. +In-repo, every other `tree` author is a self-reference or carries no +`reference`; the objectui pin's unit fixtures are outside this schema's reach +and are listed on the card. diff --git a/content/docs/data-modeling/field-type-decision-tree.mdx b/content/docs/data-modeling/field-type-decision-tree.mdx index 8f4b9241db..3bb3a692bb 100644 --- a/content/docs/data-modeling/field-type-decision-tree.mdx +++ b/content/docs/data-modeling/field-type-decision-tree.mdx @@ -179,6 +179,10 @@ flowchart TD **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). + +**`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`. + + ### File & Media Types | Type | Use When | Example | diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index dc11bfc480..93fe74728c 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -357,7 +357,7 @@ Self-referential hierarchy (e.g., categories, org chart). | Property | Type | Default | Description | |:---|:---|:---|:---| -| `reference` | `string` | **required** | Same object (self-reference) | +| `reference` | `string` | — | Optional. If given, must be this object (self-reference) — any other object is refused at parse | ```typescript { name: 'parent_category', label: 'Parent Category', type: 'tree', reference: 'category' } diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index f536a08383..b2d1f90ecd 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -294,9 +294,9 @@ application-level emptiness check is needed. | Property | Type | Default | Validation Behavior | |:---|:---|:---|:---| -| `reference` | `string` | — | **Required.** Self-referencing object name | +| `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..reference`); a link to a different object is a `lookup` | -**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. +**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. --- diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 855f2cd26e..a55cce40af 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -47,7 +47,7 @@ nothing to do with elevation. | Declaration | What it is | This page? | |:---|:---|:---:| | `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ | -| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | +| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1634` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | | `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ | | `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ | @@ -196,7 +196,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:743` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | | "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10306`–`10323` | -| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1581` (#3493 / #6640) | +| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1553`, `:1582`; `domains/actions.ts:414` | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 15424f1d37..da4beff2da 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -75,7 +75,7 @@ const result = CurrencyConfigSchema.parse(data); | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ea105ef57e..6d89ca2b20 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -237,7 +237,7 @@ const result = ApiMethod.parse(data); | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | @@ -569,7 +569,7 @@ const result = ApiMethod.parse(data); | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index a3ecaff540..064b40dccf 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -75,7 +75,7 @@ Add a new field to an existing object | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | @@ -494,7 +494,7 @@ Add a new field to an existing object | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index c0c4b9ed03..1924808b3d 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -3196,7 +3196,7 @@ "fixtures": { "app": "showcase", "requires": [ - "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", + "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", "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" ], "knownGaps": [ diff --git a/examples/app-showcase/src/data/objects/field-zoo.object.ts b/examples/app-showcase/src/data/objects/field-zoo.object.ts index 76120013cd..8e75331cb9 100644 --- a/examples/app-showcase/src/data/objects/field-zoo.object.ts +++ b/examples/app-showcase/src/data/objects/field-zoo.object.ts @@ -14,8 +14,10 @@ import { cel } from '@objectstack/spec'; * declared as raw `{ type, ... }` literals (the field input is * `Omit, 'type'>`, so any valid type string is accepted). * - * Relationship types (`lookup`, `master_detail`, `tree`) point at the other - * showcase objects so $expand and hierarchy resolution have real targets. + * Relationship types (`lookup`, `master_detail`) point at the other showcase + * objects so $expand has real targets; `tree` points at THIS object — a + * `tree` field's `reference` is optional and, if given, must be the declaring + * object (#14892), so `f_tree` is the zoo's own parent pointer. */ export const FieldZoo = ObjectSchema.create({ name: 'showcase_field_zoo', @@ -105,7 +107,7 @@ export const FieldZoo = ObjectSchema.create({ // see `f_users` below for the half that a fresh boot cannot seed. f_lookups: Field.lookup('showcase_account', { label: 'Lookup → Accounts (multiple)', multiple: true }), f_master_detail: Field.masterDetail('showcase_project', { label: 'Master-Detail → Project', required: true }), - f_tree: { type: 'tree', label: 'Tree (self/category)', reference: 'showcase_category' }, + f_tree: { type: 'tree', label: 'Tree (self-reference)', reference: 'showcase_field_zoo' }, // ── User (lookup specialized to sys_user) ──────────────────────────── // NOT seeded, and deliberately so: `sys_user` rows are created by SIGN-UP, diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 9d15075343..951088c3e3 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -162,7 +162,7 @@ export const enMetadataForms: NonNullable = { }, "fields.reference": { label: "Reference", - helpText: "Target object name" + 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)" }, "fields.lookupFilters": { label: "Lookup Filters", diff --git a/packages/qa/dogfood/test/field-zoo.matrix.ts b/packages/qa/dogfood/test/field-zoo.matrix.ts index d41326b83c..ecf2e17cf0 100644 --- a/packages/qa/dogfood/test/field-zoo.matrix.ts +++ b/packages/qa/dogfood/test/field-zoo.matrix.ts @@ -48,6 +48,10 @@ export const REFERENCE_PLACEHOLDER = 'zoo_reference_id_resolved_at_runtime'; * account has to exist first and the project has to be given its real id. That * dependency is itself a small proof of #4441 — seeding these in the wrong * order now fails loudly instead of writing a project that points at nothing. + * `f_tree` is a SELF-reference (#14892: a `tree` field's `reference`, when + * given, must name the declaring object), so its target is another zoo row, + * which in turn needs the zoo's own required `f_master_detail` — the project + * seeded one entry earlier. Last for that reason. */ export const REFERENCE_TARGETS: ReadonlyArray<{ field: string; @@ -72,8 +76,8 @@ export const REFERENCE_TARGETS: ReadonlyArray<{ }, { field: 'f_tree', - object: 'showcase_category', - body: () => ({ name: 'zoo-ref-category' }), + object: 'showcase_field_zoo', + body: (seeded) => ({ name: 'zoo-ref-parent', f_master_detail: seeded.f_master_detail }), }, ]; diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 63dbc4836b..9f90c8e11d 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1135,10 +1135,19 @@ export const FieldSchema = lazySchema(() => { * * For `master_detail` fields, the parent record controls the lifecycle of child records * (e.g., cascade delete). For `lookup` fields, the reference is a soft link. + * + * On a `tree` field the key is OPTIONAL and, when present, must name the + * declaring object itself (#14892): a hierarchy is parent/child within one + * object, so the value is a redundant self-annotation. This schema cannot + * judge it — a field does not know which object declares it — so the + * refusal of any other target lives on `ObjectSchema` / `ObjectExtensionSchema` + * (`object.zod.ts`, `refuseForeignTreeReference`), where the own name is known. */ reference: z.string().optional().describe( 'Target object name (snake_case) for lookup/master_detail fields. ' - + 'Required for relationship types. Used by $expand to resolve foreign key IDs into full objects.' + + 'Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. ' + + 'On a `tree` field it is optional and, if given, must be the declaring object\'s own name — ' + + 'the object schema refuses any other target.' ), /** * Polymorphic pointer declaration (ADR-0052 §5 — the ActivityPointer model). diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index aeb00065ca..df90385d8e 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -223,7 +223,11 @@ export const objectForm = defineForm({ }, // Relational - { field: 'reference', type: 'text', helpText: 'Target object name', visibleWhen: "data.type in ['lookup','master_detail','tree']" }, + // ONE shared row for the three reference-carrying types (#14892): + // the text carries the `tree` rule the schema enforces — optional, + // and if given this object — so the designer never invites the + // foreign target `ObjectSchema` refuses at publish. + { 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']" }, // `lookupFilters`, not `referenceFilter`: an array of // {field, operator, value} rules, not a CEL string. { field: 'lookupFilters', widget: 'json', helpText: 'Filter rules applied to the picker ({field, operator, value})', visibleWhen: "data.type in ['lookup','master_detail']" }, diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 2d83dd0f5c..9012ca3698 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1549,6 +1549,45 @@ const UNKNOWN_KEY_GUIDANCE: Record = { 'got a table and was instantiable. Remove the key.', }; +/** + * [#14892 — maintainer ruling 2026-09-05, option A] A `tree` field's + * `reference`, when present, must name the object that declares it. + * + * A hierarchy is parent/child WITHIN one object, and that is what every reader + * of the type assumes: the tree renderer's parent-pointer auto-detection takes + * the first `type: 'tree'` field as this object's own parent column, and + * `deleteBehavior` materialises on `tree` beside `lookup` because a + * self-referential hierarchy is a relation whose cascade is exactly the + * intended semantics. `reference` stays OPTIONAL on a `tree` — under this rule + * it is a redundant self-annotation — so absence is accepted; a value naming + * any OTHER object is refused here, where the declaring object's name is known + * (`FieldSchema` never sees it), rather than parsing green and rendering as a + * parent pointer into a table it does not point at. A link to a different + * object is a `lookup`. Applied at both doors that carry a field map — + * `ObjectSchema` (own name = `name`) and `ObjectExtensionSchema` (own name = + * `extend`) — so an extension cannot merge the shape the object refuses. + * Pinned in `tree-reference-self-only.test.ts`; the kernel predicate that + * reads the same rule is `hasDetectableParentField` + * (`kernel/functional-completeness.ts`). + */ +function refuseForeignTreeReference(ownName: unknown, fields: unknown, ctx: z.RefinementCtx): void { + if (typeof ownName !== 'string' || fields === null || typeof fields !== 'object') return; + for (const [fieldName, def] of Object.entries(fields as Record)) { + if (def === null || typeof def !== 'object') continue; + const { type, reference } = def as { type?: unknown; reference?: unknown }; + if (type !== 'tree' || reference === undefined || reference === ownName) continue; + ctx.addIssue({ + code: 'custom', + path: ['fields', fieldName, 'reference'], + message: + `tree field \`${fieldName}\` on object \`${ownName}\` references \`${String(reference)}\`, ` + + 'but a `tree` field\'s `reference` must name the declaring object itself — a hierarchy is ' + + 'parent/child within one object. Drop `reference` (it is optional on a `tree`), set it to ' + + `'${ownName}', or declare a \`lookup\` if a link to a different object was meant.`, + }); + } +} + // ⚠️ ORDER IS LOAD-BEARING (#5593). This map used to live ~700 lines BELOW // `ObjectSchemaBase`, and the error map that reads it was built lazily // (`objectUnknownKeyErrorImpl ??= …`) purely to step around the temporal dead @@ -2321,6 +2360,12 @@ const ObjectSchemaBase = strictObject( // ADR-0010 — runtime protection envelope (internal — set by loader). ...MetadataProtectionFields, +}).superRefine((object, ctx) => { + // [#14892] A `tree` field's `reference`, when present, must be this object's + // own name — judged here because only the object knows its name. `.superRefine` + // keeps this a `ZodObject` (zod 4 attaches checks in place), so `.shape` and + // `create()`'s unknown-key walk are untouched; see the helper's docblock. + refuseForeignTreeReference(object.name, object.fields, ctx); }); /** @@ -3082,6 +3127,10 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({ /** Merge priority. Higher number applied later (wins on conflict). Default: 200 */ priority: z.number().int().min(0).max(999).default(200).describe('Merge priority (higher = applied later)'), +}).superRefine((extension, ctx) => { + // [#14892] The same rule as on `ObjectSchema`: the fields merge into + // `extend`, so that is the object a `tree` field's `reference` must name. + refuseForeignTreeReference(extension.extend, extension.fields, ctx); })); export type ObjectExtension = z.input; diff --git a/packages/spec/src/data/tree-reference-self-only.test.ts b/packages/spec/src/data/tree-reference-self-only.test.ts new file mode 100644 index 0000000000..17bcb7a33b --- /dev/null +++ b/packages/spec/src/data/tree-reference-self-only.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { ObjectSchema, ObjectExtensionSchema } from './object.zod'; +import { FieldSchema } from './field.zod'; +import { classifyDottedFilterHead } from './filter-dotted-head'; + +// --------------------------------------------------------------------------- +// [#14892] A `tree` field's `reference`, when present, must name the declaring +// object — maintainer ruling 2026-09-05, option A. Four surfaces used to answer +// "what does a tree's `reference` mean" four different ways and nothing read or +// refused any of them; this file pins the ONE answer at the door that can judge +// it (the object schema, where the own name is known) and the shape it leaves +// alone (the field schema, which never sees a name). +// +// The pins bear weight in three directions: the two accepted shapes (self, and +// absent — absent stays `relation` and still materialises `deleteBehavior`, the +// fifth reading the ruling folds in), the refused shape with its located issue, +// and the field-level door that deliberately does NOT refuse. +// --------------------------------------------------------------------------- + +const zoo = (reference?: string) => ({ + name: 'showcase_field_zoo', + fields: { + name: { type: 'text', label: 'Name' }, + f_tree: { type: 'tree', label: 'Tree', ...(reference === undefined ? {} : { reference }) }, + }, +}); + +const firstSentence = (message: string): string => message.split(/\.\s/)[0]; + +describe('[#14892] a `tree` field\'s `reference` must name the declaring object', () => { + it('accepts a self-reference through the object schema, and through create()', () => { + const parsed = ObjectSchema.safeParse(zoo('showcase_field_zoo')); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); + if (!parsed.success) return; + expect(parsed.data.fields.f_tree.reference).toBe('showcase_field_zoo'); + // The relational family's delete semantics: a self-referential hierarchy + // is a relation and its cascade default is exactly the intended meaning. + expect(parsed.data.fields.f_tree.deleteBehavior).toBe('set_null'); + + // The authoring door, spelled as an author spells it (the literal keeps + // `create()`'s generic inferable, so `fields.f_tree` is typed). + const created = ObjectSchema.create({ + name: 'showcase_field_zoo', + fields: { + name: { type: 'text', label: 'Name' }, + f_tree: { type: 'tree', label: 'Tree', reference: 'showcase_field_zoo' }, + }, + }); + expect(created.fields.f_tree.reference).toBe('showcase_field_zoo'); + }); + + it('accepts an ABSENT reference — a redundant self-annotation may be omitted — and it is still a relation', () => { + const parsed = ObjectSchema.safeParse(zoo()); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); + if (!parsed.success) return; + expect(parsed.data.fields.f_tree.reference).toBeUndefined(); + // The fifth reading (folded from #13928): a reference-less `tree` is + // classified `relation` and still materialises `deleteBehavior` beside + // `lookup` — coherent under this rule, because the hierarchy it declares + // is self-referential by definition. + expect(parsed.data.fields.f_tree.deleteBehavior).toBe('set_null'); + expect(classifyDottedFilterHead({ type: 'tree' })).toBe('relation'); + }); + + it('refuses a `tree` naming ANOTHER object, at the field\'s `reference`, naming both objects', () => { + const parsed = ObjectSchema.safeParse(zoo('showcase_category')); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues).toHaveLength(1); + const [issue] = parsed.error.issues; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['fields', 'f_tree', 'reference']); + // The message's first sentence names the declaring object AND the object + // the field wrongly points at — the envelope an author greps for. + const first = firstSentence(issue.message); + expect(first).toContain('`showcase_field_zoo`'); + expect(first).toContain('`showcase_category`'); + expect(first).toContain('`f_tree`'); + // The remedy travels with the refusal: drop it, self-reference, or lookup. + expect(issue.message).toContain("'showcase_field_zoo'"); + expect(issue.message).toContain('`lookup`'); + + // The authoring door throws the same located issue. + expect(() => ObjectSchema.create({ + name: 'showcase_field_zoo', + fields: { + name: { type: 'text', label: 'Name' }, + f_tree: { type: 'tree', label: 'Tree', reference: 'showcase_category' }, + }, + })).toThrow(/showcase_category/); + }); + + it('judges each `tree` field on its own: one foreign pointer beside a self-reference is one issue', () => { + const parsed = ObjectSchema.safeParse({ + name: 'category', + fields: { + name: { type: 'text', label: 'Name' }, + parent: { type: 'tree', label: 'Parent', reference: 'category' }, + stray: { type: 'tree', label: 'Stray', reference: 'department' }, + }, + }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.map((i) => i.path)).toEqual([['fields', 'stray', 'reference']]); + }); + + it('is scoped to `tree`: a lookup / master_detail to another object is untouched (control)', () => { + const parsed = ObjectSchema.safeParse({ + name: 'showcase_field_zoo', + fields: { + name: { type: 'text', label: 'Name' }, + f_lookup: { type: 'lookup', label: 'Account', reference: 'showcase_account' }, + f_master_detail: { type: 'master_detail', label: 'Project', reference: 'showcase_project' }, + }, + }); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); + }); + + it('the field schema alone does NOT refuse — it never learns the declaring object\'s name', () => { + // Where the rule lives is part of the contract: `FieldSchema` cannot judge + // a foreign target, so `field.test.ts`'s field-level tree fixtures keep + // parsing and the refusal is the object door's alone. + const parsed = FieldSchema.safeParse({ name: 'parent_id', label: 'Parent', type: 'tree', reference: 'category' }); + expect(parsed.success).toBe(true); + }); + + it('the extension door judges against the object it extends', () => { + const self = ObjectExtensionSchema.safeParse({ + extend: 'contact', + fields: { parent_contact: { type: 'tree', label: 'Parent', reference: 'contact' } }, + }); + expect(self.success, self.success ? '' : JSON.stringify(self.error.issues)).toBe(true); + + const foreign = ObjectExtensionSchema.safeParse({ + extend: 'contact', + fields: { parent_contact: { type: 'tree', label: 'Parent', reference: 'company' } }, + }); + expect(foreign.success).toBe(false); + if (foreign.success) return; + expect(foreign.error.issues.map((i) => i.path)).toEqual([['fields', 'parent_contact', 'reference']]); + expect(firstSentence(foreign.error.issues[0].message)).toContain('`contact`'); + expect(firstSentence(foreign.error.issues[0].message)).toContain('`company`'); + }); +}); diff --git a/packages/spec/src/kernel/functional-completeness.test.ts b/packages/spec/src/kernel/functional-completeness.test.ts index e4bdd2c942..47a7d330c4 100644 --- a/packages/spec/src/kernel/functional-completeness.test.ts +++ b/packages/spec/src/kernel/functional-completeness.test.ts @@ -185,8 +185,11 @@ describe('checkViewCompleteness — the tree parent pointer (the silent-flat hal // A `tree: {}` block satisfies the binding-block table (every key is // optional) and still renders flat on an object with no self-reference — // the shape a block-presence gate would vouch for. This rule is the second - // check the triage asked for, and it mirrors objectui's `detectParentField` - // exactly: `type: 'tree'`, else a lookup / master_detail back to the object. + // check the triage asked for. Its `lookup` / `master_detail` arm mirrors + // objectui's `detectParentField` (a reference back to the object); its + // `tree` arm reads the #14892 rule the parse door enforces — a `tree` field + // with no `reference`, or one naming this object — which is stricter than + // the renderer's "any `tree` field" (see the predicate's docblock). const flatObject = { name: 'business_unit', fields: { name: { type: 'text' }, manager: { type: 'lookup', reference: 'sys_user' } }, @@ -232,6 +235,31 @@ describe('checkViewCompleteness — the tree parent pointer (the silent-flat hal })).toEqual([]); }); + // [#14892] The `tree` arm reads the rule the object schema enforces: a + // `tree` field's `reference`, when present, must name the declaring object. + it('a `tree` field naming THIS object is a parent pointer — silent', () => { + expect(checkViewCompleteness({ type: 'tree', tree: {} }, { + name: 'category', + fields: { name: { type: 'text' }, parent: { type: 'tree', reference: 'category' } }, + })).toEqual([]); + }); + + it('a `tree` field naming ANOTHER object is not a parent pointer — flagged, as the parse door refuses it', () => { + expect(rulesOf({ type: 'tree', tree: {} }, { + name: 'showcase_field_zoo', + fields: { name: { type: 'text' }, f_tree: { type: 'tree', reference: 'showcase_category' } }, + })).toEqual([VIEW_TREE_WITHOUT_PARENT_FIELD]); + }); + + it('a `tree` field with no `reference` is a parent pointer even on a nameless object; one WITH a reference cannot be matched there', () => { + expect(checkViewCompleteness({ type: 'tree', tree: {} }, { + fields: { parent: { type: 'tree' } }, + })).toEqual([]); + expect(rulesOf({ type: 'tree', tree: {} }, { + fields: { parent: { type: 'tree', reference: 'category' } }, + })).toEqual([VIEW_TREE_WITHOUT_PARENT_FIELD]); + }); + it.each(['lookup', 'master_detail'])('is silent when the object carries a %s back to itself', (type) => { expect(checkViewCompleteness({ type: 'tree', tree: {} }, { name: 'business_unit', diff --git a/packages/spec/src/kernel/functional-completeness.ts b/packages/spec/src/kernel/functional-completeness.ts index a8201feeb1..f8e42912e9 100644 --- a/packages/spec/src/kernel/functional-completeness.ts +++ b/packages/spec/src/kernel/functional-completeness.ts @@ -302,22 +302,33 @@ function fieldDefsOf(object: AnyRec): AnyRec[] { } /** - * Whether the tree renderer could auto-detect a parent pointer on this object - * — a mirror of objectui's `detectParentField` - * (`packages/plugin-tree/src/ObjectTree.tsx`): a field declared - * `type: 'tree'`, else a `lookup` / `master_detail` whose `reference` is the - * object's own name. Mirrored, not tightened: a stricter predicate here would - * warn about a view that renders correctly, a looser one would bless the flat - * render. The renderer also reads `reference_to`; that is the retired spelling - * the ADR-0087 conversion layer folds to `reference` before this predicate - * ever sees the stack, so it needs no arm here. An object with no `name` - * cannot be self-referenced — the renderer's detection needs the object name - * for the lookup arm too. + * Whether the tree renderer could auto-detect a parent pointer on this object: + * a field declared `type: 'tree'` whose `reference` is absent or the object's + * own name, else a `lookup` / `master_detail` whose `reference` is the object's + * own name. + * + * The `tree` arm reads the rule `ObjectSchema` enforces at parse (#14892 + * ruling: a `tree` field's `reference`, when present, must name the declaring + * object — a hierarchy is parent/child within one object), so an unparsed + * object carrying a foreign-referencing `tree` is judged here exactly as the + * parse door judges it: not a parent pointer. On that arm this predicate is + * STRICTER than objectui's `detectParentField` + * (`packages/plugin-tree/src/ObjectTree.tsx`), which still returns the first + * `tree` field whatever its `reference` says; tightening the renderer is an + * objectui follow-up, and the shape the two now disagree on no longer parses + * here, so the disagreement is unreachable from parsed metadata. The + * `lookup` / `master_detail` arm is the renderer's, unchanged. The renderer + * also reads `reference_to`; that is the retired spelling the ADR-0087 + * conversion layer folds to `reference` before this predicate ever sees the + * stack, so it needs no arm here. An object with no `name` cannot be + * self-referenced — the renderer's detection needs the object name for the + * lookup arm too, and a `tree` that names a `reference` cannot be matched + * against a name that is not there. */ function hasDetectableParentField(object: AnyRec): boolean { const own = isNonEmptyString(object.name) ? object.name : undefined; return fieldDefsOf(object).some((def) => - def.type === 'tree' + (def.type === 'tree' && (def.reference === undefined || (own !== undefined && def.reference === own))) || ((def.type === 'lookup' || def.type === 'master_detail') && own !== undefined && def.reference === own)); } @@ -456,8 +467,9 @@ export function checkViewCompleteness(view: unknown, boundObject?: unknown): Com path: 'tree.parentField', message: 'A `tree` view with no resolvable parent pointer renders FLAT, not empty: `parentField` is ' - + 'undeclared and the bound object declares neither a `tree` field nor a lookup/master_detail ' - + 'back to itself, so the renderer\'s auto-detection finds nothing (objectui `ObjectTree.tsx` — ' + + 'undeclared and the bound object declares neither a `tree` field (with no `reference`, or one ' + + 'naming this object) nor a lookup/master_detail back to itself, so the renderer\'s ' + + 'auto-detection finds nothing (objectui `ObjectTree.tsx` — ' + '`detectParentField`) and `buildForest` makes every record a root at depth 0. The result is ' + 'a complete, correct-looking table with an expand slot that never opens, while authoring ' + 'reports success. Declare `tree.parentField`, or add a self-referencing field to the object.',