From bdbe67b3b0effaec96b2d8a788e40fa0bb509e01 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 05:03:35 +0000 Subject: [PATCH 1/7] feat(spec): declare rows (multiline editors) and options[].description as authorable field-metadata keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#6140 (maintainer ruling 2026-08-25, Option A) + objectui#6153 (inherited per key): both keys were consumed-but-undeclared — objectui's RichTextField/TextAreaField read rows through an as-any, LookupField searches option description — while the strict publish door refused them. Declared for the measured consumption sets, the #11566 template: superRefine refuses rows outside textarea/markdown/html/richtext; house count discipline refuses 0/negative/fractional rows. dependsOn deliberately NOT declared (the canonical depends_on exists; widget fix rides the objectui half). The four inert editor keys stay undeclared, pinned. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- ...ld-rows-and-option-description-declared.md | 12 + content/docs/references/data/field.mdx | 5 +- content/docs/references/data/object.mdx | 6 +- content/docs/references/system/migration.mdx | 6 +- content/docs/references/ui/view.mdx | 6 +- packages/spec/authorable-surface/data.json | 2 + packages/spec/authorable-surface/ui.json | 1 + .../field-rows-option-description.test.ts | 205 ++++++++++++++++++ packages/spec/src/data/field.form.ts | 5 + packages/spec/src/data/field.zod.ts | 88 +++++++- packages/spec/src/data/object.form.ts | 5 + 11 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 .changeset/field-rows-and-option-description-declared.md create mode 100644 packages/spec/src/data/field-rows-option-description.test.ts diff --git a/.changeset/field-rows-and-option-description-declared.md b/.changeset/field-rows-and-option-description-declared.md new file mode 100644 index 0000000000..50830d9441 --- /dev/null +++ b/.changeset/field-rows-and-option-description-declared.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": minor +--- + +**`rows` and `options[].description` become declared, authorable field-metadata keys** (objectui#6140 / objectui#6153 — maintainer ruling 2026-08-25, Option A, verbatim: 「就全部接受,然后继续下一批」; the accepted-set-expansion follows the #11566/#11949 `maxLength`/`minLength` convergence template). + +Both keys were consumed-but-undeclared — real behaviour in the running app, reached through an `as any` on the metadata carrier, while the strict publish door refused exactly the author who wrote them legally: + +- `rows?: number` (positive integer) on the multiline editor types `textarea` / `markdown` / `html` / `richtext`. objectui's `RichTextField` — the one widget behind the `markdown`/`html`/`richtext` registry keys — reads `richField?.rows || 8`, and `TextAreaField` reads `textareaField?.rows || 4`, sizing the inline (non-fullscreen) editor surface. The ruled pair is `markdown`/`html` (aligning the `TextareaFieldMetadata` precedent); `textarea`/`richtext` complete the measured consumption set. A superRefine refuses the key on every other type (the #11566 template), and the house count discipline refuses `0` / negative / fractional values. Both authoring forms show the key for exactly this set. The ruling's capability expansion STOPS here: the four inert rich-text editor keys (`toolbar`/`preview`/`minHeight`/`maxHeight`) stay undeclared, and a pin holds that door shut. +- `description?: string` on `SelectOptionSchema`. objectui's `LookupField` searches it on a lookup's authored static options (`opt.description && opt.description.toLowerCase().includes(q)`) and its `recordToOption` produces the same key for fetched options — and the object-definition authoring form has offered a `description` input all along; the declaration makes both honest. It flows into `FormSelectOptionSchema` by the #12868 Omit construction. Per the same inherited ruling, `dependsOn` is deliberately NOT declared — the canonical field-level `depends_on` already exists, and the widget-side spelling fix rides the objectui half. + +Additive in both cases: no stored shape changes, and every previously-written body carrying either key was refused at parse, so nothing legal changes meaning. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 7fd49e1ba0..b594ed32a1 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -65,6 +65,7 @@ const result = CurrencyConfigSchema.parse(data); | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | +| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | @@ -72,7 +73,7 @@ const result = CurrencyConfigSchema.parse(data); | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **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; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | +| **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. | | **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 | @@ -189,6 +190,7 @@ const result = CurrencyConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **label** | `string` | ✅ | Display label (human-readable, any case allowed) | | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | +| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. | | **color** | `string` | optional | Color code for badges/charts | | **default** | `boolean` | optional | Is default option | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | @@ -400,6 +402,7 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name` | :--- | :--- | :--- | :--- | | **label** | `string` | ✅ | Display label (human-readable, any case allowed) | | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | +| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. | | **color** | `string` | optional | Color code for badges/charts | | **default** | `boolean` | optional | Is default option | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 11ff6a09a3..8a5460426e 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -227,6 +227,7 @@ const result = ApiMethod.parse(data); | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | +| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | @@ -234,7 +235,7 @@ const result = ApiMethod.parse(data); | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **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; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | +| **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. | | **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 | @@ -555,6 +556,7 @@ const result = ApiMethod.parse(data); | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | +| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | @@ -562,7 +564,7 @@ const result = ApiMethod.parse(data); | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **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; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | +| **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. | | **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 | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 5e8200e77a..e5e79611d5 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -65,6 +65,7 @@ Add a new field to an existing object | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | +| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | @@ -72,7 +73,7 @@ Add a new field to an existing object | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **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; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | +| **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. | | **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 | @@ -482,6 +483,7 @@ Add a new field to an existing object | **defaultValue** | `any` | optional | Default applied on INSERT when the field is omitted or null (`''` is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope `{ dialect: 'cel', source: 'today()' }` (accepted structurally; result type is a runtime concern); a runtime TOKEN — `NOW()` on `datetime`/`date`/`time` only, `current_user` on `user` or `lookup` with `reference: 'sys_user'` only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 `valueSchemaFor`). Anything else is refused at parse time with a prescriptive message. | | **maxLength** | `integer` | optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | | **minLength** | `integer` | optional | Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. | +| **rows** | `integer` | optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. | | **precision** | `integer` | optional | Total digits (non-negative integer) | | **scale** | `integer` | optional | Decimal places (non-negative integer) | | **min** | `number` | optional | Minimum value | @@ -489,7 +491,7 @@ Add a new field to an existing object | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | | **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; color?: string; default?: boolean; … }[]` | optional | Static options for select/multiselect | +| **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. | | **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 | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 933cd798fe..2a0a3f78f0 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -180,7 +180,7 @@ Column footer summary configuration | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name (snake_case) | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | Field type (auto-infers widget if omitted) | -| **options** | `{ label: string; value: string; color?: string; visibleWhen?: string \| object }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition) | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition) | | **reference** | `string` | optional | Target object name for lookup/master_detail fields | | **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. | | **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) | @@ -268,6 +268,7 @@ Form-view select option — the object-field option shape minus the per-option ` | :--- | :--- | :--- | :--- | | **label** | `string` | ✅ | Display label (human-readable, any case allowed) | | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | +| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. | | **color** | `string` | optional | Color code for badges/charts | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | @@ -343,7 +344,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name (snake_case) | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | Field type (auto-infers widget if omitted) | -| **options** | `{ label: string; value: string; color?: string; visibleWhen?: string \| object }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition) | +| **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Options for select/multiselect/radio/checkboxes fields (per-option `default` is not accepted here — declare the pre-selected choice on the object definition) | | **reference** | `string` | optional | Target object name for lookup/master_detail fields | | **publicPicker** | `{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }` | optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. | | **maxLength** | `integer` | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) | @@ -384,6 +385,7 @@ Form-view select option — the object-field option shape minus the per-option ` | :--- | :--- | :--- | :--- | | **label** | `string` | ✅ | Display label (human-readable, any case allowed) | | **value** | `string` | ✅ | Stored value (lowercase machine identifier) | +| **description** | `string` | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. | | **color** | `string` | optional | Color code for badges/charts | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live `record` plus the host predicate scope, which binds `current_user`. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false — so a user-gated CHOICE belongs here. e.g. P`record.country == 'cn'` or P`'admin' in current_user.positions` | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 4716b2025d..9ebe699707 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -396,6 +396,7 @@ "data/Field:requiredPermissions", "data/Field:requiredWhen", "data/Field:returnType", + "data/Field:rows", "data/Field:scale", "data/Field:searchable", "data/Field:sortable", @@ -829,6 +830,7 @@ "data/SeedLoaderResult:summary", "data/SelectOption:color", "data/SelectOption:default", + "data/SelectOption:description", "data/SelectOption:label", "data/SelectOption:value", "data/SelectOption:visibleWhen", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 132adfbd24..25267ec513 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -448,6 +448,7 @@ "ui/FormSection:visibleOn", "ui/FormSection:visibleWhen", "ui/FormSelectOption:color", + "ui/FormSelectOption:description", "ui/FormSelectOption:label", "ui/FormSelectOption:value", "ui/FormSelectOption:visibleWhen", diff --git a/packages/spec/src/data/field-rows-option-description.test.ts b/packages/spec/src/data/field-rows-option-description.test.ts new file mode 100644 index 0000000000..567c1c5dc6 --- /dev/null +++ b/packages/spec/src/data/field-rows-option-description.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [objectui#6140 / objectui#6153] The two consumed-but-undeclared field + * metadata keys the 2026-08-25 maintainer ruling declared (Option A, verbatim: + * 「就全部接受,然后继续下一批」): + * + * - **`rows`** on the multiline editor field types. objectui's `RichTextField` + * (the one widget behind the `markdown`/`html`/`richtext` registry keys, + * objectui#5498) reads `richField?.rows || 8` through an `as any`, and + * `TextAreaField` reads `textareaField?.rows || 4` — while `FieldSchema` + * refused the key at publish on EVERY type, so the capability worked in the + * running app and failed for exactly the author who wrote it legally. + * Declared for the measured consumption set (`MULTILINE_EDITOR_FIELD_TYPES`: + * textarea, markdown, html, richtext) with the #11566 template — a + * superRefine refuses it on every other type, and the house count discipline + * (#8321) refuses 0 / negative / fractional values. + * - **`options[].description`** on the select-option shape. objectui's + * `LookupField` searches it on a lookup's authored static options + * (`opt.description && opt.description.toLowerCase().includes(q)`) and its + * `recordToOption` produces the same key for fetched options — while + * `SelectOptionSchema` refused it. The object-definition authoring form has + * offered a `description` input all along; the declaration makes the offer + * honest. Per the same inherited ruling, `dependsOn` is deliberately NOT + * declared (the canonical `depends_on` already exists at the field level). + * + * The ruling's capability expansion STOPS at these keys: the four inert + * rich-text editor keys (`toolbar`/`preview`/`minHeight`/`maxHeight`) stay + * undeclared — the control pins below hold that door shut. + */ + +import { describe, it, expect } from 'vitest'; + +import { FieldSchema, SelectOptionSchema } from './field.zod'; +import { FormSelectOptionSchema } from '../ui/view.zod'; + +// ========================================================================= +// 1. `rows` — the publish door now accepts what the widgets already read +// ========================================================================= + +describe('FieldSchema accepts `rows` on the multiline editor types (objectui#6140, ruled 2026-08-25)', () => { + // Hardcoded on purpose (not iterated off the module-local set) so this test + // is an independent measurement of the set, not a tautology — the same + // discipline as the #11949 bounded-string pins. + const multiline = ['textarea', 'markdown', 'html', 'richtext'] as const; + + for (const type of multiline) { + it(`accepts rows on type: '${type}', preserving the value through parse`, () => { + const result = FieldSchema.safeParse({ + name: 'body', label: 'Body', type, rows: 12, + }); + expect(result.success, JSON.stringify((result as { error?: unknown }).error ?? {})).toBe(true); + if (result.success) expect(result.data.rows).toBe(12); + }); + } + + it('accepts rows: 1 (the lower bound is 1, not 2)', () => { + const result = FieldSchema.safeParse({ + name: 'body', label: 'Body', type: 'textarea', rows: 1, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.rows).toBe(1); + }); + + it('absent rows stays absent — no default materializes, on any type', () => { + for (const type of ['textarea', 'markdown', 'boolean'] as const) { + const result = FieldSchema.parse({ name: 'f', label: 'F', type }) as Record; + expect('rows' in result).toBe(false); + } + }); +}); + +describe('malformed or misplaced rows declarations are refused at authoring', () => { + // House count discipline (#8321 / #11566): a row count of 0, -5 or 2.5 has + // no defined meaning — the HTML `rows` attribute is a positive integer, and + // the consuming widgets treat 0 as absent (`|| 8`), so `rows: 0` would be a + // silently-inert declaration. + const shapeCases: Array<[value: number, code: string]> = [ + [0, 'too_small'], + [-5, 'too_small'], + [2.5, 'invalid_type'], + ]; + for (const [value, code] of shapeCases) { + it(`refuses rows: ${value} on a textarea field with a ${code} issue at [rows]`, () => { + const result = FieldSchema.safeParse({ + name: 'body', label: 'Body', type: 'textarea', rows: value, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path[0] === 'rows'); + expect(issue?.code).toBe(code); + // Message substance, not just a throw: the refusal names what a legal + // value looks like (int / >=1), so an AI author can fix it. + expect(issue?.message).toMatch(code === 'invalid_type' ? /expected int/ : />=1/); + } + }); + } + + // One representative per family, mirroring the #11566/#11949 wrong-type + // blocks — plus `code` and `text`, the near-misses: `code` is a multiline + // EDITOR whose widget has no `rows` read, and `text` is the single-line + // sibling an AI author will reach for first. + const wrongTypes = [ + 'text', 'code', 'boolean', 'number', 'date', 'select', 'lookup', + 'autonumber', 'formula', 'json', + ] as const; + for (const type of wrongTypes) { + it(`refuses rows on type: '${type}' with a custom issue at [rows]`, () => { + const result = FieldSchema.safeParse({ + name: 'f', label: 'F', type, rows: 6, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path[0] === 'rows'); + expect(issue?.code).toBe('custom'); + // The refusal names the legal set and the offending type, so an AI + // author can fix the declaration without leaving the message. + expect(issue?.message).toMatch(/multiline editor/); + expect(issue?.message).toContain("'textarea', 'markdown', 'html', 'richtext'"); + expect(issue?.message).toContain(`\`${type}\``); + } + }); + } +}); + +describe('the ruled expansion stops at `rows` — the four inert editor keys stay refused', () => { + // The control half of the publish-door pins: the same markdown field that + // now accepts `rows` still refuses the inert keys the ruling explicitly did + // NOT make real. If one of these starts parsing, that is a NEW accepted-set + // expansion nobody ruled on. + it.each(['toolbar', 'preview', 'minHeight', 'maxHeight'] as const)( + 'an undeclared editor key `%s` on a markdown field is still an unrecognized_keys refusal', + (key) => { + const result = FieldSchema.safeParse({ + name: 'body', label: 'Body', type: 'markdown', rows: 8, [key]: true, + }); + expect(result.success).toBe(false); + if (!result.success) { + const hit = result.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(hit, `no unrecognized_keys issue for \`${key}\` in ${JSON.stringify(result.error.issues)}`).toBeDefined(); + expect(JSON.stringify(hit)).toContain(key); + } + }, + ); +}); + +// ========================================================================= +// 2. `options[].description` — the option shape accepts the searched key +// ========================================================================= + +describe('SelectOptionSchema accepts `description` (objectui#6153, inherited ruling 2026-08-25)', () => { + const OPTION = { label: 'Open', value: 'open', description: 'Still being worked' } as const; + + it('accepts and preserves description on the bare option shape', () => { + const result = SelectOptionSchema.safeParse(OPTION); + expect(result.success, JSON.stringify((result as { error?: unknown }).error ?? {})).toBe(true); + if (result.success) expect(result.data.description).toBe('Still being worked'); + }); + + it('accepts a described option through the real select-field door', () => { + const result = FieldSchema.safeParse({ + name: 'status', label: 'Status', type: 'select', + options: [OPTION, { label: 'Closed', value: 'closed' }], + }); + expect(result.success, JSON.stringify((result as { error?: unknown }).error ?? {})).toBe(true); + if (result.success) expect(result.data.options?.[0]?.description).toBe('Still being worked'); + }); + + it('accepts a described option through the lookup-field door (the measured consumer path)', () => { + // objectui's LookupField takes `fieldMeta?.options || []` as its static + // options and searches `opt.description` — this is the door an author of + // that behaviour publishes through. + const result = FieldSchema.safeParse({ + name: 'assignee', label: 'Assignee', type: 'lookup', reference: 'sys_user', + options: [OPTION], + }); + expect(result.success, JSON.stringify((result as { error?: unknown }).error ?? {})).toBe(true); + if (result.success) expect(result.data.options?.[0]?.description).toBe('Still being worked'); + }); + + it('flows into the form-view option face by construction (the #12868 Omit)', () => { + const result = FormSelectOptionSchema.safeParse(OPTION); + expect(result.success, JSON.stringify((result as { error?: unknown }).error ?? {})).toBe(true); + if (result.success) { + expect((result.data as { description?: string }).description).toBe('Still being worked'); + } + }); + + it('the neighbouring undeclared option keys are still refused — `icon` (control) and `dependsOn` (explicitly not licensed)', () => { + // `icon`: offered by the object.form options repeater and declared by + // objectui's SelectOptionMetadata, but #5016's option C left it out of + // this shape — it stays an unrecognized_keys refusal until someone rules + // otherwise. `dependsOn`: the inherited #6153 ruling resolves it + // objectui-side (the widget reads the canonical field-level `depends_on`); + // declaring a camelCase twin here is explicitly not licensed. + for (const [key, value] of [['icon', 'circle-dot'], ['dependsOn', 'country']] as const) { + const result = SelectOptionSchema.safeParse({ label: 'Open', value: 'open', [key]: value }); + expect(result.success, `\`${key}\` unexpectedly parsed — an unruled accepted-set expansion`).toBe(false); + if (!result.success) { + const hit = result.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(hit, `no unrecognized_keys issue for \`${key}\``).toBeDefined(); + } + } + }); +}); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 09a936f51a..eecedfca9c 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -49,6 +49,11 @@ export const fieldForm = defineForm({ // #11875 added `signature`/`qrcode` to the set (the write seam now // enforces their declared bound); this visibleWhen moves with it. { field: 'maxLength', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']", helpText: 'Maximum character length' }, + // objectui#6140 (maintainer ruling 2026-08-25, Option A) — `rows` is + // shown for exactly the multiline editor types the schema accepts it + // on (MULTILINE_EDITOR_FIELD_TYPES in field.zod.ts); this visibleWhen + // moves with the set. + { field: 'rows', visibleWhen: "data.type in ['textarea','markdown','html','richtext']", helpText: 'Inline editor height in text rows' }, // Number field options { field: 'min', visibleWhen: "data.type == 'number' || data.type == 'currency'", helpText: 'Minimum value' }, { field: 'max', visibleWhen: "data.type == 'number' || data.type == 'currency'", helpText: 'Maximum value' }, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index f0e8154daa..57ff40b5e5 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -138,6 +138,35 @@ export const BOUNDED_STRING_FIELD_TYPES: ReadonlySet = new Set([ 'signature', 'qrcode', ] as const satisfies readonly FieldType[]); +/** + * Field types whose value is edited in a MULTILINE text editor whose inline + * (non-fullscreen) surface is sized by the HTML `rows` attribute — the set on + * which an authored `rows` height hint reaches a real reader (objectui#6140, + * maintainer ruling 2026-08-25, Option A). + * + * Measured from the consuming widgets, the #11566 method (the list with a + * measured reader is the one promoted to the protocol): objectui's + * `TextAreaField` reads the key for `textarea` (`textareaField?.rows || 4`), + * and `RichTextField` — the one widget registered for the `markdown`, `html` + * and `richtext` keys (objectui#5498) — reads it for the other three + * (`richField?.rows || 8`, passed to the inline editor surface; the + * fullscreen/dialog surface sizes itself and ignores it). The RULED pair is + * `markdown`/`html` — the two objectui metadata types that lacked the + * declaration, while `TextareaFieldMetadata` already declared `rows` and is + * the precedent the ruling cites. `textarea`/`richtext` are members because + * the same measured read serves them and this schema refuses the key on + * EVERY type today — declaring the ruled pair while still refusing the + * precedent type's own declared key would manufacture a fresh + * declared-vs-enforced split on the type the ruling aligns to. + * + * Deliberately NOT here: `code` (its editor has no `rows` read — the only + * `rows` occurrences in objectui's field widgets are the two quoted above), + * and every single-line string type. + */ +const MULTILINE_EDITOR_FIELD_TYPES: ReadonlySet = new Set([ + 'textarea', 'markdown', 'html', 'richtext', +] as const satisfies readonly FieldType[]); + /** * Field types whose stored value the RUNTIME owns outright — issued by the * engine (or the driver's persistent sequence), never supplied by a caller on @@ -227,6 +256,23 @@ export const SelectOptionSchema = lazySchema(() => strictObject({ }, { label: z.string().describe('Display label (human-readable, any case allowed)'), value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'), + /** + * Optional secondary text for the option (objectui#6153, inheriting the + * objectui#6140 ruling frame — maintainer 2026-08-25: a key that is + * genuinely consumed gets declared). Consumed-but-undeclared until now: + * objectui's `LookupField` takes a lookup's authored static `options` as its + * own open local type and SEARCHES this key (`opt.description && + * opt.description.toLowerCase().includes(q)`), and its `recordToOption` + * produces the same key for fetched options — while this strict shape + * refused it at publish, so the search behaviour was real for a key no + * author could legally write. The object-definition authoring form + * (`object.form.ts` options repeater) has offered a `description` input all + * along; this declaration is what makes that offer honest. Per the same + * inherited ruling, `dependsOn` is NOT declared here — the canonical + * spelling `depends_on` already exists at the field level, and a camelCase + * twin would be a second spelling for a declared concept. + */ + description: z.string().optional().describe('Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text.'), color: z.string().optional().describe('Color code for badges/charts'), default: z.boolean().optional().describe('Is default option'), /** @@ -910,7 +956,24 @@ export const FieldSchema = lazySchema(() => { // metadata author mass-produces — and is refused loudly at authoring instead // of parsing cleanly and asserting nothing. minLength: z.number().int().min(1).optional().describe('Min character length (positive integer; `minLength: 0` is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode.'), - + + // objectui#6140 (maintainer ruling 2026-08-25, Option A — verbatim: + // 「就全部接受,然后继续下一批」): `rows` was consumed-but-undeclared. + // objectui's `RichTextField` has always read it (`richField?.rows || 8`, + // behind the `markdown`/`html`/`richtext` registry keys — reached through an + // `as any` on the metadata carrier) and `TextAreaField` likewise + // (`textareaField?.rows || 4`), while this strict shape refused the key at + // publish — the capability worked in the running app and failed for exactly + // the author who wrote it legally. Declared here for precisely the measured + // consumption set (MULTILINE_EDITOR_FIELD_TYPES; the superRefine below is + // the #11566 template). The value shape follows the house count discipline + // (#8321 / #11566: a row count of 0, -5 or 2.5 has no defined meaning; + // objectui's `TextareaFieldMetadata` spells `rows?: number` only because a + // TS interface cannot say more). The ruling's capability expansion STOPS at + // `rows` — the four inert rich-text editor keys (`toolbar` / `preview` / + // `minHeight` / `maxHeight`) stay undeclared. + rows: z.number().int().min(1).optional().describe('Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea `rows` attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height.'), + /** Number Constraints */ // #8321 — `precision`/`scale` are digit COUNTS, so a non-integer or negative // declaration has no defined meaning. #7501 made `scale` enforced at write @@ -1725,6 +1788,29 @@ export const FieldSchema = lazySchema(() => { }); } + // objectui#6140 (maintainer ruling 2026-08-25, Option A): `rows` is only + // authorable on the multiline editor types whose widget actually reads it + // (MULTILINE_EDITOR_FIELD_TYPES — see its docblock for the measured + // consumption set). On any other type the declaration would parse and do + // nothing — the declared-but-inert shape ADR-0078 keeps out — so it is + // refused at the authoring seam, where the fix is one keystroke away. + // `rows` has no schema default, so `undefined` here always means "not + // authored" — a field without the key can never fire this. The message + // enumerates the set ITSELF rather than a prose copy of it (#12017 + // two-copies failure shape). + if (field.rows !== undefined && !MULTILINE_EDITOR_FIELD_TYPES.has(field.type)) { + ctx.addIssue({ + code: 'custom', + path: ['rows'], + message: + `\`rows\` is only valid on multiline editor field types — ` + + `${[...MULTILINE_EDITOR_FIELD_TYPES].map((t) => `'${t}'`).join(', ')} — ` + + `and this field is \`${field.type}\`: its widget renders no rows-sized ` + + 'editor surface, so the declaration would parse and change nothing. ' + + 'Drop the key, or use a multiline editor type.', + }); + } + // #7918 (maintainer ruling 2026-08-12, Option A): the FIELD-level // `precision` key doubles as the currency display width — objectui's // CurrencyField reads it, and objectui#4361 pinned authored-precision-wins diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 8befcd8bc5..8949b2ab13 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -134,6 +134,11 @@ export const objectForm = defineForm({ // to stop at 9 types (`code` was the one it was missing); it moves // with the set, exactly like the row above. { field: 'minLength', type: 'number', helpText: 'Min characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext','code','signature','qrcode']" }, + // objectui#6140 (maintainer ruling 2026-08-25, Option A) — `rows` + // is shown for exactly the multiline editor types the schema + // accepts it on (MULTILINE_EDITOR_FIELD_TYPES in field.zod.ts); + // this visibleWhen moves with the set. + { field: 'rows', type: 'number', helpText: 'Inline editor height (text rows)', visibleWhen: "data.type in ['textarea','markdown','html','richtext']" }, // Numeric constraints { field: 'min', type: 'number', helpText: 'Minimum value', visibleWhen: "data.type in ['number','currency','percent','rating','slider','progress']" }, From e665729f1c5caaca4a03ed601c7ca0e4d94a16a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 05:20:51 +0000 Subject: [PATCH 2/7] chore(i18n): regenerate the nine translation bundles for the new metadataForms rows entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node scripts/check-i18n-bundles.mjs --write (merge mode) — the two new form rows of objectui#6140 land as metadataForms keys; translated locales receive the source text per merge-mode contract and still need translating. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- .../src/apps/translations/en.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/es-ES.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/es-ES.source-hashes.generated.ts | 4 ++++ .../apps/translations/ja-JP.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/ja-JP.source-hashes.generated.ts | 4 ++++ .../apps/translations/zh-CN.metadata-forms.generated.ts | 8 ++++++++ .../apps/translations/zh-CN.source-hashes.generated.ts | 4 ++++ 7 files changed, 44 insertions(+) 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 be4726fe20..c8ffff0c15 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 @@ -120,6 +120,10 @@ export const enMetadataForms: NonNullable = { label: "Min Length", helpText: "Min characters" }, + "fields.rows": { + label: "Rows", + helpText: "Inline editor height (text rows)" + }, "fields.min": { label: "Min", helpText: "Minimum value" @@ -384,6 +388,10 @@ export const enMetadataForms: NonNullable = { label: "Max Length", helpText: "Maximum character length" }, + rows: { + label: "Rows", + helpText: "Inline editor height in text rows" + }, min: { label: "Min", helpText: "Minimum value" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 1bfd66b720..9b8fd04d7f 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -120,6 +120,10 @@ export const esESMetadataForms: NonNullable = label: "Longitud mínima", helpText: "Mínimo de caracteres" }, + "fields.rows": { + label: "Rows", + helpText: "Inline editor height (text rows)" + }, "fields.min": { label: "Mínimo", helpText: "Valor mínimo" @@ -384,6 +388,10 @@ export const esESMetadataForms: NonNullable = label: "Longitud máxima", helpText: "Longitud máxima de caracteres" }, + rows: { + label: "Rows", + helpText: "Inline editor height in text rows" + }, min: { label: "Mínimo", helpText: "Valor mínimo" diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 4a9e1a3250..cd88187980 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -65,6 +65,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "metadataForms.doc.label": "3f35cf5088b999ad", "metadataForms.field.fields.placeholder.helpText": "07cecac0844860be", "metadataForms.field.fields.placeholder.label": "44d62b55b63fe718", + "metadataForms.field.fields.rows.helpText": "66755b9447e67a15", + "metadataForms.field.fields.rows.label": "319af74cc41ea823", "metadataForms.field.fields.summaryOperations.field.helpText": "b6897e7341b31c09", "metadataForms.field.fields.summaryOperations.field.label": "e21c314685cd95ca", "metadataForms.field.fields.summaryOperations.filter.helpText": "7a46d2abde5a25c4", @@ -110,6 +112,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "metadataForms.object.fields.fields.readonlyWhen.label": "af1c3f486fb2fbf7", "metadataForms.object.fields.fields.requiredWhen.helpText": "997a0d21e5e32203", "metadataForms.object.fields.fields.requiredWhen.label": "89b4d31ea46e2e76", + "metadataForms.object.fields.fields.rows.helpText": "3adb8c4c447f36b7", + "metadataForms.object.fields.fields.rows.label": "319af74cc41ea823", "metadataForms.object.fields.fields.summaryOperations.field.helpText": "ff2f535b8f9cfffe", "metadataForms.object.fields.fields.summaryOperations.field.label": "e21c314685cd95ca", "metadataForms.object.fields.fields.summaryOperations.function.helpText": "b1022db50ca584b3", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index c977db9de9..f6ea4bcc8e 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -120,6 +120,10 @@ export const jaJPMetadataForms: NonNullable = label: "最小長", helpText: "最小文字数" }, + "fields.rows": { + label: "Rows", + helpText: "Inline editor height (text rows)" + }, "fields.min": { label: "最小値", helpText: "最小値" @@ -384,6 +388,10 @@ export const jaJPMetadataForms: NonNullable = label: "最大長", helpText: "最大文字数" }, + rows: { + label: "Rows", + helpText: "Inline editor height in text rows" + }, min: { label: "最小値", helpText: "最小値" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index 737d89f461..e6e9a32814 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -65,6 +65,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "metadataForms.doc.label": "3f35cf5088b999ad", "metadataForms.field.fields.placeholder.helpText": "07cecac0844860be", "metadataForms.field.fields.placeholder.label": "44d62b55b63fe718", + "metadataForms.field.fields.rows.helpText": "66755b9447e67a15", + "metadataForms.field.fields.rows.label": "319af74cc41ea823", "metadataForms.field.fields.summaryOperations.field.helpText": "b6897e7341b31c09", "metadataForms.field.fields.summaryOperations.field.label": "e21c314685cd95ca", "metadataForms.field.fields.summaryOperations.filter.helpText": "7a46d2abde5a25c4", @@ -110,6 +112,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "metadataForms.object.fields.fields.readonlyWhen.label": "af1c3f486fb2fbf7", "metadataForms.object.fields.fields.requiredWhen.helpText": "997a0d21e5e32203", "metadataForms.object.fields.fields.requiredWhen.label": "89b4d31ea46e2e76", + "metadataForms.object.fields.fields.rows.helpText": "3adb8c4c447f36b7", + "metadataForms.object.fields.fields.rows.label": "319af74cc41ea823", "metadataForms.object.fields.fields.summaryOperations.field.helpText": "ff2f535b8f9cfffe", "metadataForms.object.fields.fields.summaryOperations.field.label": "e21c314685cd95ca", "metadataForms.object.fields.fields.summaryOperations.function.helpText": "b1022db50ca584b3", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index fa7e281102..48f9abe561 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -120,6 +120,10 @@ export const zhCNMetadataForms: NonNullable = label: "最小长度", helpText: "最小字符数" }, + "fields.rows": { + label: "Rows", + helpText: "Inline editor height (text rows)" + }, "fields.min": { label: "最小值", helpText: "最小值" @@ -384,6 +388,10 @@ export const zhCNMetadataForms: NonNullable = label: "最大长度", helpText: "最多字符数" }, + rows: { + label: "Rows", + helpText: "Inline editor height in text rows" + }, min: { label: "最小值", helpText: "允许的最小数值" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 724d2dfc0d..d3542beb59 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -65,6 +65,8 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "metadataForms.doc.label": "3f35cf5088b999ad", "metadataForms.field.fields.placeholder.helpText": "07cecac0844860be", "metadataForms.field.fields.placeholder.label": "44d62b55b63fe718", + "metadataForms.field.fields.rows.helpText": "66755b9447e67a15", + "metadataForms.field.fields.rows.label": "319af74cc41ea823", "metadataForms.field.fields.summaryOperations.field.helpText": "b6897e7341b31c09", "metadataForms.field.fields.summaryOperations.field.label": "e21c314685cd95ca", "metadataForms.field.fields.summaryOperations.filter.helpText": "7a46d2abde5a25c4", @@ -110,6 +112,8 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "metadataForms.object.fields.fields.readonlyWhen.label": "af1c3f486fb2fbf7", "metadataForms.object.fields.fields.requiredWhen.helpText": "997a0d21e5e32203", "metadataForms.object.fields.fields.requiredWhen.label": "89b4d31ea46e2e76", + "metadataForms.object.fields.fields.rows.helpText": "3adb8c4c447f36b7", + "metadataForms.object.fields.fields.rows.label": "319af74cc41ea823", "metadataForms.object.fields.fields.summaryOperations.field.helpText": "ff2f535b8f9cfffe", "metadataForms.object.fields.fields.summaryOperations.field.label": "e21c314685cd95ca", "metadataForms.object.fields.fields.summaryOperations.function.helpText": "b1022db50ca584b3", From c1146584173091b63b4a922b4f90597d5d6dd59e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 05:25:21 +0000 Subject: [PATCH 3/7] docs(permissions): re-anchor the system-context preserveAudit citation after the field.zod.ts insertion (census --fix) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0d5e887c30..bdb6fbae1d 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "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:9457`–`9474` | -| "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:1451` (#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:1514` (#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:273` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` | From 83eeba1be32081e310df53a6e974a03bcbce0c2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 05:35:37 +0000 Subject: [PATCH 4/7] chore(liveness): classify field/rows (live, cross-repo evidence) and note options[].description; regenerate state counts The liveness walk found the newly declared rows key UNCLASSIFIED. Entry follows the placeholder precedent (consumers-shipped-first, cross-repo objectui evidence with line cites). options note now names description (objectui#6153). state-counts.md regenerated: field 88->89 live. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- packages/spec/liveness/field.json | 9 ++++++++- packages/spec/liveness/state-counts.md | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index f77613b827..9a9fab68ee 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -54,7 +54,7 @@ }, "options": { "status": "live", - "note": "select options {label,value,color,default} — renderers + validation." + "note": "select options {label,value,description,color,default} — renderers + validation. `description` joined 2026-08-31 (objectui#6153, inheriting the objectui#6140 ruling frame): objectui LookupField searches it on authored static options (LookupField.tsx:526) and recordToOption produces it for fetched options." }, "deleteBehavior": { "status": "live", @@ -221,6 +221,13 @@ "evidence": "packages/objectql/src/validation/record-validator.ts#validateOne (`if (def.minLength !== undefined && s.length < def.minLength) return fail('min_length', { minLength: def.minLength, actual: s.length })`)", "note": "CAVEAT — server camel; client form reads min_length. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `:130` had rotted onto `export class ValidationError extends Error`, the error class rather than any check; its sibling `maxLength` cited `:127` three lines above, so the pair had drifted together. Re-closed by hand against 8cb96ec41." }, + "rows": { + "status": "live", + "verifiedAt": "2026-08-31", + "evidenceScope": "cross-repo", + "evidence": "objectui: packages/fields/src/widgets/RichTextField.tsx:283 (`const rows = richField?.rows || 8;`, applied at :321 as `rows={rows}` on the inline editor surface — the one widget behind the `markdown`/`html`/`richtext` registry keys, objectui#5498); objectui: packages/fields/src/widgets/TextAreaField.tsx:94 (`const rows = textareaField?.rows || 4;`, applied at :126) — the objectui#6140 measurement table, re-confirmed 2026-08-31 on a local objectui checkout (e3d117a)", + "note": "Inline multiline-editor height (the HTML textarea rows attribute; fullscreen/dialog surfaces size themselves and ignore it). Declared 2026-08-31 (objectui#6140, maintainer ruling 2026-08-25 Option A): the consumers shipped first, reached through an `as any` on the metadata carrier — the declaration was the missing half, aligning the TextareaFieldMetadata precedent. Authorable only on the multiline editor types (textarea/markdown/html/richtext — MULTILINE_EDITOR_FIELD_TYPES superRefine in field.zod.ts)." + }, "displayField": { "status": "live", "note": "objectui LookupField/RecordPickerDialog — candidate label field in the record picker (reads displayField || display_field)." diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 1c63d166f0..5bd69cacda 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -28,7 +28,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| | `object` | 51 | 0 | 0 | 1 | 52 | -| `field` | 88 | 0 | 1 | 2 | 91 | +| `field` | 89 | 0 | 1 | 2 | 92 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 41 | 0 | 3 | 2 | 46 | | `hook` | 18 | 0 | 2 | 0 | 20 | @@ -58,4 +58,4 @@ for both corollaries. | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | | `manifest` | 22 | 0 | 16 | 0 | 38 | -| **total** | **820** | **5** | **75** | **10** | **910** | +| **total** | **821** | **5** | **75** | **10** | **911** | From b6e9d27b8e251842858cc905eca0942ddf280b96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 07:34:37 +0000 Subject: [PATCH 5/7] fix(driver-sql,lint): classify the new rows key (presentation) and invert the shipped-form census pins in place Patch round on PR #13669's red head 83eeba1b, two signatures: - driver-sql builtin-column-collision (#12015): the classification pin demands every FieldSchema key be classified deliberately; rows joins as 'presentation' (read by objectui's TextAreaField/RichTextField, never by the DDL). CI red 'unclassified FieldSchema key(s): [rows]' -> green. - packages/lint validate-predicate-path-refs census pins: the two new visibleWhen-gated rows entries move the corpus 49->51 (shipped-form predicates) and 17->18 (object form's data.type-rooted predicates); inverted in place with the reason recorded, per the #13216 precedent. The ==-literal census stays 42 (the new rows use 'data.type in [...]'). Maintainer ruled the open question A: the four-type multiline set stands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- .../src/builtin-column-collision.ts | 1 + .../src/validate-predicate-path-refs.test.ts | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index dfb00e6cb3..02995a58aa 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -100,6 +100,7 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = min: 'presentation', // write-time validation max: 'presentation', // write-time validation step: 'presentation', // input granularity + rows: 'presentation', // inline multiline-editor height (objectui#6140) — read by objectui's TextAreaField/RichTextField, never by the DDL (a rows-sized editor surface, not a column shape) useGrouping: 'presentation', options: 'presentation', // select values: validation + UI, no DDL accept: 'presentation', // upload validation diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 8569c0b546..db0d6a2851 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -531,12 +531,16 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { for (const value of Object.values(rec)) corrupt(value); }; corrupt(corrupted.views); - // The count tracks the CORPUS, not an issue: 49 today because #13216 added + // The count tracks the CORPUS, not an issue: 49 after #13216 added // a `page` section to `view.form.ts` — the surface block for the new `page` // view type, gated by `visibleWhen: "data.type == 'page'"` exactly as every // other surface block is — so the walk has one more predicate to reach. + // It is 51 today: objectui#6140 (maintainer ruling 2026-08-25, Option A) + // declared `rows` on the multiline editor types, adding one + // `data.type in […]`-gated row to the field form AND one to the object + // form's fields repeater — two more predicates for the walk to reach. // Earlier measurements stay what they were: history, not the census. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(49); + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(51); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -630,11 +634,15 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // form ships — the class the issue measured, and the class this rule exists // to have caught before it shipped. // - // The count tracks the CORPUS, not the issue: it is 17 today because + // The count tracks the CORPUS, not the issue: it was 17 after // #11410 split `deleteBehavior` into two declarations with disjoint // `visibleWhen` (`lookup` / `master_detail`), so a `master_detail` author is - // no longer offered a `set_null` the schema refuses. #6254's own measurement - // was 16 and stays 16 — that number is history, this one is a census. + // no longer offered a `set_null` the schema refuses. It is 18 today: + // objectui#6140 added a `rows` row to the object form's fields repeater, + // gated by `data.type in ['textarea','markdown','html','richtext']` — one + // more `data.type`-rooted predicate for the debare walk to restore. #6254's + // own measurement was 16 and stays 16 — that number is history, this one + // is a census. const objectForm = structuredClone(METADATA_FORM_REGISTRY.object) as Record; let restored = 0; const debare = (node: unknown): void => { @@ -664,10 +672,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { expect( restored, "the object form's `data.type`-rooted predicates are no longer where this test looks", - ).toBe(17); + ).toBe(18); const findings = validatePredicatePathRefs({ views: [objectForm] }); - expect(findings).toHaveLength(17); + expect(findings).toHaveLength(18); expect(new Set(findings.map((f) => f.rule))).toEqual(new Set([PREDICATE_PATH_UNROOTED])); expect(findings[0].message).toContain('`type`'); }); From 9c2f86676b64545fbe508c7bbc01596fdeff16bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 07:51:02 +0000 Subject: [PATCH 6/7] docs(permissions): re-derive the system-context census anchor on the merged tree Discharges the regeneration owed by the preceding merge commit: `node scripts/check-system-context-census.mjs --fix` re-anchored the one line-rotted citation; the gate is green on the merged tree. --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index dbef79ec23..6e3fca479f 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "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:9492`–`9509` | -| "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:1451` (#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:1514` (#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:273` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` | From 5a4b5e4b2d96ce94875aa19fb7ef6421201b2719 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:09:33 +0000 Subject: [PATCH 7/7] docs(permissions): re-derive the system-context census anchor on the merged tree --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e35abe539a..1d512782fd 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` | | "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:9588`–`9605` | -| "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:1451` (#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:1514` (#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:286` | | "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |