From 9b3d9dbef3e7e6afd9a1a664057fb3de8bc6e2c2 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 5 Sep 2026 15:48:13 +0000 Subject: [PATCH 1/5] =?UTF-8?q?wip:=20tree=20reference=20self-only=20(#148?= =?UTF-8?q?92)=20=E2=80=94=20refinement,=20predicate,=20form=20text,=20sho?= =?UTF-8?q?wcase,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- .../field-type-decision-tree.mdx | 4 + content/docs/data-modeling/field-types.mdx | 2 +- .../docs/data-modeling/validation-rules.mdx | 4 +- .../areas/records-forms.json | 2 +- .../src/data/objects/field-zoo.object.ts | 8 +- packages/qa/dogfood/test/field-zoo.matrix.ts | 8 +- packages/spec/src/data/field.zod.ts | 11 +- packages/spec/src/data/object.form.ts | 6 +- packages/spec/src/data/object.zod.ts | 49 +++++++ .../src/data/tree-reference-self-only.test.ts | 132 ++++++++++++++++++ .../kernel/functional-completeness.test.ts | 32 ++++- .../src/kernel/functional-completeness.ts | 40 ++++-- 12 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 packages/spec/src/data/tree-reference-self-only.test.ts 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/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/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..ab9d974a39 --- /dev/null +++ b/packages/spec/src/data/tree-reference-self-only.test.ts @@ -0,0 +1,132 @@ +// 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'); + + const created = ObjectSchema.create(zoo('showcase_field_zoo') as never); + 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(zoo('showcase_category') as never)).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.', From cc1fdf7eb0ff4498aee20ed10030af2b152fe637 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 5 Sep 2026 15:52:11 +0000 Subject: [PATCH 2/5] wip: pins + changeset for the tree reference rule (#14892) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- .changeset/tree-reference-self-only.md | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .changeset/tree-reference-self-only.md 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. From b6f0313bb25ee10841d975c9037d4c8e6c369140 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 5 Sep 2026 16:54:22 +0000 Subject: [PATCH 3/5] wip: typed create() pins, regenerated reference docs, census re-anchor (#14892) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- content/docs/permissions/system-context.mdx | 4 ++-- content/docs/references/data/field.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/system/migration.mdx | 4 ++-- .../src/data/tree-reference-self-only.test.ts | 18 ++++++++++++++++-- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 815ccac008..9e79bacff9 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:702` | | "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/packages/spec/src/data/tree-reference-self-only.test.ts b/packages/spec/src/data/tree-reference-self-only.test.ts index ab9d974a39..17bcb7a33b 100644 --- a/packages/spec/src/data/tree-reference-self-only.test.ts +++ b/packages/spec/src/data/tree-reference-self-only.test.ts @@ -39,7 +39,15 @@ describe('[#14892] a `tree` field\'s `reference` must name the declaring object' // is a relation and its cascade default is exactly the intended meaning. expect(parsed.data.fields.f_tree.deleteBehavior).toBe('set_null'); - const created = ObjectSchema.create(zoo('showcase_field_zoo') as never); + // 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'); }); @@ -75,7 +83,13 @@ describe('[#14892] a `tree` field\'s `reference` must name the declaring object' expect(issue.message).toContain('`lookup`'); // The authoring door throws the same located issue. - expect(() => ObjectSchema.create(zoo('showcase_category') as never)).toThrow(/showcase_category/); + 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', () => { From 91e253208305ad76bedd0352bb03576ee6a93e6b Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 5 Sep 2026 17:45:23 +0000 Subject: [PATCH 4/5] chore(platform-objects): regenerate the en metadata-forms bundle for the shared reference row's help text (#14892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 b6f0313bb (Type Check · consumer gates). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- .../src/apps/translations/en.metadata-forms.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 8d0825976b97a001a5f48950a0d39b9060839bb1 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 5 Sep 2026 21:46:04 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20merge=20hand-off=20=E2=80=94=20mai?= =?UTF-8?q?n's=20side=20of=20the=20os-regen=20artifacts,=20system-context?= =?UTF-8?q?=20census=20regenerated=20on=20the=20merged=20tree=20(#14892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- content/docs/permissions/system-context.mdx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 9e79bacff9..a55cce40af 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -87,14 +87,14 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1620` | -| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2546` (the step 3.5 block), skipped by `:1620` | -| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4374` | -| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4525` | -| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4603` | -| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1447`, `:1469` | +| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: all of rows 2–6 at once — this is the single largest behaviour on the page | `security-plugin.ts:1686` | +| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | guard at `security-plugin.ts:2612` (the step 3.5 block), skipped by `:1686` | +| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `security-plugin.ts:4440` | +| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable. Lose: field masking | `security-plugin.ts:4591` | +| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `security-plugin.ts:4669` | +| 6 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `security-plugin.ts:1513`, `:1535` | | 7 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `object-schema-fls.ts:228` | -| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3887` | +| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3953` | | 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` | | 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` | | 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1427` | @@ -120,7 +120,7 @@ that silently does not happen. | 26 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6881` | | 27 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12477` | | 28 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12406` | -| 29 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `security-plugin.ts:1620` | +| 29 | **Bulk data event `organizationId` OMITTED** — the batch is published "not asserted" | plugin-security | Get: nothing — the `data.records.*` event still publishes. Lose: the per-organization attribution: this exit is taken before the security middleware composes any tenant wall, so it records no Layer 0 verdict on the operation (`OperationContext.tenantLayer0Verdict`, #15813), and the engine's bulk producer — which reads that recorded verdict and nothing else — omits the key rather than filling it from the caller's `tenantId`; a tenant-scoped consumer then does not deliver the event inside an organization wall (#15225) | `security-plugin.ts:1686` | ### 3. Sharing (`plugin-sharing`) @@ -134,7 +134,7 @@ The largest single consumer — **17 of the 105 sites**. | 33 | `grant()` skips the enforcement + manage-shares assertions | Get: the rule evaluator can materialise through the public API. Note it is **not** a bare skip: the system branch asserts the grant is not *inert* instead (a grant on an object no verdict can consult is refused) | `plugin-sharing/src/sharing-service.ts:1238` | | 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` | -| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1088` | +| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1189` | | 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` | | 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | | 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:202`, `:427` | @@ -158,9 +158,9 @@ The largest single consumer — **17 of the 105 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5083`, `:6509`, `:6757`, `:7188`, `:7381` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5101`, `:6527`, `:6775`, `:7206`, `:7399` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | -| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | +| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:535`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` | @@ -193,7 +193,7 @@ assuming `isSystem` covers it is a documented source of bugs. | Assumption | Reality | Anchor | |:---|:---|:---| -| "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:702` | +| "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:1590` (#3493 / #6640) |