diff --git a/.changeset/typed-expression-envelope-dialect.md b/.changeset/typed-expression-envelope-dialect.md new file mode 100644 index 0000000000..a1f6128cb6 --- /dev/null +++ b/.changeset/typed-expression-envelope-dialect.md @@ -0,0 +1,63 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: a typed expression slot fixes its dialect on the envelope arm too, and refuses a blank string (#15028, #15035) + + + +**BREAKING** accept-set narrowing on the twelve authorable keys typed +`CronExpressionInputSchema` (`system/CronSchedule:expression`, +`ai/KnowledgeRefreshPolicy:cron`, `api/ScheduledExport` and +`api/ScheduleExportRequest` `schedule.cronExpression`, +`automation/ScheduleState:cronExpression`, `integration/DataSyncConfig:schedule`, +`system/CacheWarmup:schedule`, `system/BackupConfig:schedule`, +`system/DisasterRecoveryPlan` `testing.schedule`) and +`TemplateExpressionInputSchema` (`ai/PromptTemplate:system`, +`ai/PromptTemplate:user`, `data/Object:titleFormat`). Shipped as `minor` under +the repo's launch-window convention for breaking changes. Measured cost: zero +— of the 46 author values probed across the repo, the examples, the docs, the +skills and the objectui pin, every one is a bare string or a same-dialect +envelope. + +**What changes** (`packages/spec/src/shared/expression.zod.ts`): + +- The envelope arm of each typed schema is `ExpressionSchema` narrowed to that + one dialect literal. A cron-typed slot accepts a bare string or + `{ dialect: 'cron', source }` only; a template-typed slot likewise for + `template`. An envelope naming any other dialect — `cel` or `template` on a + cron slot, `cel` or `cron` on a template slot, or the retired `js` — is + refused with ONE `invalid_union` at the slot whose message is the slot's + dialect-only sentence (`TYPED_EXPRESSION_DIALECT_ONLY[dialect]`, exported). + Before, the arm was the unrestricted `ExpressionSchema`, so a cron slot + parsed a `cel` envelope green and whatever read it received an expression it + could not schedule — a copy-paste artifact of the untyped schema, never a + decision. +- The bare-string arm refuses a blank string — empty or whitespace-only, the + notion of blank `EvaluatedExpressionSchema` already applies (`source.trim()`) + — with ONE `invalid_union` at the slot whose message is the slot's + source-required sentence (`TYPED_EXPRESSION_SOURCE_REQUIRED[dialect]`, + exported). Before, `.min(1)` did not trim, so `' '` normalized to + `{ dialect: 'cron', source: ' ' }` on every typed slot. +- The author type narrows with it: `CronExpressionInput` / + `TemplateExpressionInput` no longer admit a foreign-dialect envelope, and the + published JSON Schema and the generated reference page declare the envelope's + `dialect` as that one literal. `TypedExpressionDialect` names the pair. + +**What does NOT change.** No cron syntax is judged at parse time; `croner` +judges it where a schedule is wired (`CronSchedule.expression`, the one cron +slot with a reader); no grammar is restated in spec. `'not a cron'` still +normalizes to `{ dialect: 'cron', source: 'not a cron' }`, deliberately: the +repo's two cron grammars already disagree on 5 of 32 probed patterns, and a +restatement would be a third. `ExpressionInputSchema` and `ExpressionSchema` +are untouched — the untyped envelope still takes every declared dialect, and an +envelope with neither `source` nor `ast` is refused exactly as before. + +```ts +// a cron-typed slot, e.g. defineStack({ jobs: [{ schedule: { type: 'cron', expression } }] }) +expression: '0 9 * * 1-5' // accepted, normalized to { dialect: 'cron', source } +expression: { dialect: 'cron', source: '0 9 * * 1-5' } // accepted verbatim +expression: { dialect: 'cel', source: 'now()' } // refused at jobs.0.schedule.expression +expression: ' ' // refused at jobs.0.schedule.expression +expression: 'not a cron' // accepted — syntax is croner's verdict at schedule time +``` diff --git a/content/docs/references/ai/knowledge-source.mdx b/content/docs/references/ai/knowledge-source.mdx index f5726eea87..0d839a946b 100644 --- a/content/docs/references/ai/knowledge-source.mdx +++ b/content/docs/references/ai/knowledge-source.mdx @@ -65,7 +65,7 @@ const result = FileKnowledgeSourceSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **onRecordChange** | `boolean` | optional (default: `true`) | | -| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. | +| **cron** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. | --- @@ -130,7 +130,7 @@ const result = FileKnowledgeSourceSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **onRecordChange** | `boolean` | optional (default: `true`) | | -| **cron** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. | +| **cron** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron-dialect expression for a periodic full reindex. A bare string is shorthand for `{ dialect: 'cron', source }`; the parse enforces a non-empty string or an expression envelope and normalizes to the envelope — cron syntax (5- or 6-field, or an `@` alias) is the `cron` dialect engine's verdict when the expression is evaluated, not checked here. `service-knowledge` does not schedule it: the value is surfaced so an automation flow / external scheduler can trigger `reindexSource`. | --- diff --git a/content/docs/references/ai/model-registry.mdx b/content/docs/references/ai/model-registry.mdx index 7c5d52054f..3b3d7a96d8 100644 --- a/content/docs/references/ai/model-registry.mdx +++ b/content/docs/references/ai/model-registry.mdx @@ -171,8 +171,8 @@ const result = ModelCapabilitySchema.parse(data); | **id** | `string` | ✅ | Unique template identifier | | **name** | `string` | ✅ | Template name (snake_case) | | **label** | `string` | ✅ | Display name | -| **system** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | System prompt — supports `{{var}}` interpolation | -| **user** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | User prompt template — supports `{{var}}` interpolation | +| **system** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | System prompt — supports `{{var}}` interpolation | +| **user** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | ✅ | User prompt template — supports `{{var}}` interpolation | | **assistant** | `string` | optional | Assistant message prefix | | **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; defaultValue?: any; … }[]` | optional | Template variables | | **modelId** | `string` | optional | Recommended model ID | @@ -260,8 +260,8 @@ const result = ModelCapabilitySchema.parse(data); | **id** | `string` | ✅ | Unique template identifier | | **name** | `string` | ✅ | Template name (snake_case) | | **label** | `string` | ✅ | Display name | -| **system** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | System prompt — supports `{{var}}` interpolation | -| **user** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | User prompt template — supports `{{var}}` interpolation | +| **system** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | System prompt — supports `{{var}}` interpolation | +| **user** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | ✅ | User prompt template — supports `{{var}}` interpolation | | **assistant** | `string` | optional | Assistant message prefix | | **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array'>; required?: boolean; defaultValue?: any; … }[]` | optional | Template variables | | **modelId** | `string` | optional | Recommended model ID | diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index cac214c442..4d4cc244b5 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -753,7 +753,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **cronExpression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression for schedule | +| **cronExpression** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression for schedule | | **timezone** | `string` | optional (default: `"UTC"`) | IANA timezone | ### Nested Shape: `ScheduleExportRequest.delivery` @@ -831,7 +831,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **cronExpression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression for schedule | +| **cronExpression** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression for schedule | | **timezone** | `string` | optional (default: `"UTC"`) | IANA timezone | ### Nested Shape: `ScheduledExport.delivery` diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index babae500fc..7fd0b71cd0 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: Enum<'cel' \| 'cron' \| '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. | | **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/automation/execution.mdx b/content/docs/references/automation/execution.mdx index a708052d72..0afffa193b 100644 --- a/content/docs/references/automation/execution.mdx +++ b/content/docs/references/automation/execution.mdx @@ -341,7 +341,7 @@ const result = CheckpointSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Schedule instance ID | | **flowName** | `string` | ✅ | Flow machine name | -| **cronExpression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression — cron`0 9 * * MON-FRI` | +| **cronExpression** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression — cron`0 9 * * MON-FRI` | | **timezone** | `string` | optional (default: `"UTC"`) | IANA timezone for cron evaluation | | **status** | `Enum<'active' \| 'paused' \| 'disabled' \| 'expired'>` | optional (default: `"active"`) | Current schedule status | | **nextRunAt** | `string` | optional | Next scheduled execution timestamp | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ea105ef57e..fd2db1e1fe 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: Enum<'cel' \| 'cron' \| '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. | | **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/integration/connector.mdx b/content/docs/references/integration/connector.mdx index f9f079593a..f91c078c52 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -280,7 +280,7 @@ Circuit breaker configuration | :--- | :--- | :--- | :--- | | **strategy** | `Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>` | optional (default: `"incremental"`) | Synchronization strategy | | **direction** | `Enum<'import' \| 'export' \| 'bidirectional'>` | optional (default: `"import"`) | Sync direction | -| **schedule** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled sync — cron`0 */15 * * *` | +| **schedule** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled sync — cron`0 */15 * * *` | | **realtimeSync** | `boolean` | optional (default: `false`) | Enable real-time sync | | **timestampField** | `string` | optional | Field to track last modification time | | **conflictResolution** | `Enum<'source_wins' \| 'target_wins' \| 'latest_wins' \| 'manual'>` | optional (default: `"latest_wins"`) | Conflict resolution strategy | @@ -626,7 +626,7 @@ Connector type | :--- | :--- | :--- | :--- | | **strategy** | `Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>` | optional (default: `"incremental"`) | Synchronization strategy | | **direction** | `Enum<'import' \| 'export' \| 'bidirectional'>` | optional (default: `"import"`) | Sync direction | -| **schedule** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled sync — cron`0 */15 * * *` | +| **schedule** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled sync — cron`0 */15 * * *` | | **realtimeSync** | `boolean` | optional (default: `false`) | Enable real-time sync | | **timestampField** | `string` | optional | Field to track last modification time | | **conflictResolution** | `Enum<'source_wins' \| 'target_wins' \| 'latest_wins' \| 'manual'>` | optional (default: `"latest_wins"`) | Conflict resolution strategy | @@ -763,7 +763,7 @@ Connector type | :--- | :--- | :--- | :--- | | **strategy** | `Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>` | optional (default: `"incremental"`) | Synchronization strategy | | **direction** | `Enum<'import' \| 'export' \| 'bidirectional'>` | optional (default: `"import"`) | Sync direction | -| **schedule** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled sync — cron`0 */15 * * *` | +| **schedule** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled sync — cron`0 */15 * * *` | | **realtimeSync** | `boolean` | optional (default: `false`) | Enable real-time sync | | **timestampField** | `string` | optional | Field to track last modification time | | **conflictResolution** | `Enum<'source_wins' \| 'target_wins' \| 'latest_wins' \| 'manual'>` | optional (default: `"latest_wins"`) | Conflict resolution strategy | diff --git a/content/docs/references/shared/expression.mdx b/content/docs/references/shared/expression.mdx index c9996ae7ef..9a13e648af 100644 --- a/content/docs/references/shared/expression.mdx +++ b/content/docs/references/shared/expression.mdx @@ -30,6 +30,13 @@ when `CronSchedule.expression` is scheduled (`toBoundaryJobSchedule` → cron-typed slot is parsed and reaches no engine, and `@objectstack/formula`'s registered `cron` engine has no caller outside that package. +A TYPED slot — one declared with `CronExpressionInputSchema` or +`TemplateExpressionInputSchema` — takes a bare, non-blank string (shorthand +for its own dialect) or an envelope declaring that one dialect. An envelope +naming any other dialect, and a blank string, are refused at the slot with +one issue whose message names the dialect and the fix. Only the untyped +`ExpressionInputSchema` takes every declared dialect in envelope form. + Those three are the whole list — it is exactly the `ExpressionDialect` enum below. Procedural JavaScript is **not** a dialect: it is the L2 authoring surface, the sandboxed, capability-gated `ScriptBody { language: 'js' }` in @@ -77,7 +84,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **dialect** | `Enum<'cel' \| 'cron' \| 'template'>` | ✅ | | +| **dialect** | `'cron'` | ✅ | | | **source** | `string` | optional | | | **ast** | `any` | optional | | | **meta** | `{ rationale?: string; generatedBy?: string }` | optional | | @@ -226,7 +233,7 @@ Type: `string` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **dialect** | `Enum<'cel' \| 'cron' \| 'template'>` | ✅ | | +| **dialect** | `'template'` | ✅ | | | **source** | `string` | optional | | | **ast** | `any` | optional | | | **meta** | `{ rationale?: string; generatedBy?: string }` | optional | | diff --git a/content/docs/references/system/cache.mdx b/content/docs/references/system/cache.mdx index f2ae75b68b..1e370aedb1 100644 --- a/content/docs/references/system/cache.mdx +++ b/content/docs/references/system/cache.mdx @@ -195,7 +195,7 @@ Cache warmup strategy | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable cache warmup | | **strategy** | `Enum<'eager' \| 'lazy' \| 'scheduled'>` | optional (default: `"lazy"`) | Warmup strategy: eager (at startup), lazy (on first access), scheduled (cron) | -| **schedule** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled warmup | +| **schedule** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled warmup | | **patterns** | `string[]` | optional | Key patterns to warm up (e.g., "user:*", "config:*") | | **concurrency** | `number` | optional (default: `10`) | Maximum concurrent warmup operations | @@ -258,7 +258,7 @@ Rule defining when and how cached entries are invalidated | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable cache warmup | | **strategy** | `Enum<'eager' \| 'lazy' \| 'scheduled'>` | optional (default: `"lazy"`) | Warmup strategy: eager (at startup), lazy (on first access), scheduled (cron) | -| **schedule** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled warmup | +| **schedule** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled warmup | | **patterns** | `string[]` | optional | Key patterns to warm up (e.g., "user:*", "config:*") | | **concurrency** | `number` | optional (default: `10`) | Maximum concurrent warmup operations | diff --git a/content/docs/references/system/disaster-recovery.mdx b/content/docs/references/system/disaster-recovery.mdx index b47f173d65..d7f8813ad7 100644 --- a/content/docs/references/system/disaster-recovery.mdx +++ b/content/docs/references/system/disaster-recovery.mdx @@ -30,7 +30,7 @@ Backup configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **strategy** | `Enum<'full' \| 'incremental' \| 'differential'>` | optional (default: `"incremental"`) | Backup strategy | -| **schedule** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron expression for backup schedule — cron`0 2 * * *` | +| **schedule** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for backup schedule — cron`0 2 * * *` | | **retention** | `{ days: number; minCopies?: number; maxCopies?: number }` | ✅ | Backup retention policy | | **destination** | `{ type: Enum<'s3' \| 'gcs' \| 'azure_blob' \| 'local'>; bucket?: string; path?: string; region?: string }` | ✅ | Backup storage destination | | **encryption** | `{ enabled?: boolean; algorithm?: Enum<'AES-256-GCM' \| 'AES-256-CBC' \| 'ChaCha20-Poly1305'>; keyId?: string }` | optional | Backup encryption settings | @@ -137,7 +137,7 @@ Complete disaster recovery plan configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **strategy** | `Enum<'full' \| 'incremental' \| 'differential'>` | optional (default: `"incremental"`) | Backup strategy | -| **schedule** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron expression for backup schedule — cron`0 2 * * *` | +| **schedule** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for backup schedule — cron`0 2 * * *` | | **retention** | `{ days: number; minCopies?: number; maxCopies?: number }` | ✅ | Backup retention policy | | **destination** | `{ type: Enum<'s3' \| 'gcs' \| 'azure_blob' \| 'local'>; bucket?: string; path?: string; region?: string }` | ✅ | Backup storage destination | | **encryption** | `{ enabled?: boolean; algorithm?: Enum<'AES-256-GCM' \| 'AES-256-CBC' \| 'ChaCha20-Poly1305'>; keyId?: string }` | optional | Backup encryption settings | @@ -169,7 +169,7 @@ Complete disaster recovery plan configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable automated DR testing | -| **schedule** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Cron expression for DR test schedule | +| **schedule** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for DR test schedule | | **notificationChannel** | `string` | optional | Notification channel for DR test results | ### Nested Shape: `DisasterRecoveryPlan.contacts[number]` diff --git a/content/docs/references/system/job.mdx b/content/docs/references/system/job.mdx index e60dea07a0..5f0c81a8cd 100644 --- a/content/docs/references/system/job.mdx +++ b/content/docs/references/system/job.mdx @@ -28,7 +28,7 @@ const result = CronScheduleSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `'cron'` | ✅ | | -| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression — cron`0 0 * * *` for daily at midnight. Build emits `{dialect:"cron",source}` envelope. | +| **expression** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression — cron`0 0 * * *` for daily at midnight. Build emits `{dialect:"cron",source}` envelope. | | **timezone** | `string` | optional (default: `"UTC"`) | Timezone for cron execution (e.g., "America/New_York") | @@ -73,7 +73,7 @@ const result = CronScheduleSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `'cron'` | ✅ | | -| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression — cron`0 0 * * *` for daily at midnight. Build emits `{dialect:"cron",source}` envelope. | +| **expression** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression — cron`0 0 * * *` for daily at midnight. Build emits `{dialect:"cron",source}` envelope. | | **timezone** | `string` | optional (default: `"UTC"`) | Timezone for cron execution (e.g., "America/New_York") | ### Nested Shape: `Job.schedule[type='interval']` @@ -176,7 +176,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `'cron'` | ✅ | | -| **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression — cron`0 0 * * *` for daily at midnight. Build emits `{dialect:"cron",source}` envelope. | +| **expression** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression — cron`0 0 * * *` for daily at midnight. Build emits `{dialect:"cron",source}` envelope. | | **timezone** | `string` | optional (default: `"UTC"`) | Timezone for cron execution (e.g., "America/New_York") | --- diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index a3ecaff540..180423d98c 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: Enum<'cel' \| 'cron' \| '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. | | **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: Enum<'cel' \| 'cron' \| '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. | | **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/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index e1cb2e8c96..0ce30ac240 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -354,7 +354,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ }, { // Sibling cards named in this row's note: #15500 (ratchet-key granularity) - // and #15028 (the envelope arm accepts any dialect). + // and #15028 (the envelope arm now pins the dialect — the note's last sentence). id: 'cron-declared-unwired', summary: 'cron slots on subsystems that were declared but never built — export schedules, flow schedule state, connector sync, cache warmup, DR backup/test', dialect: 'cron', mode: 'interpret', state: 'experimental', failPolicy: 'compile-error', @@ -367,7 +367,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'system/cache.zod.ts:CacheWarmupSchema.schedule', 'system/disaster-recovery.zod.ts:BackupConfigSchema.schedule', 'system/disaster-recovery.zod.ts:DisasterRecoveryPlanSchema.schedule', ], - note: 'EXPERIMENTAL — five declared cron slots with no runtime evaluator (ADR-0049 enforce-or-remove candidates; each wants its own look, and the card that surfaced them says so rather than proposing a sweep). ⚠️ TWO of these surfaces are declared TWICE: `api/export.zod.ts` `cronExpression` on `ScheduledExportSchema` and on `ScheduleExportRequestSchema`, and `system/disaster-recovery.zod.ts` `schedule` on `BackupConfigSchema` and on `DisasterRecoveryPlanSchema` (the DR `testing` block). Both pairs are genuinely the same surface twice, so one row is honest here — and now that each declaring position carries its OWN key, that judgement is written out as two `covers` entries instead of being assumed by a collapse. ⚠️ The `failPolicy` on this row is `compile-error` because the PARSE is the only thing that ever refuses one of these values; it is not a claim that cron SYNTAX is checked. It is not: `@objectstack/formula` cronEngine validates 5/6-field patterns and `@` aliases, and has ZERO consumers outside packages/formula — nothing routes these slots through it. And per the sibling finding on the dialect union, the envelope arm of `CronExpressionInputSchema` accepts any declared dialect, so even the parse does not pin these to `cron`.', + note: 'EXPERIMENTAL — five declared cron slots with no runtime evaluator (ADR-0049 enforce-or-remove candidates; each wants its own look, and the card that surfaced them says so rather than proposing a sweep). ⚠️ TWO of these surfaces are declared TWICE: `api/export.zod.ts` `cronExpression` on `ScheduledExportSchema` and on `ScheduleExportRequestSchema`, and `system/disaster-recovery.zod.ts` `schedule` on `BackupConfigSchema` and on `DisasterRecoveryPlanSchema` (the DR `testing` block). Both pairs are genuinely the same surface twice, so one row is honest here — and now that each declaring position carries its OWN key, that judgement is written out as two `covers` entries instead of being assumed by a collapse. ⚠️ The `failPolicy` on this row is `compile-error` because the PARSE is the only thing that ever refuses one of these values; it is not a claim that cron SYNTAX is checked. It is not: `@objectstack/formula` cronEngine validates 5/6-field patterns and `@` aliases, and has ZERO consumers outside packages/formula — nothing routes these slots through it. The parse now DOES pin these slots to the cron dialect (the sibling finding on the dialect union is closed): the envelope arm of `CronExpressionInputSchema` accepts a `cron` envelope only and its bare-string arm refuses a blank string, each with one issue at the slot naming the fix — and it still judges no cron syntax, by position: no grammar is restated in spec; `croner` judges the pattern where a schedule is wired (`cron-job-schedule`).', }, // ── TEMPLATE dialect (#15027) ───────────────────────────────────────────── diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index 8cb6bb7860..1305100de1 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -81,8 +81,11 @@ "StrictUnknownKeyErrorOptions (interface)", "SystemIdentifier (type)", "SystemIdentifierSchema (const)", + "TYPED_EXPRESSION_DIALECT_ONLY (const)", + "TYPED_EXPRESSION_SOURCE_REQUIRED (const)", "TemplateExpressionInput (type)", "TemplateExpressionInputSchema (const)", + "TypedExpressionDialect (type)", "VISIBILITY_ALIAS_KEYS (const)", "VISIBILITY_STRICT_OPTIONS (const)", "ValueDomain (type)", diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 4a0b18a446..3e3567fe31 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -81,8 +81,11 @@ "StrictUnknownKeyErrorOptions": "src/shared/suggestions.zod.ts#StrictUnknownKeyErrorOptions (interface)", "SystemIdentifier": "src/shared/identifiers.zod.ts#SystemIdentifier (type)", "SystemIdentifierSchema": "src/shared/identifiers.zod.ts#SystemIdentifierSchema (const)", + "TYPED_EXPRESSION_DIALECT_ONLY": "src/shared/expression.zod.ts#TYPED_EXPRESSION_DIALECT_ONLY (const)", + "TYPED_EXPRESSION_SOURCE_REQUIRED": "src/shared/expression.zod.ts#TYPED_EXPRESSION_SOURCE_REQUIRED (const)", "TemplateExpressionInput": "src/shared/expression.zod.ts#TemplateExpressionInput (type)", "TemplateExpressionInputSchema": "src/shared/expression.zod.ts#TemplateExpressionInputSchema (const)", + "TypedExpressionDialect": "src/shared/expression.zod.ts#TypedExpressionDialect (type)", "VISIBILITY_ALIAS_KEYS": "src/shared/visibility.ts#VISIBILITY_ALIAS_KEYS (const)", "VISIBILITY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_STRICT_OPTIONS (const)", "ValueDomain": "src/shared/value-domain.zod.ts#ValueDomain (type)", diff --git a/packages/spec/src/ai/knowledge-source.test.ts b/packages/spec/src/ai/knowledge-source.test.ts index 539b33b967..df0f805c65 100644 --- a/packages/spec/src/ai/knowledge-source.test.ts +++ b/packages/spec/src/ai/knowledge-source.test.ts @@ -10,10 +10,13 @@ // schema ENFORCES was measured before these pins were written, and the pins // state exactly that — no more: // -// - a bare non-empty string normalizes to `{ dialect: 'cron', source }`; -// - an expression envelope passes through; -// - an empty string, a non-string, or an envelope naming an unknown dialect -// is refused with `invalid_union` at the slot's own path; +// - a bare non-blank string normalizes to `{ dialect: 'cron', source }`; +// - a `{ dialect: 'cron' }` envelope passes through; +// - a blank string (empty or whitespace-only), a non-string, or an envelope +// naming any dialect but `cron` is refused with ONE `invalid_union` at the +// slot's own path whose message names the fix — the shared dialect fixed its +// envelope arm and learned to trim at #15028 / #15035; the sentences are +// `TYPED_EXPRESSION_SOURCE_REQUIRED.cron` / `TYPED_EXPRESSION_DIALECT_ONLY.cron`; // - cron SYNTAX is not judged at parse time. `'not a cron'` normalizes like // any other string: the syntax verdict belongs to the `cron` dialect engine // (`@objectstack/formula` cron-engine — 5- or 6-field, or an `@` alias) when @@ -23,6 +26,7 @@ // rewritten in the same commit. import { describe, expect, it } from 'vitest'; +import { TYPED_EXPRESSION_DIALECT_ONLY, TYPED_EXPRESSION_SOURCE_REQUIRED } from '../shared/expression.zod'; import { KnowledgeRefreshPolicySchema, KnowledgeSourceSchema, @@ -67,15 +71,17 @@ describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', ( if (withoutRefresh.success) expect(withoutRefresh.data.refresh?.cron).toBeUndefined(); }); - it('refuses an empty string with `invalid_union` at `refresh.cron`', () => { - const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: '' } }); - expect(r.success).toBe(false); - if (r.success) return; - const issue = r.error.issues.find((i) => i.path.join('.') === 'refresh.cron'); - expect(issue, JSON.stringify(r.error.issues)).toBeDefined(); - expect(issue?.code).toBe('invalid_union'); - expect(issue?.message.split('.')[0]).toBe('Invalid input'); - }); + it.each([['the empty string', ''], ['whitespace only', ' \t']])( + 'refuses a blank string (%s) with ONE `invalid_union` at `refresh.cron` whose message is the cron source-required sentence', + (_label, blank) => { + const r = KnowledgeSourceSchema.safeParse({ ...SOURCE, refresh: { cron: blank } }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => [i.code, i.path.join('.'), i.message])).toEqual([ + ['invalid_union', 'refresh.cron', TYPED_EXPRESSION_SOURCE_REQUIRED.cron], + ]); + }, + ); it('refuses a non-string value with `invalid_union` at `cron`', () => { const r = KnowledgeRefreshPolicySchema.safeParse({ cron: 42 }); @@ -84,12 +90,16 @@ describe('KnowledgeRefreshPolicySchema.cron — the typed cron slot (#14825)', ( expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]); }); - it('refuses an envelope naming a dialect the protocol does not declare', () => { - // `js` was retired from `ExpressionDialect` (#3278, ADR-0058 addendum). - const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect: 'js', source: 'x' } }); + it.each([ + ['a dialect the protocol does not declare (`js`, retired at #3278, ADR-0058 addendum)', 'js'], + ['a declared dialect that is not this slot\'s (`cel`, #15028)', 'cel'], + ])('refuses an envelope naming %s with ONE `invalid_union` at `cron` whose message is the cron dialect-only sentence', (_label, dialect) => { + const r = KnowledgeRefreshPolicySchema.safeParse({ cron: { dialect, source: 'x' } }); expect(r.success).toBe(false); if (r.success) return; - expect(r.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_union', 'cron']]); + expect(r.error.issues.map((i) => [i.code, i.path.join('.'), i.message])).toEqual([ + ['invalid_union', 'cron', TYPED_EXPRESSION_DIALECT_ONLY.cron], + ]); }); it('does NOT judge cron syntax at parse time — measured, and the describe promises no more (declared = enforced)', () => { diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index 0176f52181..bbccf2c39b 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -28,6 +28,13 @@ import { z } from 'zod'; * cron-typed slot is parsed and reaches no engine, and `@objectstack/formula`'s * registered `cron` engine has no caller outside that package. * + * A TYPED slot — one declared with `CronExpressionInputSchema` or + * `TemplateExpressionInputSchema` — takes a bare, non-blank string (shorthand + * for its own dialect) or an envelope declaring that one dialect. An envelope + * naming any other dialect, and a blank string, are refused at the slot with + * one issue whose message names the dialect and the fix. Only the untyped + * `ExpressionInputSchema` takes every declared dialect in envelope form. + * * Those three are the whole list — it is exactly the `ExpressionDialect` enum * below. Procedural JavaScript is **not** a dialect: it is the L2 authoring * surface, the sandboxed, capability-gated `ScriptBody { language: 'js' }` in @@ -168,25 +175,120 @@ export const ExpressionInputSchema = z.union([ export type ExpressionInput = z.input; /** - * Cron-typed input shape: a bare string is shorthand for `{ dialect: 'cron', - * source }` (not `cel`). Use this for `schedule` / `cronExpression` fields so - * authors can write `'0 9 * * 1-5'` without manually wrapping. + * The dialects that have a TYPED input schema below. On a typed slot the bare + * string is shorthand for this dialect, and the envelope arm accepts this + * dialect only. + */ +export type TypedExpressionDialect = Extract; + +/** + * The one sentence a TYPED slot refuses a blank string with — empty or + * whitespace-only, the notion of blank `EvaluatedExpressionSchema` applies + * (`source.trim()`), so the two typed schemas and the evaluated one share one + * rule. Its own words rather than {@link EVALUATED_EXPRESSION_SOURCE_REQUIRED}, + * because that sentence prescribes `{ dialect: 'cel', source }` — the one + * envelope a typed slot refuses. + */ +export const TYPED_EXPRESSION_SOURCE_REQUIRED: Readonly> = { + cron: + 'A cron-typed slot needs a non-blank cron expression: a bare string is shorthand for ' + + '`{ dialect: \'cron\', source }`, and a blank one would normalize to an envelope with nothing to schedule. ' + + 'Write `\'0 9 * * 1-5\'` or `{ dialect: \'cron\', source: \'0 9 * * 1-5\' }`; no cron syntax is judged ' + + 'here — `croner` refuses an invalid pattern where a schedule is wired.', + template: + 'A template-typed slot needs a non-blank template: a bare string is shorthand for ' + + '`{ dialect: \'template\', source }`, and a blank one would normalize to an envelope with nothing to ' + + 'interpolate. Write `\'{{record.name}}\'` or `{ dialect: \'template\', source: \'{{record.name}}\' }`.', +}; + +/** + * The one sentence a TYPED slot refuses a foreign-dialect envelope with (and + * any value that is neither a string nor an envelope). It names the slot's + * dialect first, then the fix, so the prescription travels with the refusal. + */ +export const TYPED_EXPRESSION_DIALECT_ONLY: Readonly> = { + cron: + 'A cron-typed slot accepts a bare cron string or an envelope declaring `dialect: \'cron\'` only: an ' + + 'envelope naming another dialect would validate and then have nothing to schedule. ' + + 'Write `\'0 9 * * 1-5\'` or `{ dialect: \'cron\', source: \'0 9 * * 1-5\' }`.', + template: + 'A template-typed slot accepts a bare template string or an envelope declaring `dialect: \'template\'` ' + + 'only: an envelope naming another dialect would validate and then have nothing to interpolate. ' + + 'Write `\'{{record.name}}\'` or `{ dialect: \'template\', source: \'{{record.name}}\' }`.', +}; + +/** + * The two arms a typed input union shares, spelled once. Each typed schema + * below composes them around its own `z.literal(dialect)` envelope arm. + * + * The refusal shape is measured, not assumed (zod 4.4): a union reports the + * one arm that did not abort, else `invalid_union`. Both arms here abort on a + * foreign value — the string arm is a pipe, whose transform aborts the arm on + * any issue, and the envelope arm's `z.literal` aborts on a foreign dialect — + * so every refusal is ONE `invalid_union` at the slot, and the union's own + * error map is where the message lives: the source-required sentence for a + * string input, the dialect-only sentence for everything else. A `.refine` on + * the envelope arm would surface as `custom` at `dialect` instead, but would + * leave the input TYPE, the JSON Schema and the generated reference page + * declaring every dialect on a typed slot; the literal keeps all four surfaces + * saying one thing. The one refusal `ExpressionSchema` carries — neither + * `source` nor `ast` — still surfaces on its own, with its own message: that + * arm does not abort on it. + * + * The string arm's transform returns the narrowed `{ dialect, source }`, not + * the wide `Expression`: the parsed value of a typed slot must stay assignable + * to its own input type (`ObjectStackDefinitionSchema.parse` output is handed + * to validators typed with the input shape), and it is — a same-dialect + * envelope is both. + */ +function typedExpressionStringArm(dialect: D) { + return z.string() + .refine((source) => source.trim().length > 0, { message: TYPED_EXPRESSION_SOURCE_REQUIRED[dialect] }) + .transform((source) => ({ dialect, source })); +} + +function typedExpressionUnionParams(dialect: TypedExpressionDialect): { error: (issue: { input?: unknown }) => string } { + return { + error: (issue) => (typeof issue.input === 'string' + ? TYPED_EXPRESSION_SOURCE_REQUIRED[dialect] + : TYPED_EXPRESSION_DIALECT_ONLY[dialect]), + }; +} + +/** + * Cron-typed input shape: a bare, non-blank string is shorthand for + * `{ dialect: 'cron', source }`, and an envelope must declare `dialect: 'cron'` + * — a `cel` or `template` envelope is refused at the slot, naming the fix + * (`TYPED_EXPRESSION_DIALECT_ONLY.cron`), as is a blank string + * (`TYPED_EXPRESSION_SOURCE_REQUIRED.cron`). Use this for `schedule` / + * `cronExpression` fields so authors can write `'0 9 * * 1-5'` without + * manually wrapping. + * + * No cron syntax is judged at parse time — `'not a cron'` normalizes like any + * other string. `croner` judges the pattern where a schedule is wired + * (`CronSchedule.expression` → `toBoundaryJobSchedule` → `CronJobAdapter`); + * every other cron-typed slot reaches no engine, and no grammar is restated + * here. */ export const CronExpressionInputSchema = z.union([ - z.string().min(1).transform((source): Expression => ({ dialect: 'cron', source })), - ExpressionSchema, -]); + typedExpressionStringArm('cron'), + ExpressionSchema.safeExtend({ dialect: z.literal('cron') }), +], typedExpressionUnionParams('cron')); export type CronExpressionInput = z.input; /** - * Template-typed input shape: a bare string is shorthand for - * `{ dialect: 'template', source }`. Use this for notification subjects/bodies, - * titleFormat, prompt templates — anything with `{{var}}` interpolation. + * Template-typed input shape: a bare, non-blank string is shorthand for + * `{ dialect: 'template', source }`, and an envelope must declare + * `dialect: 'template'` — a `cel` or `cron` envelope is refused at the slot, + * 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. */ export const TemplateExpressionInputSchema = z.union([ - z.string().min(1).transform((source): Expression => ({ dialect: 'template', source })), - ExpressionSchema, -]); + typedExpressionStringArm('template'), + ExpressionSchema.safeExtend({ dialect: z.literal('template') }), +], typedExpressionUnionParams('template')); export type TemplateExpressionInput = z.input; /** diff --git a/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts b/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts new file mode 100644 index 0000000000..69546c235a --- /dev/null +++ b/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `CronExpressionInputSchema` / `TemplateExpressionInputSchema` — a TYPED slot + * fixes its dialect on BOTH arms (#15028), and refuses a blank string the way + * `EvaluatedExpressionSchema` does (#15035, the census's correction). + * + * Before: the envelope arm was the unrestricted `ExpressionSchema`, so a cron + * slot parsed `{ dialect: 'cel', source }` green and whatever read it received + * an envelope it could not run; and the string arm's `.min(1)` did not trim, + * so `' '` normalized to `{ dialect: 'cron', source: ' ' }` on all twelve + * typed positions. Both were copy-paste artifacts of the untyped schema, never + * a decision; the narrowing refuses zero measured author values (32 cron, 14 + * template — the #15035 census). + * + * Every refusal pin asserts the issue's `code`, `path` and message — never + * `success === false` alone — and that it is the ONLY top-level issue. The + * deliberate NON-verdict is pinned alongside: cron / template SYNTAX is still + * not judged at parse time (`'not a cron'` normalizes), because no grammar is + * restated in spec — `croner` judges the one wired slot where it is scheduled. + * The control is the persistence contract itself: `ExpressionSchema` still + * accepts every declared dialect, because it was not narrowed. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { ObjectStackDefinitionSchema } from '../stack.zod.js'; +import { + CronExpressionInputSchema, + ExpressionSchema, + TemplateExpressionInputSchema, + TYPED_EXPRESSION_DIALECT_ONLY, + TYPED_EXPRESSION_SOURCE_REQUIRED, + type CronExpressionInput, + type TemplateExpressionInput, + type TypedExpressionDialect, +} from './expression.zod.js'; + +const NEITHER_SOURCE_NOR_AST = 'Expression requires at least one of `source` or `ast`'; + +/** Parse `value` in a slot named `slot`, so the path is the slot's own. */ +function slotIssues(schema: z.ZodType, value: unknown) { + const result = z.object({ slot: schema.optional() }).safeParse({ slot: value }); + return result.success + ? [] + : result.error.issues.map((i) => ({ code: i.code, path: i.path.map(String).join('.'), message: i.message })); +} + +function slotValue(schema: z.ZodType, value: unknown): unknown { + const result = z.object({ slot: schema.optional() }).safeParse({ slot: value }); + expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); + return result.success ? (result.data as { slot: unknown }).slot : undefined; +} + +const TYPED: ReadonlyArray<{ + dialect: TypedExpressionDialect; + schema: z.ZodType; + good: string; + unjudged: string; + foreign: readonly ['cel', 'template'] | readonly ['cel', 'cron']; +}> = [ + { dialect: 'cron', schema: CronExpressionInputSchema, good: '0 9 * * 1-5', unjudged: 'not a cron', foreign: ['cel', 'template'] }, + { dialect: 'template', schema: TemplateExpressionInputSchema, good: '{{record.name}}', unjudged: 'not a template {{{', foreign: ['cel', 'cron'] }, +]; + +describe.each(TYPED)('$dialect-typed slot — the dialect is fixed on both arms, and blank is refused (#15028 / #15035)', ({ dialect, schema, good, unjudged, foreign }) => { + const dialectOnly = TYPED_EXPRESSION_DIALECT_ONLY[dialect]; + const sourceRequired = TYPED_EXPRESSION_SOURCE_REQUIRED[dialect]; + + it('ACCEPTS a bare string and normalizes it to its own dialect', () => { + expect(slotValue(schema, good)).toEqual({ dialect, source: good }); + // Surrounding whitespace is authored, not blank — the notion of blank is `.trim()`, and the source is kept as written. + expect(slotValue(schema, ` ${good} `)).toEqual({ dialect, source: ` ${good} ` }); + }); + + it('ACCEPTS a same-dialect envelope verbatim, authorship metadata included', () => { + const envelope = { dialect, source: good, meta: { rationale: 'r', generatedBy: 'test' } }; + expect(slotValue(schema, envelope)).toEqual(envelope); + }); + + it('still ACCEPTS an `ast`-only envelope of its own dialect — a typed slot persists, it is not an evaluated slot', () => { + const envelope = { dialect, ast: { kind: 'const' } }; + expect(slotValue(schema, envelope)).toEqual(envelope); + }); + + it.each(foreign)('REFUSES a `%s` envelope: one `invalid_union` at the slot, the dialect-only sentence', (other) => { + expect(slotIssues(schema, { dialect: other, source: good })).toEqual([ + { code: 'invalid_union', path: 'slot', message: dialectOnly }, + ]); + }); + + it('REFUSES an envelope naming a dialect the protocol does not declare (`js`), with the same named issue', () => { + expect(slotIssues(schema, { dialect: 'js', source: 'x' })).toEqual([ + { code: 'invalid_union', path: 'slot', message: dialectOnly }, + ]); + }); + + it('REFUSES a value that is neither a string nor an envelope, with the dialect-only sentence', () => { + expect(slotIssues(schema, 42)).toEqual([{ code: 'invalid_union', path: 'slot', message: dialectOnly }]); + expect(slotIssues(schema, { source: good })).toEqual([{ code: 'invalid_union', path: 'slot', message: dialectOnly }]); + }); + + it.each([ + ['the empty string', ''], + ['three spaces', ' '], + ['a tab and a newline', '\t\n'], + ])('REFUSES a blank string (%s): one `invalid_union` at the slot, the source-required sentence', (_label, blank) => { + expect(slotIssues(schema, blank)).toEqual([{ code: 'invalid_union', path: 'slot', message: sourceRequired }]); + }); + + it('KEEPS the refusal `ExpressionSchema` carries — an envelope with neither `source` nor `ast` — under its own message', () => { + expect(slotIssues(schema, { dialect })).toEqual([{ code: 'custom', path: 'slot', message: NEITHER_SOURCE_NOR_AST }]); + }); + + it('does NOT judge syntax at parse time — the deliberate non-verdict: no grammar is restated in spec', () => { + expect(slotValue(schema, unjudged)).toEqual({ dialect, source: unjudged }); + }); + + it('the two sentences name the dialect in their first sentence and quote the fix', () => { + for (const sentence of [dialectOnly, sourceRequired]) { + expect(sentence.startsWith(`A ${dialect}-typed slot`)).toBe(true); + expect(sentence).toContain(`dialect: '${dialect}'`); + expect(sentence).toContain(`{ dialect: '${dialect}', source: '${good}' }`); + } + expect(dialectOnly).not.toBe(sourceRequired); + }); +}); + +describe('controls and the author-facing type', () => { + it('control: `ExpressionSchema` itself was NOT narrowed — every declared dialect still parses as an envelope', () => { + for (const dialect of ['cel', 'cron', 'template']) { + expect(ExpressionSchema.safeParse({ dialect, source: 'x' }).success).toBe(true); + } + }); + + it('the cron sentences say no cron syntax is judged here and name who judges it', () => { + expect(TYPED_EXPRESSION_SOURCE_REQUIRED.cron).toContain('no cron syntax is judged here'); + expect(TYPED_EXPRESSION_SOURCE_REQUIRED.cron).toContain('`croner`'); + }); + + it('narrows the author TYPE too: a foreign-dialect envelope is a compile error before it is a parse error', () => { + const cronOk: CronExpressionInput = { dialect: 'cron', source: '0 9 * * *' }; + // @ts-expect-error — `cel` is not a cron-typed slot's dialect. + const cronBad: CronExpressionInput = { dialect: 'cel', source: 'now()' }; + const templateOk: TemplateExpressionInput = { dialect: 'template', source: '{{x}}' }; + // @ts-expect-error — `cron` is not a template-typed slot's dialect. + const templateBad: TemplateExpressionInput = { dialect: 'cron', source: '0 9 * * *' }; + const bare: CronExpressionInput = '0 9 * * *'; + expect([cronOk, cronBad, templateOk, templateBad, bare]).toHaveLength(5); + }); +}); + +/** + * Through the stack: the three typed positions a `defineStack` manifest can + * reach (`jobs[].schedule.expression`, `connectors[].syncConfig.schedule`, + * `objects[].titleFormat`) refuse at the named path via + * `ObjectStackDefinitionSchema` — the choke point `os validate` parses through. + */ +describe('through `ObjectStackDefinitionSchema` — the stack-reachable typed slots refuse at the named path', () => { + const manifest = { id: 'com.example.typed', name: 'typed-slots', version: '1.0.0', type: 'app' as const }; + const job = (expression: unknown) => ({ name: 'nightly', schedule: { type: 'cron' as const, expression }, handler: 'nightly' }); + const connector = (schedule: unknown) => ({ name: 'sap', label: 'SAP', type: 'saas' as const, syncConfig: { schedule } }); + const object = (titleFormat: unknown) => ({ name: 'thing', fields: { name: { type: 'text' as const } }, titleFormat }); + + function stackIssues(stack: unknown) { + const result = ObjectStackDefinitionSchema.safeParse(stack); + return result.success + ? [] + : result.error.issues.map((i) => ({ code: i.code, path: i.path.map(String).join('.'), message: i.message })); + } + + it('control: the same stack with a bare string in every typed slot parses green and normalizes each to its envelope', () => { + const result = ObjectStackDefinitionSchema.safeParse({ + manifest, jobs: [job('0 1 * * *')], connectors: [connector('*/15 * * * *')], objects: [object('{{record.name}}')], + }); + expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); + if (!result.success) return; + expect(result.data.jobs?.[0]?.schedule).toMatchObject({ expression: { dialect: 'cron', source: '0 1 * * *' } }); + expect(result.data.connectors?.[0]?.syncConfig?.schedule).toEqual({ dialect: 'cron', source: '*/15 * * * *' }); + expect(result.data.objects?.[0]?.titleFormat).toEqual({ dialect: 'template', source: '{{record.name}}' }); + }); + + it('`jobs[].schedule.expression` refuses a `cel` envelope at `jobs.0.schedule.expression`', () => { + expect(stackIssues({ manifest, jobs: [job({ dialect: 'cel', source: 'now()' })] })).toEqual([ + { code: 'invalid_union', path: 'jobs.0.schedule.expression', message: TYPED_EXPRESSION_DIALECT_ONLY.cron }, + ]); + }); + + it('`jobs[].schedule.expression` refuses a blank string at `jobs.0.schedule.expression`', () => { + expect(stackIssues({ manifest, jobs: [job(' ')] })).toEqual([ + { code: 'invalid_union', path: 'jobs.0.schedule.expression', message: TYPED_EXPRESSION_SOURCE_REQUIRED.cron }, + ]); + }); + + it('`connectors[].syncConfig.schedule` refuses a `template` envelope at `connectors.0.syncConfig.schedule`', () => { + expect(stackIssues({ manifest, connectors: [connector({ dialect: 'template', source: '{{x}}' })] })).toEqual([ + { code: 'invalid_union', path: 'connectors.0.syncConfig.schedule', message: TYPED_EXPRESSION_DIALECT_ONLY.cron }, + ]); + }); + + it('`objects[].titleFormat` refuses a `cron` envelope at `objects.0.titleFormat`', () => { + expect(stackIssues({ manifest, objects: [object({ dialect: 'cron', source: '0 9 * * *' })] })).toEqual([ + { code: 'invalid_union', path: 'objects.0.titleFormat', message: TYPED_EXPRESSION_DIALECT_ONLY.template }, + ]); + }); + + it('the deliberate non-verdict holds through the stack: `\'not a cron\'` in a job schedule parses green', () => { + const result = ObjectStackDefinitionSchema.safeParse({ manifest, jobs: [job('not a cron')] }); + expect(result.success, result.success ? '' : JSON.stringify(result.error.issues)).toBe(true); + }); +});