From 6b4a140ff7a34ef8153277f80e0c6db90b0a9b35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:14:42 +0000 Subject: [PATCH 1/5] fix(spec): author-facing describes and refusals carry no service-interface names, ruling dates or foreign example ids Studio renders spec describes as form help (/meta/types schema + form) and the notify node's Template help from the service-automation descriptor; refusals are read at the save and install doors. The rationale those strings carried (II18nService/IEmailService names, "maintainer ruling ", "ruled ") moves into the code comments beside them; MANIFEST_ID_EXAMPLES becomes com.acme.crm / org.example.help-desk, and the package-id refusal leads with a product-words headline and names the key as a locator after it. WIP: generated artifacts not yet regenerated. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../es-ES.metadata-forms.generated.ts | 2 +- .../ja-JP.metadata-forms.generated.ts | 2 +- .../zh-CN.metadata-forms.generated.ts | 2 +- .../src/builtin/notify-node.test.ts | 5 ++ .../src/builtin/notify-node.ts | 9 ++- .../src/automation/io-node-config.test.ts | 10 +-- .../spec/src/automation/io-node-config.zod.ts | 6 +- packages/spec/src/data/field.zod.ts | 13 +++- packages/spec/src/data/filter.test.ts | 6 +- packages/spec/src/data/filter.zod.ts | 23 +++---- packages/spec/src/kernel/manifest.test.ts | 17 ++++++ packages/spec/src/kernel/manifest.zod.ts | 29 ++++++--- .../spec/src/system/email-template.form.ts | 6 +- packages/spec/src/ui/page.zod.ts | 8 ++- .../src/ui/view-form-features-root.test.ts | 9 +-- .../src/ui/view-submit-redirect-url.test.ts | 32 ++++++---- packages/spec/src/ui/view.zod.ts | 61 +++++++++---------- 17 files changed, 156 insertions(+), 84 deletions(-) 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 670cc2688af..cf5cec815a4 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 @@ -2180,7 +2180,7 @@ export const esESMetadataForms: NonNullable = sections: { identity: { label: "Identidad", - description: "Identificador de plantilla que resuelve IEmailService.sendTemplate({ template: name, locale, ... })." + description: "Los remitentes hacen referencia a esta plantilla por su nombre; la configuración regional determina qué versión de idioma se envía." }, subject: { label: "Asunto", 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 a73acc441f0..5d3ae3db800 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 @@ -2180,7 +2180,7 @@ export const jaJPMetadataForms: NonNullable = sections: { identity: { label: "ID", - description: "IEmailService.sendTemplate({ template: name, locale, ... }) が解決するテンプレート識別子。" + description: "送信側はこのテンプレートを名前で指定し、ロケールによって送信される言語版が選ばれます。" }, subject: { label: "件名", 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 cfc39e06ffb..b3974c3ba3e 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 @@ -2180,7 +2180,7 @@ export const zhCNMetadataForms: NonNullable = sections: { identity: { label: "基础信息", - description: "模板标识符,由 IEmailService.sendTemplate({ template: name, locale, ... }) 解析。" + description: "发送方按名称引用此模板;语言区域决定发送哪个语言版本。" }, subject: { label: "主题", diff --git a/packages/services/service-automation/src/builtin/notify-node.test.ts b/packages/services/service-automation/src/builtin/notify-node.test.ts index f115c567a22..ea87828abde 100644 --- a/packages/services/service-automation/src/builtin/notify-node.test.ts +++ b/packages/services/service-automation/src/builtin/notify-node.test.ts @@ -98,6 +98,11 @@ describe('notify (baseline node)', () => { expect(description).toMatch(/payload\.locale is not consulted/); expect(description).not.toMatch(/not one per recipient/); expect(description).not.toMatch(/ONE value for the whole notification/); + // Form help reads as product guidance (#22093): the deployment-default + // rung is named in product words, not as the service interface behind + // it, and the ruling's date stays in the code comment beside the key. + expect(description).not.toMatch(/\bI[A-Z]\w*Service\b/); + expect(description).not.toMatch(/\bruling\b|\b20\d\d-\d\d-\d\d\b/i); }); describe('with a messaging service registered', () => { diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 10f2bf7e913..f479340939e 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -198,9 +198,16 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // mutual exclusion with title/message lives in the Zod // contract's superRefine (executed at parse time), matching // how requiredness is owned there rather than by the form. + // + // This description is the Studio notify inspector's Template + // help (served at `GET /api/v1/automation/actions`), so it + // reads as product guidance (#22093): the deployment-default + // rung is `II18nService.getDefaultLocale()`, and per-recipient + // resolution is the maintainer ruling of 2026-09-01 (#13881) — + // both recorded here, neither in the help text. template: { type: 'string', - description: 'Email template name (sys_email_template.name) — the localizable content path: resolved by (name, locale) at delivery time, rendering subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient\'s own sys_user.locale when set, else the deployment default (II18nService.getDefaultLocale()) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set payload.locale is not consulted. Mutually exclusive with inline title/message.', + description: 'Email template name (sys_email_template.name) — the localizable content path: resolved by (name, locale) at delivery time, rendering subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient\'s own sys_user.locale when set, else the deployment default locale — so recipients whose personal languages differ receive different rows of the same bundle. The node\'s payload.locale is not consulted. Mutually exclusive with inline title/message.', }, templateData: { type: 'object', diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index a6d6dc0c1da..483ea2afe99 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -344,16 +344,18 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => expect(templateDoc).toMatch(/per recipient/); expect(templateDoc).toContain('`sys_user.locale`'); expect(templateDoc).toMatch(/deployment default/); - expect(templateDoc).toContain('II18nService.getDefaultLocale()'); + // Studio renders this describe as form help: the rung is named in product + // words, never as the service interface that implements it (#22093). + expect(templateDoc).not.toMatch(/\bI[A-Z]\w*Service\b/); expect(templateDoc).not.toMatch(/not one per recipient/); expect(templateDoc).not.toMatch(/one value for the whole notification/i); // The producer's pre-ruling knob is named as NOT consulted, so an author // who still writes `payload.locale` learns from the contract that it is // inert rather than from a recipient who got the wrong language. expect(templateDoc).toMatch(/`payload\.locale` is not consulted/); - // The ruling is dated, so the text carries its own provenance rather - // than reading as a permanent limitation of the design. - expect(templateDoc).toContain('2026-09-01'); + // The ruling's provenance lives in the code comment above the key, not + // in the help text an author reads (#22093). + expect(templateDoc).not.toMatch(/\bruling\b|\b20\d\d-\d\d-\d\d\b/i); // …and it is a RAW cross-reference, like topic/channels. expect(templateDoc).toMatch(/no `\{token\}` interpolation/i); diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index ec3eb1259e9..ddcf4007e69 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -245,9 +245,13 @@ export const NotifyConfigSchema = lazySchema(() => strictObject({ * * Read RAW like `topic`/`channels`: a static metadata cross-reference, never * interpolated. Mutually exclusive with inline `title`/`message`. + * + * The describe below is form help an author reads: it states the behaviour + * and carries neither the service interface nor the ruling date — both live + * in this comment only (#22093). */ template: z.string().optional() - .describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient\'s own `sys_user.locale` when set, else the deployment default (`II18nService.getDefaultLocale()`) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'), + .describe('Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient\'s own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different rows of the same bundle. The node\'s `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation.'), /** * Render context for the referenced template's `{{var}}` holes. Values are * interpolated per run (`{record.x}` resolves), so flow state can feed the diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 17002393fb6..e326f19d2b3 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1137,9 +1137,11 @@ export const FieldSchema = lazySchema(() => { * array: an emptied required set fails validation loudly — `[]` does not * satisfy `required` (#9447, maintainer ruling 2026-08-18). The empty set is * always representable (it reads back as `[]`, never `null` — see - * `multiple`), so the required check judges emptiness, not absence. + * `multiple`), so the required check judges emptiness, not absence. The + * describe is form help in Studio and carries the rule, not the ruling date + * (#22093). */ - required: z.boolean().default(false).describe('Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field.'), + required: z.boolean().default(false).describe('Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field.'), /** * Physical storage constraints (ADR-0113). Deliberately separate from the @@ -1166,8 +1168,13 @@ export const FieldSchema = lazySchema(() => { * readers (generated code, formula/filter predicates) never need a null * branch. Same ruling: `required` on a multi-value lookup means non-empty * array (see `required` above). + * + * Declarability: `multiple: true` outside the multi-capable types is refused + * at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower + * 2026-08-22 ruling. The rulings and their dates live in these comments; the + * describe is form help in Studio and carries only the rule (#22093). */ - multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18).'), + multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair.'), // `true` = unique WITHIN the tenant on a tenant-scoped object (composite // `(tenantField, field)` index); `'global'` = platform-wide single-column // unique. See {@link UniqueScopeSchema} for the scope vocabulary (ADR-0120). diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index d356b1d391f..86a7a7b73db 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -641,7 +641,9 @@ describe('RangeOperatorSchema', () => { const message = issuesOf(RangeOperatorSchema.safeParse({ $between: ['2026-01-01', ''] }))[0]?.message ?? ''; expect(message).toContain('{"$gte": min}'); expect(message).toContain('{"$lte": max}'); - expect(message).toContain('Ruled 2026-09-17'); + // The ruling's date lives in the code comment, not in the refusal an + // author reads (#22093). + expect(message).not.toMatch(/\bRuled\b|\bruling\b|\b20\d\d-\d\d-\d\d\b/i); }); it('refuses an ABSENT bound with the pointed message, not zod\'s generic union text', () => { @@ -658,7 +660,7 @@ describe('RangeOperatorSchema', () => { // would be sent to a scalar comparison instead of the null predicate. const message = issuesOf(RangeOperatorSchema.safeParse({ $between: [null, '2026-12-31'] }))[0]?.message ?? ''; expect(message).toContain('{"$null": true}'); - expect(message).not.toContain('Ruled 2026-09-17'); + expect(message).not.toContain('A blank value is not a valid $between endpoint'); }); it('is matched by the enforced copy and by the whole-filter face', () => { diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 8e993b6b473..ce9cca8e2a4 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -472,8 +472,7 @@ function nullOrderingComparandMessage(op: string): string { + 'stored null as equal to it, so {"$gte": null} admits that row; driver-sql compares ' + 'against SQL NULL and admits no row). State absence ' + 'with the null predicate instead: {"$eq": null} is "has no value", {"$ne": null} is ' - + '"has a value". Ruled 2026-09-01: a null ordering comparand is refused at the validation ' - + 'entrance.' + + '"has a value".' ); } @@ -547,8 +546,9 @@ function isFieldReferenceShape(value: unknown): boolean { * path whose answer is a wrong row set rather than an error. So this message * keeps the literal-value escape, replaces the in-memory escape with the * position that genuinely works (the reference as the WHOLE comparand of a - * scalar comparison, which #5222 compiles), and states the ruling that removed - * the position so the change is attributable from the error alone. + * scalar comparison, which #5222 compiles). The ruling that removed the + * position is recorded in the comment inside the builder, not in the message + * (#22093): the author acts on the rule and the repair, not on its provenance. */ function listPositionFieldReferenceMessage(position: string): string { return ( @@ -557,11 +557,10 @@ function listPositionFieldReferenceMessage(position: string): string { + 'unresolved and compares the raw reference OBJECT, so it silently matches nothing, and ' + 'both SQL drivers refuse the position with INVALID_FILTER / 400. Write a literal value ' + 'here, or move the reference to a scalar comparison operator ' - + '($eq/$ne/$gt/$gte/$lt/$lte), whose WHOLE comparand a { $field } reference may be. ' - // The removal ruling of 2026-08-11 lives on the tracker (#7596) — internal - // readers get the id here; the customer-facing sentence keeps the date and - // the customer-resolvable ADR anchor only. - + 'Ruled 2026-08-11: declared = enforced (ADR-0049).' + + '($eq/$ne/$gt/$gte/$lt/$lte), whose WHOLE comparand a { $field } reference may be.' + // The removal was ruled 2026-08-11 on the tracker (#7596) under ADR-0049's + // declared = enforced. The id, the date and the anchor live here; the + // customer-facing sentence states the rule and the repair only (#22093). ); } @@ -585,8 +584,7 @@ function nullListComparandMemberMessage(position: string): string { + 'unconditionally; the JS matchers split over the two readings of "no value"). State ' + 'absence explicitly with the null predicate instead: ' + '{"$or": [{"$in": […]}, {"$null": true}]} is "one of […] OR has no value", and ' - + '{"$null": false} is the has-a-value half. ' - + 'Ruled 2026-08-31: a null list member is refused at the validation entrance.' + + '{"$null": false} is the has-a-value half.' ); } @@ -797,8 +795,7 @@ function blankRangeBoundMessage(index: 0 | 1): string { + 'range stops bounding on that side while still reading as a complete range. Write the ' + 'bound you meant; and if only ONE side is genuinely bounded, that is not a range at all ' + '— drop $between and write the side you have as a scalar comparison ' - + '({"$gte": min} for a lower bound, {"$lte": max} for an upper one). ' - + 'Ruled 2026-09-17: a blank $between bound is refused at the validation entrance.' + + '({"$gte": min} for a lower bound, {"$lte": max} for an upper one).' ); } diff --git a/packages/spec/src/kernel/manifest.test.ts b/packages/spec/src/kernel/manifest.test.ts index ced42a5eb53..966a5c432b3 100644 --- a/packages/spec/src/kernel/manifest.test.ts +++ b/packages/spec/src/kernel/manifest.test.ts @@ -686,6 +686,23 @@ describe('manifest.id — reverse-domain identifier', () => { for (const example of MANIFEST_ID_EXAMPLES) expect(msg).toContain(example); }); + it('reads as product guidance: a headline in product words, neutral examples (#22093)', () => { + // The Studio-measured case: a display name typed where the id goes. + const r = ManifestSchema.safeParse(legal('Repairs Center')); + const issue = r.success ? undefined : r.error.issues.find((i) => i.path[0] === 'id'); + expect(issue?.code).toBe('invalid_format'); + const msg = issue?.message ?? ''; + const headline = msg.slice(0, msg.indexOf('. ') + 1); + expect(headline).toContain("'Repairs Center'"); + // The key is still named (#4001), as a locator after the headline — the + // sentence an author reads first does not lead with a JSON path. + expect(headline).not.toContain('manifest.id'); + expect(msg).toContain('(`manifest.id`)'); + // The examples are in no real vendor's namespace. + expect(msg).not.toMatch(/steedos|superset|apache/i); + for (const example of MANIFEST_ID_EXAMPLES) expect(example).not.toMatch(/steedos|superset|apache/i); + }); + it('suggests com.example. for a bare word', () => { expect(refusalFor('blank')).toContain("Did you mean 'com.example.blank'?"); }); diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 7753e21fac0..f691948f5c7 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -279,13 +279,28 @@ export const MANIFEST_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/ * reject: `manifest.test.ts` asserts every entry here matches * {@link MANIFEST_ID_PATTERN}. An example that fails its own rule teaches the * exact wrong thing to the author who is already stuck. + * + * Both are neutral reverse-domain ids (#22093): `com.acme.crm` is the id the + * Studio package dialog already offers, and `org.example.help-desk` shows the + * other common prefix and an inner hyphen. They used to be two real products' + * ids (`com.steedos.crm`, `org.apache.superset`), which put another vendor's + * namespace in front of every author refused at the package door. */ -export const MANIFEST_ID_EXAMPLES = ['com.steedos.crm', 'org.apache.superset'] as const; +export const MANIFEST_ID_EXAMPLES = ['com.acme.crm', 'org.example.help-desk'] as const; /** * The remedy a rejected package id carries (#4001: a refusal names the key, * echoes the value, and prescribes the fix). * + * The headline sentence names the thing in product words — "Invalid package + * id ''." — and the authoring key rides in the second sentence as a + * parenthetical locator (#22093). The key stays in the text because some + * doors that answer with this sentence return it as a bare message with no + * separate location field (`POST /api/v1/packages` answers + * `deps.error(message, 400)`, no details), and the key is what tells a caller + * which of several id-shaped inputs was refused. It no longer leads, so a + * surface that shows the headline alone shows no JSON path. + * * The suggestion arm is deliberately conditional. A bare word — the shape the * scaffolder and `os init` used to produce, and what an author reaches for * first — has one obvious repair, `com.example.`, and offering it is the @@ -300,11 +315,11 @@ export const MANIFEST_ID_EXAMPLES = ['com.steedos.crm', 'org.apache.superset'] a */ export function manifestIdRefusal(key: string, input: unknown): string { const received = typeof input === 'string' ? input : String(input ?? ''); - const examples = MANIFEST_ID_EXAMPLES.map((e) => `'${e}'`).join(', '); + const examples = MANIFEST_ID_EXAMPLES.map((e) => `'${e}'`).join(' or '); const base = - `Invalid package id '${received}' on \`${key}\`. Expected reverse-domain notation ` - + `(${examples}) — lowercase dot-separated segments of letters, digits and inner hyphens; ` - + 'a segment may not open with a hyphen; underscores are not admitted.'; + `Invalid package id '${received}'. A package id (\`${key}\`) is written in reverse-domain ` + + `notation, like ${examples} — lowercase dot-separated segments of letters, digits and ` + + 'inner hyphens; a segment may not open with a hyphen; underscores are not admitted.'; // Two mechanical repairs, tried in order, and only ever OFFERED once the // candidate has been checked against the pattern itself: @@ -357,8 +372,8 @@ export const ManifestSchema = strictObject({ * Both examples below are held against the pattern by `manifest.test.ts` via * {@link MANIFEST_ID_EXAMPLES}. * - * @example "com.steedos.crm" - * @example "org.apache.superset" + * @example "com.acme.crm" + * @example "org.example.help-desk" */ id: z.string() .regex(MANIFEST_ID_PATTERN, { error: (iss) => manifestIdRefusal('manifest.id', iss.input) }) diff --git a/packages/spec/src/system/email-template.form.ts b/packages/spec/src/system/email-template.form.ts index 0afb4f2990e..652ea7fd09c 100644 --- a/packages/spec/src/system/email-template.form.ts +++ b/packages/spec/src/system/email-template.form.ts @@ -16,7 +16,11 @@ export const emailTemplateForm = defineForm({ sections: [ { label: 'Identity', - description: 'Template identifier resolved by IEmailService.sendTemplate({ template: name, locale, ... }).', + // Delivery resolves a template through + // `IEmailService.sendTemplate({ template: name, locale, ... })`. This + // description is form help in Studio, so it says what that means for + // the author and leaves the interface name here (#22093). + description: 'Senders address this template by its name; the locale selects which language version of it is sent.', columns: 2, fields: [ { field: 'name', required: true, colSpan: 1, helpText: 'Dotted snake_case (e.g. auth.password_reset, crm.welcome)' }, diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 6b13a7df8e4..a4ac77be3a8 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -924,6 +924,10 @@ export const PageSchema = lazySchema(() => strictObject({ * re-authoring the rest of the page. * * Only meaningful when `type === 'record'`. Ignored otherwise. + * + * The no-Tailwind styling rule is ADR-0065 plus ADR-0080's 2026-06-30 + * amendment; the describe cites the ADRs and leaves the amendment date here, + * since it is form help in Studio (#22093). */ kind: z.enum(['full', 'slotted', 'html', 'react', 'jsx']).default('full') .describe( @@ -938,7 +942,7 @@ export const PageSchema = lazySchema(() => strictObject({ "disabled server-side via the OS_PAGE_REACT=off env toggle. " + "Do not author Tailwind classes in page source in either tier: `source` is " + "runtime metadata the build-time Tailwind never scans, so utility classNames " + - "silently produce no CSS (ADR-0065; ADR-0080 amendment 2026-06-30).", + "silently produce no CSS (ADR-0065; ADR-0080).", ), /** @@ -987,7 +991,7 @@ export const PageSchema = lazySchema(() => strictObject({ * styles via inline `style` with the same token colors. */ source: z.string().optional() - .describe("Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080 amendment 2026-06-30). Authoritative over `regions` in both."), + .describe("Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both."), /** * Plugin namespaces an html page's source uses (ADR-0080 §5; ADR-0048 * provenance). Derived from the source at save, so authors omit it. The key diff --git a/packages/spec/src/ui/view-form-features-root.test.ts b/packages/spec/src/ui/view-form-features-root.test.ts index dd5419c7178..2b685b95f84 100644 --- a/packages/spec/src/ui/view-form-features-root.test.ts +++ b/packages/spec/src/ui/view-form-features-root.test.ts @@ -63,11 +63,12 @@ function expectFeaturesRefusal( expect(issue, `expected a refusal at ${JSON.stringify(path)}, got ${JSON.stringify(issues)}`).toBeDefined(); expect(issue!.code).toBe('custom'); // The identity an author (and an AI author's retry loop) acts on: the root, - // the surface, the fail-open reason, the ruling, and the prescription. + // the surface, the fail-open reason, and the prescription. expect(issue!.message).toContain('Form-view predicates may not name the `features.*` scope root'); - expect(issue!.message).toContain('ruled 2026-08-27'); - // The negative twin of the citation pin: the ruling is cited by DATE, never - // by a tracker id a refused author cannot resolve (commit fd289be45's strip). + // Its provenance is not part of that identity: neither the ruling's date + // (#22093) nor a tracker id a refused author cannot resolve (commit + // fd289be45's strip) is in the sentence — both live in the code comment. + expect(issue!.message).not.toMatch(/\bruled\b|\bruling\b|\b20\d\d-\d\d-\d\d\b/i); expect(issue!.message).not.toMatch(/(? ({ ...FORM_BASE, submitBehavior: { kind: 'redirect', url } }); @@ -101,13 +109,13 @@ describe('ruled bullet 1 — relative paths only', () => { ['data', 'data:text/html,hi'], ['mailto', 'mailto:sales@example.com'], ['scheme-only', 'app-custom:whatever'], - ])('refuses an absolute URL (%s) and names the rule + the ruling', (_label, url) => { + ])('refuses an absolute URL (%s) and names the rule', (_label, url) => { const msg = reject(redirectTo(url)); expect(msg, 'names the rule').toContain('RELATIVE path only'); expect(msg, 'names the reason the rule exists').toContain('open'); - expect(msg, 'cites the ruling so the refusal is traceable').toContain('ruled 2026-08-11'); - // The negative twin: traceable by DATE, never by a tracker id a refused - // author cannot resolve (commit fd289be45's strip). + expect(msg, 'states the rule, not its ruling date (#22093)').not.toMatch(RULING_DATE); + // Nor a tracker id a refused author cannot resolve (commit fd289be45's + // strip): the provenance lives in the code comment above the checker. expect(msg).not.toMatch(/(? { const msg = reject(redirectTo('//evil.example/thanks')); expect(msg).toContain('protocol-relative'); expect(msg).toContain('ANOTHER ORIGIN'); - expect(msg).toContain('ruled 2026-08-11'); + expect(msg).not.toMatch(RULING_DATE); }); it.each([ @@ -135,7 +143,7 @@ describe('ruled bullet 1 — relative paths only', () => { expect(msg, 'says WHY a backslash is an origin problem, not a style problem') .toContain('normalise'); expect(msg, 'gives the escape for a legitimate backslash').toContain('%5C'); - expect(msg).toContain('ruled 2026-08-11'); + expect(msg).not.toMatch(RULING_DATE); }); it.each([ @@ -151,7 +159,7 @@ describe('ruled bullet 1 — relative paths only', () => { expect(msg).toContain('whitespace or control characters'); expect(msg, 'says why stripping is the hazard').toContain('strip'); expect(msg, 'gives the escape for a legitimate space').toContain('%20'); - expect(msg).toContain('ruled 2026-08-11'); + expect(msg).not.toMatch(RULING_DATE); }); it('refuses a document-relative path and explains what it resolves against', () => { @@ -161,7 +169,7 @@ describe('ruled bullet 1 — relative paths only', () => { const msg = reject(redirectTo('thanks')); expect(msg).toContain('must start with `/`'); expect(msg).toContain('document-relative'); - expect(msg).toContain('ruled 2026-08-11'); + expect(msg).not.toMatch(RULING_DATE); }); it.each([ @@ -177,7 +185,7 @@ describe('ruled bullet 1 — relative paths only', () => { // leading-slash message would be a confusing thing to read for it. const msg = reject(redirectTo('')); expect(msg).toContain('needs a `url`'); - expect(msg).toContain('ruled 2026-08-11'); + expect(msg).not.toMatch(RULING_DATE); }); }); @@ -210,7 +218,7 @@ describe('ruled bullet 2 — `{{record.}}` and nothing else', () => { const msg = reject(redirectTo(url)); expect(msg, 'names the vocabulary').toContain('ONLY declared record fields'); expect(msg, 'gives the spelling verbatim').toContain('{{record.field_name}}'); - expect(msg).toContain('ruled 2026-08-11'); + expect(msg).not.toMatch(RULING_DATE); }); it('the refusal states the URL-escaping half of the ruling', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 822880f5e3e..ca982ecc351 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1169,10 +1169,10 @@ export const RowHeightSchema = lazySchema(() => z.enum([ * --------------------------------------------------------------------------- */ -// The ruling is objectui#7347 ruling C (decision batch #110 item 5) — internal -// readers get the ids here; the author-facing sentence carries the date only, -// which is what a refused author can act on. -const GROUPING_FIELD_RULING = 'ruled 2026-09-10'; +// The ruling is objectui#7347 ruling C (decision batch #110 item 5), ruled +// 2026-09-10. Internal readers get the ids and the date here; the author-facing +// sentence carries neither — it states the rule and the repair, which is what a +// refused author can act on (#22093). /** * A field-reference name with no leading and no trailing whitespace. @@ -1218,7 +1218,7 @@ function checkGroupingFieldName(raw: string): string | undefined { + 'the server answers under the unpadded name, so a padded spelling makes every renderer\'s ' + 'per-row lookup miss: the grid and the gallery show a single `(empty)` group and the kanban ' + 'a single `Uncategorized` lane holding every record — a wrong answer that reads as a true ' - + `statement about the data. ${remedy} (${GROUPING_FIELD_RULING}.)`; + + `statement about the data. ${remedy}`; } /* @@ -3441,7 +3441,7 @@ const FormFieldBaseSchema = lazySchema(() => { * this one is ENFORCED: see {@link checkFormViewPredicateFeaturesRoot} for * the ruling and the scanner. */ - visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'`"), + visibleWhen: EvaluatedExpressionInputSchema.optional().describe("Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'`"), /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibleOn: EvaluatedExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'), @@ -3652,7 +3652,7 @@ export const FormSectionSchema = lazySchema(() => strictObject({ 'Whether the section renders a disclosure control, so a reader can close it and open it again. ' + 'Default `false`: a section declaring neither collapse key is always open and shows no control. ' + '⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take ' - + 'the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never ' + + 'the control away. The renderer resolves that from the DECLARATION; parse never ' + 'rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is ' + 'refused on a wizard step and beside `group`; `false` is accepted in both, because it declares ' + 'exactly what those surfaces already deliver.', @@ -3660,9 +3660,8 @@ export const FormSectionSchema = lazySchema(() => strictObject({ collapsed: z.boolean().default(false).describe( 'Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is ' + 'sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that ' - + 'reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the ' - + 'combination at the declaration, and warning on it, were both rejected — nobody can depend on a ' - + 'section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: ' + + 'reopens it, and it outranks an explicit `collapsible: false`, so writing `collapsed: true` alone is ' + + 'a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: ' + '`{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed ' + '`collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not ' + 'collapse) and beside `group`, whose field group declares the pair.', @@ -3712,7 +3711,7 @@ export const FormSectionSchema = lazySchema(() => strictObject({ * refused at parse (ruled 2026-08-27, objectui#6262; see * {@link checkFormViewPredicateFeaturesRoot}). */ - visibleWhen: EvaluatedExpressionInputSchema.optional().describe('Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open.'), + visibleWhen: EvaluatedExpressionInputSchema.optional().describe('Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open.'), /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibleOn: EvaluatedExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse.'), columns: z.union([ @@ -3953,10 +3952,10 @@ function foldFormGroupsIntoSections( * `@objectstack/lint`, which already resolves field references against object * declarations. Enforcing half loudly beats enforcing none. */ -// The ruling is #7496 on the tracker — internal readers get the id here; the -// customer-facing sentence carries the date only, which is what a refused -// author can act on. -const SUBMIT_REDIRECT_RULING = 'ruled 2026-08-11'; +// The ruling is #7496 on the tracker, ruled 2026-08-11. Internal readers get +// the id and the date here; the customer-facing sentences carry neither — each +// states the rule, why it exists and the repair, which is what a refused author +// can act on (#22093). /** The ONE interpolation `submitBehavior.url` accepts. Global — used to strip. */ const SUBMIT_REDIRECT_URL_TOKEN_RE = /\{\{record\.[a-z_][a-z0-9_]*\}\}/g; @@ -3985,12 +3984,12 @@ const URL_SMUGGLE_RE = /[\s\u0000-\u001f\u007f]/; function checkSubmitRedirectUrl(raw: string): string | undefined { if (raw === '') { return 'A `redirect` submit behavior needs a `url` — an empty string is not a destination. ' - + `Write the in-app path the submitter should land on, e.g. \`/thanks\` (${SUBMIT_REDIRECT_RULING}).`; + + 'Write the in-app path the submitter should land on, e.g. `/thanks`.'; } if (URL_SCHEME_RE.test(raw)) { - return '`submitBehavior.url` accepts a RELATIVE path only, and this is an absolute URL ' - + `(${SUBMIT_REDIRECT_RULING}). A post-submit redirect that can leave the app is an open ` + return '`submitBehavior.url` accepts a RELATIVE path only, and this is an absolute URL. ' + + 'A post-submit redirect that can leave the app is an open ' + 'redirect, so the scheme form is refused at the authoring door rather than sanitized at the ' + 'renderer. Write the in-app path instead — `/thanks`, not `https://example.com/thanks`. ' + 'To send the browser OUT of the app deliberately, that is an app navigation item ' @@ -4000,14 +3999,14 @@ function checkSubmitRedirectUrl(raw: string): string | undefined { if (raw.startsWith('//')) { return '`submitBehavior.url` accepts a RELATIVE path only, and a leading `//` is ' + `protocol-relative — the browser reads \`//example.com/thanks\` as ANOTHER ORIGIN despite ` - + `the leading slash (${SUBMIT_REDIRECT_RULING}). Use a single leading slash for an in-app ` + + 'the leading slash. Use a single leading slash for an in-app ' + 'path; for a deliberate external link use an app navigation item (`{ type: \'url\', url }`).'; } if (raw.includes('\\')) { return '`submitBehavior.url` must not contain a backslash — browsers normalise `\\` to `/` ' + 'while resolving, so `/\\example.com` navigates off-origin exactly like `//example.com` ' - + `and would walk straight past the relative-only rule (${SUBMIT_REDIRECT_RULING}). ` + + 'and would walk straight past the relative-only rule. ' + 'Write the path with forward slashes; percent-encode a backslash that is genuinely part of ' + 'a path segment (`%5C`).'; } @@ -4015,7 +4014,7 @@ function checkSubmitRedirectUrl(raw: string): string | undefined { if (URL_SMUGGLE_RE.test(raw)) { return '`submitBehavior.url` must not contain whitespace or control characters — browsers strip ' + 'them before resolving, so a leading one hides what the address really starts with and ' - + `defeats the relative-only rule (${SUBMIT_REDIRECT_RULING}). Percent-encode a space that ` + + 'defeats the relative-only rule. Percent-encode a space that ' + 'belongs in the path (`%20`).'; } @@ -4026,7 +4025,7 @@ function checkSubmitRedirectUrl(raw: string): string | undefined { if (withoutTokens.includes('{') || withoutTokens.includes('}')) { const offender = withoutTokens.match(/\{\{?[^{}]*\}?\}?/)?.[0] ?? '{'; return '`submitBehavior.url` interpolates ONLY declared record fields, spelled ' - + `\`{{record.field_name}}\` — \`${offender}\` is not that shape (${SUBMIT_REDIRECT_RULING}). ` + + `\`{{record.field_name}}\` — \`${offender}\` is not that shape. ` + 'The record just submitted is the whole scope a post-submit redirect has, and the field ' + 'segment takes the same lowercase snake_case grammar fields are declared under. Every ' + 'interpolated value is URL-escaped when the redirect is built, so a token is a value in the ' @@ -4036,7 +4035,7 @@ function checkSubmitRedirectUrl(raw: string): string | undefined { if (!raw.startsWith('/')) { return '`submitBehavior.url` must start with `/` — a document-relative path like `thanks` ' + 'resolves against whichever console route the form happened to be opened from, so one form ' - + `lands in different places depending on how it was reached (${SUBMIT_REDIRECT_RULING}). ` + + 'lands in different places depending on how it was reached. ' + 'Write the rooted in-app path: `/thanks`.'; } @@ -4092,9 +4091,9 @@ function checkSubmitRedirectUrl(raw: string): string | undefined { * authoring shape is the source string, and build emits the AST from sources * this gate has already accepted. */ -// The ruling is objectui#6262 on the tracker — internal readers get the id -// here; the customer-facing sentence carries the date only. -const FORM_VIEW_FEATURES_RULING = 'ruled 2026-08-27'; +// The ruling is objectui#6262 on the tracker, ruled 2026-08-27. Internal +// readers get the id and the date here; the customer-facing sentence carries +// neither (#22093). /** CEL string literals (both quote styles, with escapes) — stripped before the root scan. */ const CEL_STRING_LITERAL_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g; @@ -4119,8 +4118,8 @@ function checkFormViewPredicateFeaturesRoot(predicate: unknown): string | undefi const { dialect, source } = predicate as { dialect?: unknown; source?: unknown }; if (dialect !== 'cel' || typeof source !== 'string') return undefined; if (!FEATURES_ROOT_RE.test(source.replace(CEL_STRING_LITERAL_RE, ''))) return undefined; - return 'Form-view predicates may not name the `features.*` scope root ' - + `(${FORM_VIEW_FEATURES_RULING}). A form view also renders on routes with no app context ` + return 'Form-view predicates may not name the `features.*` scope root. ' + + 'A form view also renders on routes with no app context ' + '(the console\'s standalone `/forms/:name` and the public `/f/:slug`), where `features` is ' + 'UNBOUND: the predicate faults and `visibleWhen` fails OPEN, so the field or section a ' + 'feature flag was meant to hide is shown to everyone. Gate by record state instead ' @@ -4441,7 +4440,7 @@ export const FormViewSchema = lazySchema(() => strictObject({ if (refusal) ctx.addIssue({ code: 'custom', message: refusal }); }) .describe( - 'Where the browser goes after a successful submit. Ruled 2026-08-11: ' + 'Where the browser goes after a successful submit. ' + '(1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative ' + 'URLs are refused, which is what closes the open-redirect face; ' + '(2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, ' @@ -4469,13 +4468,13 @@ export const FormViewSchema = lazySchema(() => strictObject({ message: 'The `next-record` behavior takes no options — it advances to the next record. For a confirmation panel use `{ kind: "thank-you", title, message }`.', }, }, { kind: z.literal('next-record') }), - // The `url` describe below carries the ruling in full, but it reaches only + // The `url` describe below carries the rule in full, but it reaches only // the generated JSON Schema: the references page renders a union as one type // expression, so a member's inner key gets no row of its own. The headline // therefore rides HERE, where the reference table does have a row (#7496). ]).optional().describe( "Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates " - + 'only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11).', + + 'only declared record fields as `{{record.field_name}}`, URL-escaped.', ), /** From 6a59d1e08209d35fe8f3909f2d232f15478d9049 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:55:57 +0000 Subject: [PATCH 2/5] chore(spec): regenerate reference docs; keep the skill-projected submitBehavior describe for a governed change The top-level `submitBehavior` describe is projected into skills/objectstack-ui/references/react-blocks.md by gen:react-blocks, a governed surface, so its ruling date stays in this wording sweep and the deferral is recorded beside it. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../22093-author-strings-internal-refs.md | 30 +++++++++++++++++++ .../references/automation/io-node-config.mdx | 2 +- content/docs/references/data/field.mdx | 4 +-- content/docs/references/data/object.mdx | 8 ++--- content/docs/references/system/migration.mdx | 8 ++--- content/docs/references/ui/component.mdx | 2 +- content/docs/references/ui/page.mdx | 4 +-- content/docs/references/ui/view.mdx | 24 +++++++-------- packages/spec/src/ui/view.zod.ts | 8 ++++- 9 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 .changeset/22093-author-strings-internal-refs.md diff --git a/.changeset/22093-author-strings-internal-refs.md b/.changeset/22093-author-strings-internal-refs.md new file mode 100644 index 00000000000..63b9c0713a7 --- /dev/null +++ b/.changeset/22093-author-strings-internal-refs.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': patch +'@objectstack/service-automation': patch +'@objectstack/platform-objects': patch +--- + +Form help and refusals an author reads no longer carry service-interface names, ruling dates or another product's ids + +Clause-②: no + +Wording only: no schema, key, type, export or error-code change. + +- The notify node's Template help (the `NotifyConfigSchema.template` describe and the Studio + inspector's copy in `@objectstack/service-automation`) names the deployment's default locale in + product words instead of `II18nService.getDefaultLocale()`, and drops its ruling date. +- `MANIFEST_ID_EXAMPLES` is now `com.acme.crm` and `org.example.help-desk` (was `com.steedos.crm` + and `org.apache.superset`). The package-id refusal opens with the headline + "Invalid package id 'VALUE'." and names the key in the sentence after it, so the headline alone + carries no JSON path; the rule, the examples and the suggestion follow unchanged in substance. A + caller that matched the old "on KEY. Expected reverse-domain notation" wording matches the + headline, or compares against `manifestIdRefusal()` by reference, instead. +- Ruling dates leave the describes Studio renders as form help: field `required` and `multiple`, + form-view field and section `visibleWhen`, section `collapsible` / `collapsed`, the redirect + arm's `submitBehavior.url`, and page `kind` / `source` (the ADR citations stay). They also + leave the refusals for padded grouping field names, `submitBehavior.url`, `features.*` in a + form-view predicate, and the four filter comparand refusals (null ordering comparand, + `{ $field }` in a list position, null list member, blank `$between` bound). Each sentence still + states the rule, why it exists and the repair. +- The email-template form's Identity section help says how senders address a template instead of + naming `IEmailService.sendTemplate`, in all four shipped locales. diff --git a/content/docs/references/automation/io-node-config.mdx b/content/docs/references/automation/io-node-config.mdx index d001908b2eb..08809da6a9d 100644 --- a/content/docs/references/automation/io-node-config.mdx +++ b/content/docs/references/automation/io-node-config.mdx @@ -97,7 +97,7 @@ const result = HttpConfigSchema.parse(data); | **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run | | **title** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification title — a template: a bare string, or a `{ dialect: 'template', source }` envelope (the `tmpl` helper) carrying the same text. It is interpolated per run with the flow's single-brace `{token}` placeholders (`{record.name}`); a `{{var}}` is not a placeholder here — its inner `{var}` resolves and the outer braces stay in the text. One text for every recipient (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. | | **message** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | Notification body — the same template input as `title` (a bare string or a `{ dialect: 'template', source }` envelope), interpolated per run with single-brace `{token}` placeholders; not localizable. Only valid with inline `title`, never with `template`. | -| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default (`II18nService.getDefaultLocale()`) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | +| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default locale — so recipients whose personal languages differ receive different rows of the same bundle. The node's `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. | | **templateData** | `Record` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. | | **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) | | **topic** | `string` | optional | Event topic (default: "notify") | diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 82013aa975c..276db1fe066 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -57,10 +57,10 @@ const result = CurrencyConfigSchema.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **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. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 8c945c4fc5c..8f03e532258 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -221,10 +221,10 @@ const result = ApiMethod.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **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. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | @@ -556,10 +556,10 @@ const result = ApiMethod.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **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. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index c9ae5f91671..c8336c048e1 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -58,10 +58,10 @@ Add a new field to an existing object | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **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. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | @@ -480,10 +480,10 @@ Add a new field to an existing object | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | | **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | -| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | +| **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | -| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type is REFUSED at parse (maintainer ruling 2026-09-13), and on `radio` by the narrower 2026-08-22 ruling. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair (maintainer ruling 2026-08-18). | +| **multiple** | `boolean` | optional (default: `false`) | Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); `multiple: true` on any other type, `radio` included, is REFUSED at parse. An emptied multi-value lookup reads back as `[]`, never `null` — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. | | **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an `autonumber` field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit `unique: true` produces). To opt an autonumber field out, write `unique: false` explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (`os migrate duplicates`) still treats every autonumber field as an identifier. | | **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. Checked on the WRITTEN value only (the `min`/`max` transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 2b692883e03..2160ec44059 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -686,7 +686,7 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **kind** | `'redirect'` | ✅ | | -| **url** | `string` | ✅ | Where the browser goes after a successful submit. Ruled 2026-08-11: (1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item (`{ type: 'url', url }`) instead. | +| **url** | `string` | ✅ | Where the browser goes after a successful submit. (1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item (`{ type: 'url', url }`) instead. | | **delayMs** | `integer` | optional | | ### Nested Shape: `ObjectFormProps.mobile` diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index bf8f3ae5a9d..58a1291e20f 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -181,9 +181,9 @@ View filter rule | **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (`sys_position_permission_set`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | -| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080 amendment 2026-06-30). | +| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). | | **slots** | `{ header?: object \| object[]; actions?: object \| object[]; alerts?: object \| object[]; highlights?: object \| object[]; … }` | optional | Slot override map for slotted pages | -| **source** | `string` | optional | Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080 amendment 2026-06-30). Authoritative over `regions` in both. | +| **source** | `string` | optional | Page source text. For kind==='html' (alias 'jsx') it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both. | | **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page (alias 'jsx'), the kinds whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 45aaee373d1..4de6caa9d3d 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -218,7 +218,7 @@ Column footer summary configuration | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | | **dependsOn** | `string` | optional | Parent field name for cascading | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). | | **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types | @@ -310,9 +310,9 @@ Form-view select option — the object-field option shape minus the per-option ` | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | -| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away. The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false`, so writing `collapsed: true` alone is a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | @@ -348,7 +348,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | | **dependsOn** | `string` | optional | Parent field name for cascading | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field `visibleWhen`, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: the root is unbound on the standalone form routes (`/forms/:name`, `/f/:slug`) and the predicate would fault open there. Inside a repeater `data` is the ROW, but it is still spelled `data` — a bare identifier is unbound and faults open too. e.g. P`record.priority == 'urgent'` | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). | | **fields** | `{ field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … }[]` | optional | Sub-fields for composite/repeater/record types | @@ -442,9 +442,9 @@ Form-view select option — the object-field option shape minus the per-option ` | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | -| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away. The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false`, so writing `collapsed: true` alone is a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | @@ -458,9 +458,9 @@ Form-view select option — the object-field option shape minus the per-option ` | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | -| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away. The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false`, so writing `collapsed: true` alone is a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | @@ -497,7 +497,7 @@ Form-view select option — the object-field option shape minus the per-option ` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **kind** | `'redirect'` | ✅ | | -| **url** | `string` | ✅ | Where the browser goes after a successful submit. Ruled 2026-08-11: (1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item (`{ type: 'url', url }`) instead. | +| **url** | `string` | ✅ | Where the browser goes after a successful submit. (1) RELATIVE paths only — it must start with `/`, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled `{{record.field_name}}`, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item (`{ type: 'url', url }`) instead. | | **delayMs** | `integer` | optional | | ### Nested Shape: `FormView.buttons` diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index ca982ecc351..37f1dc1fda1 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4472,9 +4472,15 @@ export const FormViewSchema = lazySchema(() => strictObject({ // the generated JSON Schema: the references page renders a union as one type // expression, so a member's inner key gets no row of its own. The headline // therefore rides HERE, where the reference table does have a row (#7496). + // + // ⚠️ This describe still carries its ruling date, deliberately left for a + // separate change (#22093): its text is projected into the published skill + // `skills/objectstack-ui/references/react-blocks.md` (`gen:react-blocks`), a + // governed surface, so editing it here would make this wording sweep land + // the way a governed PR does. ]).optional().describe( "Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates " - + 'only declared record fields as `{{record.field_name}}`, URL-escaped.', + + 'only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11).', ), /** From 34bf7293380f919010654445474d3c5360268e5a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:03:26 +0000 Subject: [PATCH 3/5] chore(platform-objects): regenerate the en metadata-forms bundle for the email-template Identity help `pnpm i18n:extract` output; the zh-CN / ja-JP / es-ES values were re-translated by hand in the first commit of this branch. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../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 09fd11a305d..43b766265be 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 @@ -2180,7 +2180,7 @@ export const enMetadataForms: NonNullable = { sections: { identity: { label: "Identity", - description: "Template identifier resolved by IEmailService.sendTemplate({ template: name, locale, ... })." + description: "Senders address this template by its name; the locale selects which language version of it is sent." }, subject: { label: "Subject", From 8119405af67a897cba3283220063139902839464 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:40:48 +0000 Subject: [PATCH 4/5] fix(spec): the action description help drops the dash a stripped reference left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "(one dialog, not two —)" read as a truncated sentence in Studio's action inspector; the ruling it once cited stays in the docblock above the key. Pinned on the authoring-shape JSON Schema Studio renders: no dash left dangling before a close paren, no ruling date, no service interface, no tracker id. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- .../22093-author-strings-internal-refs.md | 2 ++ .../spec/src/ui/action-description.test.ts | 26 +++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 5 ++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.changeset/22093-author-strings-internal-refs.md b/.changeset/22093-author-strings-internal-refs.md index 63b9c0713a7..567d54fce11 100644 --- a/.changeset/22093-author-strings-internal-refs.md +++ b/.changeset/22093-author-strings-internal-refs.md @@ -28,3 +28,5 @@ Wording only: no schema, key, type, export or error-code change. states the rule, why it exists and the repair. - The email-template form's Identity section help says how senders address a template instead of naming `IEmailService.sendTemplate`, in all four shipped locales. +- The action `description` help no longer ends in a dangling dash left behind by an earlier + strip: "(one dialog, not two —)" now reads "(one dialog, not two)". diff --git a/packages/spec/src/ui/action-description.test.ts b/packages/spec/src/ui/action-description.test.ts index 9b42bcba26b..b358b47d5d7 100644 --- a/packages/spec/src/ui/action-description.test.ts +++ b/packages/spec/src/ui/action-description.test.ts @@ -20,6 +20,7 @@ // correct. import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { ActionSchema, ActionParamSchema, InlineActionSchema } from './action.zod'; import { ObjectTranslationDataSchema, TranslationDataSchema } from '../system/translation.zod'; @@ -230,3 +231,28 @@ describe('actionTranslationSchema.description', () => { .toBe('该请求已被拒绝。'); }); }); + +describe('the action `description` describe — form help an author reads in Studio (#22093)', () => { + // Studio's action inspector renders this describe from the `/meta/types` + // schema, which derives `action` in the authoring (`io: 'input'`) shape. + const help = (() => { + const json = z.toJSONSchema(ActionSchema, { unrepresentable: 'any', io: 'input' }) as { + properties?: Record; + }; + return json.properties?.description?.description ?? ''; + })(); + + it('is present, so the pins below cannot pass on nothing', () => { + expect(help).toContain('one dialog, not two'); + }); + + it('carries no residue of a stripped reference — no dash left dangling before a close paren', () => { + expect(help).not.toMatch(/[—–-]\s*\)/); + }); + + it('carries no internal reference — no ruling date, no service interface, no tracker id', () => { + expect(help).not.toMatch(/\bruling\b|\bruled\b|\b20\d\d-\d\d-\d\d\b/i); + expect(help).not.toMatch(/\bI[A-Z]\w*Service\b/); + expect(help).not.toMatch(/(? strictObject({ * ruling on #7278 is to carry the confirm question here instead: one * condition, one wording, one dialog, nothing sent until its own Confirm. * `confirmText` stays correct for a param-LESS action, where the confirm IS - * the only dialog. + * the only dialog. The describe below is Studio form help and says only "one + * dialog, not two"; the ruling that decided it is recorded here (#22093). * * **Not `ai.description`.** That one is the LLM-facing tool contract * (≥40 chars, required when `ai.exposed`); this one is human-facing dialog * copy and is never sent to a model. */ - description: I18nLabelSchema.optional().describe('Explanatory line shown under the title in the action\'s param dialog. Carries the confirm question for an action that collects params (one dialog, not two —). Not the LLM-facing `ai.description`.'), + description: I18nLabelSchema.optional().describe('Explanatory line shown under the title in the action\'s param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`.'), /** Target object this action belongs to (optional, snake_case) */ objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional().describe('Target object this action belongs to. When set, the action is auto-merged into the object\'s actions array by defineStack().'), From 0262046319a13c871d5427ac8cc4181ce54a3c5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:52:33 +0000 Subject: [PATCH 5/5] chore(spec): regenerate reference docs on the merged head gen:schema && gen:docs after merging origin/main (54ace18c6): the only delta is the action description row. Main's regenerated docs reproduce byte-for-byte from the merged sources. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude --- content/docs/references/data/object.mdx | 2 +- content/docs/references/kernel/metadata-plugin.mdx | 2 +- content/docs/references/ui/action.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 8f03e532258..b318e2ef912 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -443,7 +443,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | -| **description** | `string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two —). Not the LLM-facing `ai.description`. | +| **description** | `string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 4207db71e36..23bd44626a0 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -313,7 +313,7 @@ const result = MetadataBulkResultSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | -| **description** | `string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two —). Not the LLM-facing `ai.description`. | +| **description** | `string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index cbf25b9d9db..b1d00f690e4 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -30,7 +30,7 @@ const result = ActionSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | Display label | -| **description** | `string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two —). Not the LLM-facing `ai.description`. | +| **description** | `string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Locations where this action is visible |