From ae4be69c85ad564da519f7afe537156bc5897a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 20:52:16 +0000 Subject: [PATCH 1/2] docs(spec): state the template dialect's real placeholder contract `TemplateExpressionInputSchema`'s docblock illustrated the `template` dialect with `{{var}}` interpolation, which reads as a declared grammar. It is not one: the schema judges the dialect tag and non-emptiness and nothing else, and the accepted placeholder spelling belongs to whichever renderer consumes the slot. Measured on this tree, the two spellings are not interchangeable everywhere, so "they are equivalent" would be imprecise in the other direction: - `@objectstack/formula`'s registered `templateEngine`, the messaging renderer, the email plugin and the i18n adapters match `{{var}}` only. - The `titleFormat` renderers accept both and normalize `{{var}}` down to `{var}` first, which is why single-brace `titleFormat` values are legal by construction. Docblock and `.describe()` prose only. No schema, export or accept-set movement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- packages/spec/src/data/object.zod.ts | 2 +- packages/spec/src/shared/expression.zod.ts | 24 ++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index d55e218b4f..c3acdefb4b 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2119,7 +2119,7 @@ const ObjectSchemaBase = strictObject( * cross-repo back-compat). New metadata should set `nameField`. */ displayNameField: z.string().optional().describe('[DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField.'), - titleFormat: TemplateExpressionInputSchema.optional().describe('[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField.'), + titleFormat: TemplateExpressionInputSchema.optional().describe('[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time.'), /** * [ADR-0085] Semantic role: the object's most important fields, in priority * order (the first entry wins wherever only one field fits, e.g. child-record diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index bbccf2c39b..78a47d454c 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -20,7 +20,7 @@ import { z } from 'zod'; * |:---|:---|:---| * | `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values | * | `cron` | none at parse time — `croner` fires it at schedule time, on the one wired slot | job schedules | - * | `template` | `{{var}}` interpolation at evaluate time (same variable scope as CEL) | notification subjects/bodies, `titleFormat`, prompt templates | + * | `template` | placeholder interpolation at evaluate time (same variable scope as CEL); canonically `{{var}}`, but the accepted spelling belongs to the renderer, not to this schema — see `TemplateExpressionInputSchema` | notification subjects/bodies, `titleFormat`, prompt templates | * * No cron syntax is judged at parse time: `croner` evaluates a cron slot only * when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule` → @@ -283,7 +283,27 @@ export type CronExpressionInput = z.input; * naming the fix (`TYPED_EXPRESSION_DIALECT_ONLY.template`), as is a blank * string (`TYPED_EXPRESSION_SOURCE_REQUIRED.template`). Use this for * notification subjects/bodies, titleFormat, prompt templates — anything with - * `{{var}}` interpolation. No template syntax is judged at parse time. + * placeholder interpolation. + * + * No template syntax is judged at parse time: this schema judges the dialect + * tag and non-emptiness and nothing else, so it declares no placeholder + * grammar and refuses no spelling. Which spelling actually interpolates + * belongs to the CONSUMING RENDERER, and the two spellings in use are not + * interchangeable everywhere: + * + * - `{{var}}` is the canonical form and the only one the registered `template` + * engine reads (`@objectstack/formula`'s `templateEngine`); the messaging, + * email and i18n renderers match it alone and leave a `{var}` in their + * output verbatim. + * - `titleFormat` is the exception: its renderers take `{{var}}` and `{var}` + * as equivalent, normalizing the double form down to the single one before + * substituting (objectui's `formatTitleTemplate`, and the title renderer in + * `@objectstack/metadata-protocol`). Both spellings resolve identically + * there — single-brace `titleFormat` values are legal by construction, not + * a grammar this schema failed to enforce. + * + * So write `{{var}}` unless the slot's renderer is known to normalize, and do + * not read either spelling as declared, preferred or rejected here. */ export const TemplateExpressionInputSchema = z.union([ typedExpressionStringArm('template'), From 760ca754d39c621f373617346006e8745a8437e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 20:59:04 +0000 Subject: [PATCH 2/2] docs(spec): regenerate references and add the patch changeset Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .../template-dialect-placeholder-contract.md | 14 ++++++++++++++ content/docs/references/api/metadata.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/shared/expression.mdx | 2 +- content/docs/references/system/migration.mdx | 4 ++-- 5 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 .changeset/template-dialect-placeholder-contract.md diff --git a/.changeset/template-dialect-placeholder-contract.md b/.changeset/template-dialect-placeholder-contract.md new file mode 100644 index 0000000000..2324c28b94 --- /dev/null +++ b/.changeset/template-dialect-placeholder-contract.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": patch +--- + +`TemplateExpressionInputSchema` documents what the `template` dialect actually accepts, instead of illustrating it with a grammar it does not judge. + +The docblock introduced the dialect as "anything with `{{var}}` interpolation". Nothing in the schema enforces that: `TemplateExpressionInputSchema` judges the dialect tag and non-emptiness and nothing else, and the same docblock already said so one clause later. Read as a declaration, it made every single-brace `titleFormat` value in the wild look like it was crossing a gate. There is no gate. + +The corrected prose states where the placeholder grammar really lives — with the renderer that consumes the slot — and that the two spellings in circulation are not interchangeable everywhere: + +- `{{var}}` is the canonical form and the only one the registered `template` engine reads (`@objectstack/formula`'s `templateEngine`); the messaging renderer, the email plugin and the i18n adapters match it alone and leave a `{var}` in their output verbatim. +- `titleFormat` is the exception: its renderers accept `{{var}}` and `{var}` as equivalent, normalizing the double form down to the single one before substituting. Single-brace `titleFormat` values are legal by construction, not a grammar the schema failed to enforce. + +`titleFormat`'s `.describe()` says the same thing at the slot. Documentation only — the schema's judging logic, its accept-set and its exports are untouched, so nothing an author can write changes meaning. Both strings publish (`dist/*.d.ts` and the bundled `.describe()`), which is why this is a `patch` rather than a `skip-changeset`. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 7fd0b71cd0..ccc34fe4db 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -787,7 +787,7 @@ Metadata query with filtering, sorting, and pagination | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index d6bdc51298..c8e7652aa9 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -162,7 +162,7 @@ const result = ApiMethod.parse(data); | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | diff --git a/content/docs/references/shared/expression.mdx b/content/docs/references/shared/expression.mdx index 9a13e648af..cad13b7554 100644 --- a/content/docs/references/shared/expression.mdx +++ b/content/docs/references/shared/expression.mdx @@ -22,7 +22,7 @@ envelope. |:---|:---|:---| | `cel` | `@objectstack/formula` (cel-js + ObjectStack stdlib) | formulas, predicates, seed dynamic values | | `cron` | none at parse time — `croner` fires it at schedule time, on the one wired slot | job schedules | -| `template` | `{{var}}` interpolation at evaluate time (same variable scope as CEL) | notification subjects/bodies, `titleFormat`, prompt templates | +| `template` | placeholder interpolation at evaluate time (same variable scope as CEL); canonically `{{var}}`, but the accepted spelling belongs to the renderer, not to this schema — see `TemplateExpressionInputSchema` | notification subjects/bodies, `titleFormat`, prompt templates | No cron syntax is judged at parse time: `croner` evaluates a cron slot only when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule` → diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 59e1be5802..16c6653d45 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -338,7 +338,7 @@ Create a new object | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). | @@ -622,7 +622,7 @@ Create a new object | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | | **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |