Skip to content

Commit fb77aa5

Browse files
claude[bot]claude
andauthored
feat(spec)!: a tree field's reference, when present, must name the declaring object — refused at parse otherwise (#14892) (#15979)
* 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 * wip: pins + changeset for the tree reference rule (#14892) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * wip: typed create() pins, regenerated reference docs, census re-anchor (#14892) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * chore(platform-objects): regenerate the en metadata-forms bundle for the shared reference row's help text (#14892) node scripts/check-i18n-bundles.mjs --write — the only bundle that moved; the `reference` describe lives in no bundle. Clears the red `pnpm check:i18n` on PR #15979's head b6f0313 (Type Check · consumer gates). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * chore: merge hand-off — main's side of the os-regen artifacts, system-context census regenerated on the merged tree (#14892) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --------- Co-authored-by: claude <noreply@anthropic.com>
1 parent 3116d24 commit fb77aa5

18 files changed

Lines changed: 348 additions & 35 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: a `tree` field's `reference`, when present, must name the declaring object — any other target is refused at parse (#14892)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `reference` keeps its name, its type and its optionality on a `tree` field, and every self-referencing or reference-less `tree` parses byte-identically to before. What is newly refused is a `tree` whose `reference` names a different object — a shape no prose surface ever documented, that no runtime reader consumed as a cross-object link, and whose one in-repo author (the showcase field zoo) hedged in its own label. The remedy is authoring intent, not a mechanical rewrite: the author decides whether the field is this object's own hierarchy (a self-reference, or no `reference` at all) or a link to another object (a `lookup`), so `objectstack migrate meta` has nothing to rewrite and this changeset carries no rewrite instructions. -->
8+
9+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
10+
(the lockstep convention). Maintainer ruling 2026-09-05 on #14892, option A.
11+
12+
**What changes.** `ObjectSchema` (and `ObjectExtensionSchema`, judged against
13+
the object it extends) now refuses a field declared `type: 'tree'` whose
14+
`reference` names any object other than the declaring one. The refusal is a
15+
located parse issue at `fields.<field>.reference` whose message names both
16+
objects and the three ways out: drop `reference` (it is optional on a `tree`),
17+
name the object itself, or declare a `lookup` if a link to a different object
18+
was meant. `FieldSchema` alone is unchanged — a field does not know which
19+
object declares it, so the judgment lives on the object door.
20+
21+
**Why.** A hierarchy is parent/child within one object, and that is what every
22+
reader of the type already assumed: the tree renderer's parent-pointer
23+
auto-detection takes the first `tree` field as the object's own parent column,
24+
four prose surfaces said self-reference, and `deleteBehavior` materialises on
25+
`tree` beside `lookup` because a self-referential hierarchy is a relation whose
26+
cascade is exactly the intended semantics. The designer's shared `reference`
27+
input reused one "Target object name" help text for three types, and the one
28+
shipped `tree` example pointed at another object under a hedging label — two
29+
spellings parsed silently, and an example taught a third. The key is now
30+
enforced with one meaning; `reference` stays optional on a `tree` as a
31+
redundant self-annotation, which is also what makes a reference-less `tree`
32+
being classified `relation` (and materialising `deleteBehavior`) coherent.
33+
34+
**Alongside.** `checkViewCompleteness`'s parent-pointer predicate reads the
35+
same rule: a `tree` field is a detectable parent pointer only when its
36+
`reference` is absent or the object's own name, so a `tree` view bound to an
37+
object whose only `tree` field points elsewhere is reported `view/tree-without-
38+
parent-field` rather than blessed. The designer help text for the shared
39+
`reference` row now says so for `tree`, the showcase `showcase_field_zoo.f_tree`
40+
is a self-reference, and the data-modeling docs say "optional and, if given,
41+
must be this object".
42+
43+
```ts
44+
// accepted — a self-reference, or no reference at all
45+
parent: { type: 'tree', reference: 'category' }
46+
parent: { type: 'tree' }
47+
// refused at parse — `fields.parent.reference` on object `category`
48+
parent: { type: 'tree', reference: 'department' }
49+
```
50+
51+
**Not measured.** Out-of-repo cross-object trees are NOT MEASURED: no customer
52+
application was surveyed for a `tree` field pointing at a different object.
53+
In-repo, every other `tree` author is a self-reference or carries no
54+
`reference`; the objectui pin's unit fixtures are outside this schema's reach
55+
and are listed on the card.

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

content/docs/permissions/system-context.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ nothing to do with elevation.
4747
| Declaration | What it is | This page? |
4848
|:---|:---|:---:|
4949
| `ExecutionContext.isSystem``packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context ||
50-
| `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) ||
50+
| `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) ||
5151
| `EmailTemplate.isSystem``packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete ||
5252
| `Environment.isSystem``packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data ||
5353

@@ -196,7 +196,7 @@ assuming `isSystem` covers it is a documented source of bugs.
196196
| "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` |
197197
| "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) |
198198
| "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` |
199-
| "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) |
199+
| "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) |
200200
| "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` |
201201
| "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` |
202202
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1565`, `:1594`; `domains/actions.ts:414` |

content/docs/references/data/field.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const result = CurrencyConfigSchema.parse(data);
7575
| **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. |
7676
| **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. |
7777
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
78-
| **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. |
78+
| **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. |
7979
| **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. |
8080
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
8181
| **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. |

content/docs/references/data/object.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ const result = ApiMethod.parse(data);
237237
| **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. |
238238
| **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. |
239239
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
240-
| **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. |
240+
| **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. |
241241
| **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. |
242242
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
243243
| **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);
569569
| **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. |
570570
| **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. |
571571
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
572-
| **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. |
572+
| **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. |
573573
| **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. |
574574
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
575575
| **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. |

content/docs/references/system/migration.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Add a new field to an existing object
7575
| **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. |
7676
| **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. |
7777
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
78-
| **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. |
78+
| **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. |
7979
| **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. |
8080
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
8181
| **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
494494
| **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. |
495495
| **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. |
496496
| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for select/multiselect |
497-
| **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. |
497+
| **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. |
498498
| **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. |
499499
| **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted |
500500
| **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. |

0 commit comments

Comments
 (0)