diff --git a/.changeset/cron-typed-positions-retired.md b/.changeset/cron-typed-positions-retired.md new file mode 100644 index 0000000000..3a03c40ee0 --- /dev/null +++ b/.changeset/cron-typed-positions-retired.md @@ -0,0 +1,136 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: retire the seven cron-typed positions nothing evaluated — export schedules, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule`, backup / DR-test schedules (ADR-0049) + + + +**BREAKING** — an accept-set narrowing on seven authorable positions. Executes the +maintainer ruling of 2026-09-06 (director decision batch #56, 「其他同意」 on the per-family +recommendation: option A — retire — per family) under ADR-0049 enforce-or-remove: seven +positions across five schemas declared a `CronExpressionInputSchema` slot that the parse +normalized into the `{ dialect: 'cron', source }` envelope and that NOTHING evaluated — the +ADR-0058 D7 ledger row `cron-declared-unwired` had every one of them `unevaluated`. None of +the five schemas is `.strict()`, so each key is a `retiredKey()` tombstone rather than a bare +deletion (a deletion would have stripped it in silence): authoring it is a `tsc` error +(`never`) and a parse error carrying the prescription (`invalid_type` at the path of the key). + +| family | schema | retired position | reachable from a stack manifest | +|:--|:--|:--|:--| +| export schedules | `ScheduledExport`, `ScheduleExportRequest` (`api/export.zod.ts`) | `schedule.cronExpression` (both) | no — API contract nothing serves | +| flow schedule state | `ScheduleState` (`automation/execution.zod.ts`) | `cronExpression` (was REQUIRED) | no — runtime state | +| connector sync | `DataSyncConfig` (`integration/connector.zod.ts`) | `schedule` | **yes** — `Connector.syncConfig`, `defineStack({ connectors })` | +| cache warmup | `CacheWarmup` (`system/cache.zod.ts`) | `schedule` | no | +| backup / DR testing | `BackupConfig`, `DisasterRecoveryPlan.testing` (`system/disaster-recovery.zod.ts`) | `schedule` (both) | no | + +**What stays, byte-identical:** every other key of the five schemas and every export — no def +leaves the public surface. `ScheduledExport.schedule` / `ScheduleExportRequest.schedule` keep +their `timezone` (still defaulting to `UTC`); `ScheduleState` keeps `timezone`, `status` and +`nextRunAt`, and a state without `cronExpression` now PARSES (a tombstone accepts only absence, +so the requiredness left with the key); `CacheWarmup.strategy` keeps its `scheduled` member — +a value, not a position the ruling names, and exactly as inert as before. + +**Not in scope, deliberately:** `CronSchedule.expression` (`system/job.zod.ts`, read by +`croner` — the ONE cron slot the platform evaluates), `KnowledgeRefreshPolicy.cron` +(experimental by design), `Object.titleFormat`, and the `PromptTemplate` pair (marked, not +retired, on its sibling card). + +## FROM → TO + +```ts +// before — parsed green; no engine ever evaluated a single one of these crons +const sched: ScheduledExport = { + name: 'weekly_account_export', object: 'account', + schedule: { cronExpression: '0 6 * * MON', timezone: 'America/New_York' }, + delivery: { method: 'email', recipients: ['admin@example.com'] }, +}; +const state: ScheduleState = { + id: 'sched_001', flowName: 'daily_report', cronExpression: '0 9 * * MON-FRI', + createdAt: '2026-01-01T00:00:00Z', +}; +const connector: Connector = { + name: 'sap_erp', label: 'SAP ERP', type: 'saas', + syncConfig: { strategy: 'incremental', schedule: '*/15 * * * *' }, +}; +const warmup: CacheWarmup = { enabled: true, strategy: 'scheduled', schedule: '0 0 * * *' }; +const backup: BackupConfig = { + schedule: '0 2 * * *', retention: { days: 30 }, destination: { type: 's3', bucket: 'backups' }, +}; +const plan: DisasterRecoveryPlan = { + rpo: { value: 15 }, rto: { value: 1, unit: 'hours' }, backup, + testing: { enabled: true, schedule: '0 3 1 * *' }, +}; + +// after — delete the key. There is no replacement on any of the five schemas, +// because no export scheduler, flow-state scheduler, connector-sync scheduler, +// cache-warmup engine, backup engine or DR-test runner exists to declare a +// cadence to. The one cron slot the platform evaluates is +// `Job.schedule.expression` (`system/job.zod.ts`): work on a cadence is a `job` +// whose handler you write. +const sched: ScheduledExport = { + name: 'weekly_account_export', object: 'account', + schedule: { timezone: 'America/New_York' }, + delivery: { method: 'email', recipients: ['admin@example.com'] }, +}; +const state: ScheduleState = { + id: 'sched_001', flowName: 'daily_report', createdAt: '2026-01-01T00:00:00Z', +}; +const connector: Connector = { + name: 'sap_erp', label: 'SAP ERP', type: 'saas', + syncConfig: { strategy: 'incremental' }, +}; +const warmup: CacheWarmup = { enabled: true, strategy: 'scheduled' }; +const backup: BackupConfig = { + retention: { days: 30 }, destination: { type: 's3', bucket: 'backups' }, +}; +const plan: DisasterRecoveryPlan = { + rpo: { value: 15 }, rto: { value: 1, unit: 'hours' }, backup, + testing: { enabled: true }, +}; +``` + +One-line fix: delete the key wherever it is authored. For a connector — the one +position a stack manifest reaches — `os migrate meta --from 17` lists the mechanical +edit for every `connectors[]` entry that authored `syncConfig.schedule` (conversion +`connector-sync-schedule-removed`, `retiredFromLoadPath`: the tombstone owns the live +refusal, the conversion replays stored 17.x rows and the `migrate meta` edit list). For +the other six positions there is no `os migrate meta` edit list — none of those schemas +is a stack collection member or a metadata type, so the conversion chain has no seam to +walk (the `MetadataPluginConfig.additionalTypes` precedent); the tombstone prescription and +the protocol-18 upgrade guide are the channels. + +The retirement kit — one shape per family, as the ruling says: + +- `retiredKey()` tombstones at all seven sites (`api/export.zod.ts` ×2, + `automation/execution.zod.ts`, `integration/connector.zod.ts`, `system/cache.zod.ts`, + `system/disaster-recovery.zod.ts` ×2; each file's section comment records why no + engine ever read the key and, per family, why it does or does not convert) +- ADR-0087 registration: seven `RETIRED_KEYS_BY_MAJOR[18]` entries (the three nested + sites spelled `api/ScheduledExport:schedule.cronExpression`, + `api/ScheduleExportRequest:schedule.cronExpression`, + `system/DisasterRecoveryPlan:testing.schedule`); ONE D2 conversion for the connector + family (`connector-sync-schedule-removed`, one strip per `connectors[]` entry, wired + into the step-18 chain) plus its D3 twin `connector-sync-schedule-retired`, which + carries the measured author population — zero in-repo authors, out-of-repo stacks NOT + MEASURED from this repo — on the fields the upgrade guide, `spec-changes.json` and + `os migrate meta` project, as the #15954 ruling's letter requires; four D3 semantic + entries for the other four families +- no liveness-ledger row: none of the five schemas is an enrolled ledger type +- the ADR-0058 D7 expression-conformance ledger loses its `cron-declared-unwired` row + (every position it covered is a tombstone now, so discovery by roster name no longer + sees them); the cron dialect is now exactly the one evaluated slot plus the one + experimental-by-design slot +- pin tests (`cron-typed-positions-retirement.test.ts`): a refusal pin per site + asserting the issue path, code and prescription on the base schema and through every + nesting carrier (`Connector.syncConfig`, `stack.connectors[]`, the `/meta/connector` + door, `DisasterRecoveryPlan.backup`, `DistributedCacheConfig.warmup`); the tsc `never` + channel; no-materialize pins; the migrate sentence present on the connector prescription + and absent from the six others; and the ADR-0087 registration per family +- generated baselines and docs follow the schema: `authorable-surface/` flips four rows + to `[RETIRED]` (the three nested positions have no row of their own), the five + reference pages are regenerated, the published `objectstack-formula` skill's `cron` + row drops the three retired carriers and keeps `Job.schedule.expression`, and + `packages/spec/docs/SYNC_ARCHITECTURE.md` stops teaching `syncConfig.schedule` +- `json-schema.manifest/` and `api-surface/` are unchanged, and correctly so: the first + ratchets def *names* and the second export *existence*; retiring keys removes neither diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 9803a1a0bc..a8276b61a1 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -751,14 +751,14 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **fields** | `string[]` | optional | Fields to include | | **filter** | `Record` | optional | Record filter criteria | | **templateId** | `string` | optional | Export template ID for field mappings | -| **schedule** | `{ cronExpression: string \| object; timezone?: string }` | ✅ | Schedule timing configuration | +| **schedule** | `{ timezone: string }` | ✅ | Schedule timing configuration | | **delivery** | `{ method: Enum<'email' \| 'storage' \| 'webhook'>; recipients?: string[]; storagePath?: string; webhookUrl?: string }` | ✅ | Export delivery configuration | ### Nested Shape: `ScheduleExportRequest.schedule` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **cronExpression** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression for schedule | +| **cronExpression** | `never` | optional | [REMOVED] `ScheduleExportRequest.schedule.cronExpression` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no export scheduler exists on the platform (`POST /api/v1/data/export/schedules` is a declared contract no server route implements, and `IExportService` has no provider), so the cron never fired. Delete the key; there is no replacement until an export scheduler exists. The one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a recurring export is a job whose handler you write. | | **timezone** | `string` | optional (default: `"UTC"`) | IANA timezone | ### Nested Shape: `ScheduleExportRequest.delivery` @@ -825,7 +825,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **fields** | `string[]` | optional | Fields to include | | **filter** | `Record` | optional | Record filter criteria | | **templateId** | `string` | optional | Export template ID for field mappings | -| **schedule** | `{ cronExpression: string \| object; timezone?: string }` | ✅ | Schedule timing configuration | +| **schedule** | `{ timezone: string }` | ✅ | Schedule timing configuration | | **delivery** | `{ method: Enum<'email' \| 'storage' \| 'webhook'>; recipients?: string[]; storagePath?: string; webhookUrl?: string }` | ✅ | Export delivery configuration | | **enabled** | `boolean` | optional (default: `true`) | Whether the scheduled export is active | | **lastRunAt** | `string` | optional | Last execution timestamp | @@ -837,7 +837,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **cronExpression** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression for schedule | +| **cronExpression** | `never` | optional | [REMOVED] `ScheduledExport.schedule.cronExpression` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no export scheduler exists on the platform (`POST /api/v1/data/export/schedules` is a declared contract no server route implements, and `IExportService` has no provider), so the cron never fired. Delete the key; there is no replacement until an export scheduler exists. The one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a recurring export is a job whose handler you write. | | **timezone** | `string` | optional (default: `"UTC"`) | IANA timezone | ### Nested Shape: `ScheduledExport.delivery` diff --git a/content/docs/references/automation/execution.mdx b/content/docs/references/automation/execution.mdx index 37f00dc3eb..ee77715742 100644 --- a/content/docs/references/automation/execution.mdx +++ b/content/docs/references/automation/execution.mdx @@ -345,7 +345,7 @@ const result = CheckpointSchema.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Schedule instance ID | | **flowName** | `string` | ✅ | Flow machine name | -| **cronExpression** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | ✅ | Cron expression — cron`0 9 * * MON-FRI` | +| **cronExpression** | `never` | optional | [REMOVED] `ScheduleState.cronExpression` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no scheduler consumed a `ScheduleState` row, and the schedule trigger that does run reads a flow start node's `config.schedule`, a different shape this key never reached. Delete the key; a scheduled flow declares its cadence on the flow's start node (`config.schedule`), and the one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`). | | **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/integration/connector.mdx b/content/docs/references/integration/connector.mdx index a909001a80..d9012d046f 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -166,17 +166,17 @@ Circuit breaker configuration | **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type | | **description** | `string` | optional | Connector description | | **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | | **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| object; realtimeSync?: boolean; … }` | optional | Data sync configuration | +| **syncConfig** | `{ strategy: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction: Enum<'import' \| 'export' \| 'bidirectional'>; realtimeSync: boolean; timestampField?: string; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules | | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | +| **retryConfig** | `{ strategy: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts: number; initialDelayMs: number; maxDelayMs: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional (default: `30000`) | Connection timeout in ms | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | | **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional (default: `"inactive"`) | Connector status | @@ -282,7 +282,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: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled sync — cron`0 */15 * * *` | +| **schedule** | `never` | optional | [REMOVED] `connector.syncConfig.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine schedules a connector sync, so the cron was parsed and never fired. Delete the key; sync on a cadence is a `job` (`Job.schedule.expression`, the one cron slot the platform evaluates) whose handler drives the connector, and `realtimeSync` is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **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 | @@ -345,8 +345,8 @@ Circuit breaker configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **healthCheck** | `{ enabled: boolean; intervalMs?: number; timeoutMs?: number; endpoint?: string; … }` | optional | Health check configuration | -| **circuitBreaker** | `{ enabled: boolean; failureThreshold?: number; resetTimeoutMs?: number; halfOpenMaxRequests?: number; … }` | optional | Circuit breaker configuration | +| **healthCheck** | `{ enabled: boolean; intervalMs: number; timeoutMs: number; endpoint?: string; … }` | optional | Health check configuration | +| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeoutMs: number; halfOpenMaxRequests: number; … }` | optional | Circuit breaker configuration | --- @@ -630,7 +630,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: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled sync — cron`0 */15 * * *` | +| **schedule** | `never` | optional | [REMOVED] `connector.syncConfig.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine schedules a connector sync, so the cron was parsed and never fired. Delete the key; sync on a cadence is a `job` (`Job.schedule.expression`, the one cron slot the platform evaluates) whose handler drives the connector, and `realtimeSync` is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **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 | @@ -652,17 +652,17 @@ Connector type | **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type | | **description** | `string` | optional | Connector description | | **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | | **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions | -| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| object; realtimeSync?: boolean; … }` | optional | Data sync configuration | +| **syncConfig** | `{ strategy: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction: Enum<'import' \| 'export' \| 'bidirectional'>; realtimeSync: boolean; timestampField?: string; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules | | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | +| **retryConfig** | `{ strategy: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts: number; initialDelayMs: number; maxDelayMs: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional (default: `30000`) | Connection timeout in ms | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | | **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional (default: `"inactive"`) | Connector status | @@ -768,7 +768,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: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled sync — cron`0 */15 * * *` | +| **schedule** | `never` | optional | [REMOVED] `connector.syncConfig.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine schedules a connector sync, so the cron was parsed and never fired. Delete the key; sync on a cadence is a `job` (`Job.schedule.expression`, the one cron slot the platform evaluates) whose handler drives the connector, and `realtimeSync` is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **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 | @@ -831,8 +831,8 @@ Connector type | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **healthCheck** | `{ enabled: boolean; intervalMs?: number; timeoutMs?: number; endpoint?: string; … }` | optional | Health check configuration | -| **circuitBreaker** | `{ enabled: boolean; failureThreshold?: number; resetTimeoutMs?: number; halfOpenMaxRequests?: number; … }` | optional | Circuit breaker configuration | +| **healthCheck** | `{ enabled: boolean; intervalMs: number; timeoutMs: number; endpoint?: string; … }` | optional | Health check configuration | +| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeoutMs: number; halfOpenMaxRequests: number; … }` | optional | Circuit breaker configuration | --- diff --git a/content/docs/references/system/cache.mdx b/content/docs/references/system/cache.mdx index fdba1fe562..ccf34df256 100644 --- a/content/docs/references/system/cache.mdx +++ b/content/docs/references/system/cache.mdx @@ -198,7 +198,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: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled warmup | +| **schedule** | `never` | optional | [REMOVED] `CacheWarmup.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no cache-warmup engine exists on the platform, so a scheduled warmup never ran. Delete the key. The one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a warmup on a cadence is a job whose handler you write. | | **patterns** | `string[]` | optional | Key patterns to warm up (e.g., "user:*", "config:*") | | **concurrency** | `number` | optional (default: `10`) | Maximum concurrent warmup operations | @@ -214,14 +214,14 @@ Distributed cache configuration with consistency and avalanche prevention | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable application-level caching | -| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttlSeconds?: number; … }[]` | ✅ | Ordered cache tier hierarchy | +| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttlSeconds: number; … }[]` | ✅ | Ordered cache tier hierarchy | | **invalidation** | `{ trigger: Enum<'create' \| 'update' \| 'delete' \| 'manual'>; scope: Enum<'key' \| 'pattern' \| 'tag' \| 'all'>; pattern?: string; tags?: string[] }[]` | ✅ | Cache invalidation rules | | **prefetch** | `boolean` | optional (default: `false`) | Enable cache prefetching | | **compression** | `boolean` | optional (default: `false`) | Enable data compression in cache | | **encryption** | `boolean` | optional (default: `false`) | Enable encryption for cached data | | **consistency** | `Enum<'write_through' \| 'write_behind' \| 'write_around' \| 'refresh_ahead'>` | optional | Distributed cache consistency strategy | | **avalanchePrevention** | `{ jitterTtl?: object; circuitBreaker?: object; lockout?: object }` | optional | Cache avalanche and stampede prevention | -| **warmup** | `{ enabled?: boolean; strategy?: Enum<'eager' \| 'lazy' \| 'scheduled'>; schedule?: string \| object; patterns?: string[]; … }` | optional | Cache warmup strategy | +| **warmup** | `{ enabled: boolean; strategy: Enum<'eager' \| 'lazy' \| 'scheduled'>; patterns?: string[]; concurrency: number }` | optional | Cache warmup strategy | ### Nested Shape: `DistributedCacheConfig.tiers[number]` @@ -252,9 +252,9 @@ Rule defining when and how cached entries are invalidated | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **jitterTtl** | `{ enabled?: boolean; maxJitterSeconds?: number }` | optional | TTL jitter to prevent simultaneous expiration | -| **circuitBreaker** | `{ enabled?: boolean; failureThreshold?: number; resetTimeoutSeconds?: number }` | optional | Circuit breaker for backend protection | -| **lockout** | `{ enabled?: boolean; lockTimeoutMs?: number }` | optional | Lock-based stampede prevention | +| **jitterTtl** | `{ enabled: boolean; maxJitterSeconds: number }` | optional | TTL jitter to prevent simultaneous expiration | +| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeoutSeconds: number }` | optional | Circuit breaker for backend protection | +| **lockout** | `{ enabled: boolean; lockTimeoutMs: number }` | optional | Lock-based stampede prevention | ### Nested Shape: `DistributedCacheConfig.warmup` @@ -262,7 +262,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: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for scheduled warmup | +| **schedule** | `never` | optional | [REMOVED] `CacheWarmup.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no cache-warmup engine exists on the platform, so a scheduled warmup never ran. Delete the key. The one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a warmup on a cadence is a job whose handler you write. | | **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 8eb09d2389..8c892ad8fd 100644 --- a/content/docs/references/system/disaster-recovery.mdx +++ b/content/docs/references/system/disaster-recovery.mdx @@ -30,11 +30,11 @@ Backup configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **strategy** | `Enum<'full' \| 'incremental' \| 'differential'>` | optional (default: `"incremental"`) | Backup strategy | -| **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 | +| **schedule** | `never` | optional | [REMOVED] `BackupConfig.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no backup engine exists on the platform, so an automated backup never ran on it. Delete the key. The one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a backup on a cadence is a job whose handler you write. | +| **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 | -| **compression** | `{ enabled?: boolean; algorithm?: Enum<'gzip' \| 'zstd' \| 'lz4' \| 'snappy'> }` | optional | Backup compression settings | +| **encryption** | `{ enabled: boolean; algorithm: Enum<'AES-256-GCM' \| 'AES-256-CBC' \| 'ChaCha20-Poly1305'>; keyId?: string }` | optional | Backup encryption settings | +| **compression** | `{ enabled: boolean; algorithm: Enum<'gzip' \| 'zstd' \| 'lz4' \| 'snappy'> }` | optional | Backup compression settings | | **verifyAfterBackup** | `boolean` | optional (default: `true`) | Verify backup integrity after creation | ### Nested Shape: `BackupConfig.retention` @@ -109,12 +109,12 @@ Complete disaster recovery plan configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable disaster recovery plan | -| **rpo** | `{ value: number; unit?: Enum<'seconds' \| 'minutes' \| 'hours'> }` | ✅ | Recovery Point Objective | -| **rto** | `{ value: number; unit?: Enum<'seconds' \| 'minutes' \| 'hours'> }` | ✅ | Recovery Time Objective | -| **backup** | `{ strategy?: Enum<'full' \| 'incremental' \| 'differential'>; schedule?: string \| object; retention: object; destination: object; … }` | ✅ | Backup configuration | -| **failover** | `{ mode?: Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>; autoFailover?: boolean; healthCheckIntervalSeconds?: number; failureThreshold?: number; … }` | optional | Multi-region failover configuration | -| **replication** | `{ mode?: Enum<'synchronous' \| 'asynchronous' \| 'semi_synchronous'>; maxLagSeconds?: number; includeObjects?: string[]; excludeObjects?: string[] }` | optional | Data replication settings | -| **testing** | `{ enabled?: boolean; schedule?: string \| object; notificationChannel?: string }` | optional | Automated disaster recovery testing | +| **rpo** | `{ value: number; unit: Enum<'seconds' \| 'minutes' \| 'hours'> }` | ✅ | Recovery Point Objective | +| **rto** | `{ value: number; unit: Enum<'seconds' \| 'minutes' \| 'hours'> }` | ✅ | Recovery Time Objective | +| **backup** | `{ strategy: Enum<'full' \| 'incremental' \| 'differential'>; retention: object; destination: object; encryption?: object; … }` | ✅ | Backup configuration | +| **failover** | `{ mode: Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>; autoFailover: boolean; healthCheckIntervalSeconds: number; failureThreshold: number; … }` | optional | Multi-region failover configuration | +| **replication** | `{ mode: Enum<'synchronous' \| 'asynchronous' \| 'semi_synchronous'>; maxLagSeconds?: number; includeObjects?: string[]; excludeObjects?: string[] }` | optional | Data replication settings | +| **testing** | `{ enabled: boolean; notificationChannel?: string }` | optional | Automated disaster recovery testing | | **runbookUrl** | `string` | optional | URL to disaster recovery runbook/playbook | | **contacts** | `{ name: string; role: string; email?: string; phone?: string }[]` | optional | Emergency contact list for DR incidents | @@ -137,11 +137,11 @@ Complete disaster recovery plan configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **strategy** | `Enum<'full' \| 'incremental' \| 'differential'>` | optional (default: `"incremental"`) | Backup strategy | -| **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 | +| **schedule** | `never` | optional | [REMOVED] `BackupConfig.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no backup engine exists on the platform, so an automated backup never ran on it. Delete the key. The one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a backup on a cadence is a job whose handler you write. | +| **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 | -| **compression** | `{ enabled?: boolean; algorithm?: Enum<'gzip' \| 'zstd' \| 'lz4' \| 'snappy'> }` | optional | Backup compression settings | +| **encryption** | `{ enabled: boolean; algorithm: Enum<'AES-256-GCM' \| 'AES-256-CBC' \| 'ChaCha20-Poly1305'>; keyId?: string }` | optional | Backup encryption settings | +| **compression** | `{ enabled: boolean; algorithm: Enum<'gzip' \| 'zstd' \| 'lz4' \| 'snappy'> }` | optional | Backup compression settings | | **verifyAfterBackup** | `boolean` | optional (default: `true`) | Verify backup integrity after creation | ### Nested Shape: `DisasterRecoveryPlan.failover` @@ -154,7 +154,7 @@ Complete disaster recovery plan configuration | **healthCheckInterval** | `never` | optional | [REMOVED] `FailoverConfig.healthCheckInterval` was renamed to `healthCheckIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `healthCheckIntervalSeconds`; the value (seconds) and the 30 default are unchanged. | | **failureThreshold** | `number` | optional (default: `3`) | Consecutive failures before failover | | **regions** | `{ name: string; role: Enum<'primary' \| 'secondary' \| 'witness'>; endpoint?: string; priority?: number }[]` | ✅ | Multi-region configuration (minimum 2 regions) | -| **dns** | `{ ttl?: number; provider?: Enum<'route53' \| 'cloudflare' \| 'azure_dns' \| 'custom'> }` | optional | DNS failover settings | +| **dns** | `{ ttl: number; provider?: Enum<'route53' \| 'cloudflare' \| 'azure_dns' \| 'custom'> }` | optional | DNS failover settings | ### Nested Shape: `DisasterRecoveryPlan.replication` @@ -170,7 +170,7 @@ Complete disaster recovery plan configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable automated DR testing | -| **schedule** | `string \| { dialect: 'cron'; source?: string; ast?: any; meta?: object }` | optional | Cron expression for DR test schedule | +| **schedule** | `never` | optional | [REMOVED] `DisasterRecoveryPlan.testing.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no disaster-recovery test runner exists on the platform, so a periodic DR test never ran. Delete the key. The one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a DR test on a cadence is a job whose handler you write. | | **notificationChannel** | `string` | optional | Notification channel for DR test results | ### Nested Shape: `DisasterRecoveryPlan.contacts[number]` diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 2dbac23c97..5da1c5e233 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -338,10 +338,10 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ // the L1 "Simple Sync" DataSyncConfig) left with the whole file in // #4738 — the L1 layer was narrative-only, so no engine ever evaluated // that predicate. Connector-attached sync (`ConnectorSchema.syncConfig`) - // declares no CEL surface to re-point this cover at. It does declare a - // cron one — `syncConfig.schedule` — which was invisible to discovery - // when that was written and is classified by `cron-declared-unwired` - // since #15027; nothing evaluates it either. + // declares no CEL surface to re-point this cover at. It did declare a + // cron one — `syncConfig.schedule` — invisible to discovery when that + // was written, classified by `cron-declared-unwired` from #15027, and + // retired under ADR-0049 at #16320 (nothing ever evaluated it). // `kernel/metadata-loader.zod.ts:filter` (on MetadataLoadOptions and // MetadataExportOptions) was removed with the rest of that file's // zero-consumer duplicate envelope family in #4411. The surviving @@ -367,7 +367,7 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ 'runtime/job-schedule.ts `toBoundaryJobSchedule` — the authoring→boundary seam: it lowers the parsed `{dialect:"cron",source}` envelope to the bare cron string the adapter takes, and THROWS naming the job on a non-cron dialect, an AST-only envelope, or a missing/blank source. Called from runtime/app-plugin.ts `start`; the boundary value reaches service-job/cron-job-adapter.ts `CronJobAdapter.schedule` → **croner** `Cron` (db-job-adapter.ts routes the cron variant there and persists the shape onto sys_job). The throw is CONTAINED at the call site, deliberately and visibly: AppPlugin catches per job, logs `Background job FAILED TO SCHEDULE — it will never run` at ERROR with the `jobScheduleFailuresTotal` counter, then reports the failed count — boot continues and the job does not run. Cron SYNTAX is not judged on this path at all: `toBoundaryJobSchedule` only checks dialect/source shape, and a syntactically invalid pattern throws later inside croner, into the same catch', covers: ['system/job.zod.ts:CronScheduleSchema.expression'], proof: 'packages/runtime/src/job-schedule.test.ts', - note: 'The ONE cron slot in the spec with a measured evaluator. `@objectstack/formula` cronEngine is NOT on this path — see `cron-declared-unwired` for what that means for the rest.', + note: 'The ONE cron slot in the spec with a measured evaluator. `@objectstack/formula` cronEngine is NOT on this path — it has zero consumers outside packages/formula, and the five other cron slots once declared beside this one (the former `cron-declared-unwired` row: export schedules, flow schedule state, connector sync, cache warmup, DR backup/test) never reached it either; they were retired under ADR-0049 as declared-but-never-evaluated.', }, { // The key and its documented hand-off arrived with #14825. @@ -377,25 +377,27 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ enforcement: 'PARSE ONLY — `CronExpressionInputSchema` refuses a blank/non-string, non-envelope value and normalizes to `{dialect:"cron",source}`; nothing evaluates the result. service-knowledge/knowledge-service.ts reads `refresh.onRecordChange` and NEVER `refresh.cron` (measured: the only `refresh` reads in that package are the two `onRecordChange` sites)', covers: ['ai/knowledge-source.zod.ts:KnowledgeRefreshPolicySchema.cron'], - note: 'EXPERIMENTAL by DESIGN, and separated from `cron-declared-unwired` for that reason: the key documents its own hand-off — service-knowledge surfaces the value so an automation flow / external scheduler can call `reindexSource`, and the field docblock says so. Nothing in this repo schedules it, which is the intended state rather than an undelivered one. It still has no evaluator, so it is not `enforced`.', - }, - { - // Sibling cards named in this row's note: #15500 (ratchet-key granularity) - // 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: 'unevaluated', - enforcement: - 'PARSE ONLY — `CronExpressionInputSchema` refuses a blank/non-string, non-envelope value and normalizes to the envelope; NO EVALUATOR FOUND for any of these five keys. Reader hunt, per key, walking out from each declaration (2026-09-04, `61821e54cf5`): `api/export.zod.ts:cronExpression` — the whole `ExportJobApiContracts` family has zero consumers and rest-server serves no `/api/v1/data/export` route, so `POST /api/v1/data/export/schedules` is a declared contract nothing implements; `IExportService` has no provider binding, which its own source already records. `automation/execution.zod.ts:cronExpression` — `ScheduleStateSchema` has no consumer outside packages/spec; the schedule TRIGGER that does work reads a flow start node `config.schedule` through trigger-schedule/schedule-trigger.ts `normalizeSchedule`, a different shape this key never reaches. `integration/connector.zod.ts:schedule` — `syncConfig` has no reader outside packages/spec. `system/cache.zod.ts:schedule` (CacheWarmup) and `system/disaster-recovery.zod.ts:schedule` (BackupConfig + the DR `testing` block) — neither schema has any consumer outside packages/spec', - covers: [ - 'api/export.zod.ts:ScheduledExportSchema.cronExpression', 'api/export.zod.ts:ScheduleExportRequestSchema.cronExpression', - 'automation/execution.zod.ts:ScheduleStateSchema.cronExpression', - 'integration/connector.zod.ts:DataSyncConfigSchema.schedule', - '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 `unevaluated`. It read `compile-error` until the vocabulary gained a member for "nothing evaluates this slot", and that value was the closest available rather than a true one: the PARSE is the only thing that ever refuses one of these values, which is a property every row in this ledger shares and says nothing about this one. It was never 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`).', + note: 'EXPERIMENTAL by DESIGN — and that is why it survived the ADR-0049 retirement of the other declared-but-unwired cron slots (the former `cron-declared-unwired` row): the key documents its own hand-off — service-knowledge surfaces the value so an automation flow / external scheduler can call `reindexSource`, and the field docblock says so. Nothing in this repo schedules it, which is the intended state rather than an undelivered one. It still has no evaluator, so it is not `enforced`.', }, + // `cron-declared-unwired` sat here until #16320 retired every position it + // covered under ADR-0049 (the #15954 ruling, decision batch #56, option A — + // retire — per family): `api/export.zod.ts` `ScheduledExportSchema.cronExpression` + // / `ScheduleExportRequestSchema.cronExpression`, `automation/execution.zod.ts` + // `ScheduleStateSchema.cronExpression`, `integration/connector.zod.ts` + // `DataSyncConfigSchema.schedule`, `system/cache.zod.ts` `CacheWarmupSchema.schedule`, + // and `system/disaster-recovery.zod.ts` `BackupConfigSchema.schedule` / + // `DisasterRecoveryPlanSchema.schedule` (the DR `testing` block). Each is a + // `retiredKey()` tombstone now — no `CronExpressionInputSchema` member left at + // any of the seven — so discovery (by roster name) no longer sees them and + // every cover would read STALE; the row is deleted rather than re-pointed, + // the `mapping.zod.ts:expression` (#5552) / `element:form.onSubmit` (#9249) + // way. What the row recorded — PARSE ONLY, no evaluator found for any of the + // five keys, `failPolicy: 'unevaluated'` — became the retirement's reason, + // stated at each tombstone and in the ADR-0087 entries + // (`export-schedule-cron-retired`, `schedule-state-cron-expression-retired`, + // `connector-sync-schedule-removed`, `cache-warmup-schedule-retired`, + // `disaster-recovery-schedules-retired`). The two cron rows above are the + // whole cron dialect now: one evaluated slot, one experimental-by-design. // ── TEMPLATE dialect (#15027) ───────────────────────────────────────────── { diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 69da28d70f..d68d16374e 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -283,7 +283,7 @@ "automation/ScheduleState:consecutiveFailures", "automation/ScheduleState:createdAt", "automation/ScheduleState:createdBy", - "automation/ScheduleState:cronExpression", + "automation/ScheduleState:cronExpression [RETIRED]", "automation/ScheduleState:endDate", "automation/ScheduleState:flowName", "automation/ScheduleState:id", diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index 04e2dcceb3..bbc2a2f6d9 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -76,7 +76,7 @@ "integration/DataSyncConfig:direction", "integration/DataSyncConfig:filters", "integration/DataSyncConfig:realtimeSync", - "integration/DataSyncConfig:schedule", + "integration/DataSyncConfig:schedule [RETIRED]", "integration/DataSyncConfig:strategy", "integration/DataSyncConfig:timestampField", "integration/DeclarativeConnectorEntry:_lock", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 1488775e64..0a19b7c0da 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -121,7 +121,7 @@ "system/BackupConfig:destination", "system/BackupConfig:encryption", "system/BackupConfig:retention", - "system/BackupConfig:schedule", + "system/BackupConfig:schedule [RETIRED]", "system/BackupConfig:strategy", "system/BackupConfig:verifyAfterBackup", "system/BackupRetention:days", @@ -198,7 +198,7 @@ "system/CacheWarmup:concurrency", "system/CacheWarmup:enabled", "system/CacheWarmup:patterns", - "system/CacheWarmup:schedule", + "system/CacheWarmup:schedule [RETIRED]", "system/CacheWarmup:strategy", "system/ChangeSet:author", "system/ChangeSet:createdAt", diff --git a/packages/spec/docs/SYNC_ARCHITECTURE.md b/packages/spec/docs/SYNC_ARCHITECTURE.md index 81b7113473..c9304ac610 100644 --- a/packages/spec/docs/SYNC_ARCHITECTURE.md +++ b/packages/spec/docs/SYNC_ARCHITECTURE.md @@ -48,7 +48,8 @@ live declarations in `integration/connector.zod.ts` and `ui/offline.zod.ts` (the - **Connector-attached sync** — `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`): the live, parsed sync-strategy surface - (strategy, direction, schedule, `conflictResolution`, batching, delete mode). + (strategy, direction, `conflictResolution`, batching, delete mode; the cron + `schedule` slot was retired at #16320 under ADR-0049 — nothing ever evaluated it). - **Transformation pipelines** — ~~`ETLPipeline` (`automation/etl.zod.ts`) for multi-source, multi-stage data movement~~ **also retired, at #6414** (ADR-0049), on the same reading this section applies to L1: zero execution-side consumers, no @@ -96,7 +97,8 @@ ten-stage pipeline, get no error, and get no execution. - **Scheduled, connector-attached synchronisation** — `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), the live, parsed surface described under L3 below: - strategy, direction, cron schedule, `conflictResolution`, batching, delete mode. + strategy, direction, `conflictResolution`, batching, delete mode — no cron slot: + `syncConfig.schedule` was retired at #16320 under ADR-0049, nothing ever evaluated it. - **Per-field value conversion on import** — `mapping.fieldMapping[].transform` (`data/mapping.zod.ts`): a string enum (`none` / `constant` / `map` / `split` / `join` / `lookup`) with its settings in `params`, applied row by row by the REST @@ -191,11 +193,11 @@ Complete, production-grade integration with external systems. Includes authentic > `strategy` / `direction` / `realtimeSync` / `conflictResolution` / > `batchSize` / `deleteMode`, a mapping's `required` / `syncMode`, a webhook's > `method` / `timeoutMs` / `isActive` / `signatureAlgorithm` — is optional when -> you write a connector, and `syncConfig.schedule` takes the bare cron string -> the schema wraps for you. Annotate the **result** of +> you write a connector. (`syncConfig.schedule`, the cron slot the schema used +> to wrap into an envelope, was retired at #16320 under ADR-0049: nothing ever +> evaluated it.) Annotate the **result** of > `ConnectorSchema.parse(…)` with **`ConnectorParsed`**, which is `z.infer`: -> there those keys are all present and `schedule` is already the -> `{ dialect: 'cron', source }` envelope. The same convention held on L2's +> there those keys are all present. The same convention held on L2's > `ETLPipeline` / `ETLPipelineParsed` before that layer was retired (#6414), and > **[ADR-0122](../../../docs/adr/0122-schema-type-alias-naming-convention.md) > is why**: the bare name is the author state and `XParsed` is the parsed state, @@ -236,7 +238,6 @@ const sapConnector: Connector = { syncConfig: { strategy: 'incremental', direction: 'bidirectional', - schedule: '*/15 * * * *', // Every 15 minutes realtimeSync: true, timestampField: 'updated_at', conflictResolution: 'latest_wins', diff --git a/packages/spec/src/api/export.test.ts b/packages/spec/src/api/export.test.ts index 56531fe920..0cfe4b9886 100644 --- a/packages/spec/src/api/export.test.ts +++ b/packages/spec/src/api/export.test.ts @@ -435,8 +435,9 @@ describe('ScheduledExportSchema', () => { format: 'csv', fields: ['name', 'email', 'status'], filter: { status: 'active' }, + // `schedule.cronExpression` is a retiredKey() tombstone (#16320) — the + // refusal is pinned in `cron-typed-positions-retirement.test.ts`. schedule: { - cronExpression: '0 6 * * MON', timezone: 'America/New_York', }, delivery: { @@ -445,7 +446,7 @@ describe('ScheduledExportSchema', () => { }, }); expect(sched.name).toBe('weekly_account_export'); - expect(sched.schedule.cronExpression).toEqual({ dialect: 'cron', source: '0 6 * * MON' }); + expect(sched.schedule.timezone).toBe('America/New_York'); expect(sched.delivery.method).toBe('email'); expect(sched.enabled).toBe(true); }); @@ -454,7 +455,7 @@ describe('ScheduledExportSchema', () => { const sched = ScheduledExportSchema.parse({ name: 'daily_export', object: 'order', - schedule: { cronExpression: '0 0 * * *' }, + schedule: {}, delivery: { method: 'storage', storagePath: '/exports/daily/' }, }); expect(sched.format).toBe('csv'); @@ -466,7 +467,7 @@ describe('ScheduledExportSchema', () => { expect(() => ScheduledExportSchema.parse({ name: 'WeeklyExport', object: 'account', - schedule: { cronExpression: '0 6 * * MON' }, + schedule: {}, delivery: { method: 'email' }, })).toThrow(); }); @@ -477,7 +478,7 @@ describe('ScheduledExportSchema', () => { expect(() => ScheduledExportSchema.parse({ name: 'test_export', object: 'account', - schedule: { cronExpression: '0 0 * * *' }, + schedule: {}, delivery: { method: m }, })).not.toThrow(); }); @@ -631,8 +632,9 @@ describe('ScheduleExportRequestSchema', () => { object: 'account', format: 'csv', fields: ['name', 'email'], + // `schedule.cronExpression` is a retiredKey() tombstone (#16320) — the + // refusal is pinned in `cron-typed-positions-retirement.test.ts`. schedule: { - cronExpression: '0 6 * * MON', timezone: 'America/New_York', }, delivery: { @@ -641,7 +643,7 @@ describe('ScheduleExportRequestSchema', () => { }, }); expect(req.name).toBe('weekly_account_export'); - expect(req.schedule.cronExpression).toEqual({ dialect: 'cron', source: '0 6 * * MON' }); + expect(req.schedule.timezone).toBe('America/New_York'); expect(req.delivery.method).toBe('email'); }); @@ -649,7 +651,7 @@ describe('ScheduleExportRequestSchema', () => { const req = ScheduleExportRequestSchema.parse({ name: 'daily_export', object: 'order', - schedule: { cronExpression: '0 0 * * *' }, + schedule: {}, delivery: { method: 'storage', storagePath: '/exports/daily/' }, }); expect(req.format).toBe('csv'); @@ -660,7 +662,7 @@ describe('ScheduleExportRequestSchema', () => { expect(() => ScheduleExportRequestSchema.parse({ name: 'WeeklyExport', object: 'account', - schedule: { cronExpression: '0 6 * * MON' }, + schedule: {}, delivery: { method: 'email' }, })).toThrow(); }); diff --git a/packages/spec/src/api/export.zod.ts b/packages/spec/src/api/export.zod.ts index ef4543c6b9..2e0e8f4b78 100644 --- a/packages/spec/src/api/export.zod.ts +++ b/packages/spec/src/api/export.zod.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { CronExpressionInputSchema } from '../shared/expression.zod'; +import { retiredKey } from '../shared/retired-key'; import { BaseResponseSchema } from './contract.zod'; /** @@ -550,6 +550,41 @@ export type UndoImportJobResponse = z.input; // 5. Scheduled Export Jobs // ========================================== +/** + * The two export-schedule cron positions — RETIRED (ADR-0049 enforce-or-remove; + * maintainer ruling 2026-09-06, option A per family, #15954 / #16320). + * `ScheduledExport.schedule.cronExpression` and + * `ScheduleExportRequest.schedule.cronExpression` were declared, parsed and read + * by NOTHING: the whole `ExportJobApiContracts` family has zero consumers, + * rest-server serves no `/api/v1/data/export` route, and `IExportService` has no + * provider binding (its own header records that) — so `POST + * /api/v1/data/export/schedules` is a declared contract nothing implements and + * the cron inside it never fired. Neither schema is `.strict()`, so a bare + * deletion would be a silent strip (ADR-0104); the tombstone makes the removal + * audible in `tsc` (the input type is `never`) and at parse (this string is the + * issue message). Registered as `api/ScheduledExport:schedule.cronExpression` + * and `api/ScheduleExportRequest:schedule.cronExpression` in + * `RETIRED_KEYS_BY_MAJOR[18]` — nested spellings, since neither position has an + * authorable-surface row of its own; D3 semantic entry + * `export-schedule-cron-retired`. No D2 conversion and no `os migrate meta` + * sentence: an export schedule is an API request/response body, not a stack + * collection member or a `sys_metadata` row, so the chain has no seam that ever + * runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent). The + * `schedule` block and its `timezone` stay — the ruling retires the cron + * position, not the block. + */ +const EXPORT_SCHEDULE_CRON_RETIRED_TAIL = + ' was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: ' + + 'no export scheduler exists on the platform (`POST /api/v1/data/export/schedules` is a ' + + 'declared contract no server route implements, and `IExportService` has no provider), so ' + + 'the cron never fired. Delete the key; there is no replacement until an export scheduler ' + + 'exists. The one cron slot the platform evaluates is `Job.schedule.expression` ' + + '(`system/job.zod.ts`): a recurring export is a job whose handler you write.'; +const SCHEDULED_EXPORT_CRON_EXPRESSION_RETIRED = + '`ScheduledExport.schedule.cronExpression`' + EXPORT_SCHEDULE_CRON_RETIRED_TAIL; +const SCHEDULE_EXPORT_REQUEST_CRON_EXPRESSION_RETIRED = + '`ScheduleExportRequest.schedule.cronExpression`' + EXPORT_SCHEDULE_CRON_RETIRED_TAIL; + /** * Scheduled Export Schema * Defines a recurring data export job. @@ -559,7 +594,7 @@ export type UndoImportJobResponse = z.input; * name: 'weekly_account_export', * object: 'account', * format: 'csv', - * schedule: { cronExpression: '0 6 * * MON', timezone: 'America/New_York' }, + * schedule: { timezone: 'America/New_York' }, * delivery: { method: 'email', recipients: ['admin@example.com'] }, * } */ @@ -573,7 +608,8 @@ export const ScheduledExportSchema = lazySchema(() => z.object({ filter: z.record(z.string(), z.unknown()).optional().describe('Record filter criteria'), templateId: z.string().optional().describe('Export template ID for field mappings'), schedule: z.object({ - cronExpression: CronExpressionInputSchema.describe('Cron expression for schedule'), + /** Tombstone — see `EXPORT_SCHEDULE_CRON_RETIRED_TAIL` (ADR-0049, #16320). */ + cronExpression: retiredKey(SCHEDULED_EXPORT_CRON_EXPRESSION_RETIRED), timezone: z.string().default('UTC').describe('IANA timezone'), }).describe('Schedule timing configuration'), delivery: z.object({ @@ -703,7 +739,8 @@ export const ScheduleExportRequestSchema = lazySchema(() => z.object({ filter: z.record(z.string(), z.unknown()).optional().describe('Record filter criteria'), templateId: z.string().optional().describe('Export template ID for field mappings'), schedule: z.object({ - cronExpression: CronExpressionInputSchema.describe('Cron expression for schedule'), + /** Tombstone — see `EXPORT_SCHEDULE_CRON_RETIRED_TAIL` (ADR-0049, #16320). */ + cronExpression: retiredKey(SCHEDULE_EXPORT_REQUEST_CRON_EXPRESSION_RETIRED), timezone: z.string().default('UTC').describe('IANA timezone'), }).describe('Schedule timing configuration'), delivery: z.object({ diff --git a/packages/spec/src/automation/execution.test.ts b/packages/spec/src/automation/execution.test.ts index add592b962..1b5facab07 100644 --- a/packages/spec/src/automation/execution.test.ts +++ b/packages/spec/src/automation/execution.test.ts @@ -725,7 +725,8 @@ describe('ScheduleStateSchema', () => { const state = ScheduleStateSchema.parse({ id: 'sched_001', flowName: 'daily_report', - cronExpression: '0 9 * * MON-FRI', + // `cronExpression` is a retiredKey() tombstone (#16320) — the refusal is + // pinned in `cron-typed-positions-retirement.test.ts`. timezone: 'America/New_York', status: 'active', nextRunAt: '2026-02-03T14:00:00Z', @@ -742,7 +743,7 @@ describe('ScheduleStateSchema', () => { createdBy: 'user_admin', }); expect(state.id).toBe('sched_001'); - expect(state.cronExpression).toEqual({ dialect: 'cron', source: '0 9 * * MON-FRI' }); + expect(state).not.toHaveProperty('cronExpression'); expect(state.totalRuns).toBe(42); expect(state.timezone).toBe('America/New_York'); }); @@ -751,7 +752,6 @@ describe('ScheduleStateSchema', () => { const state = ScheduleStateSchema.parse({ id: 'sched_002', flowName: 'weekly_sync', - cronExpression: '0 6 * * MON', createdAt: '2026-01-01T00:00:00Z', }); expect(state.timezone).toBe('UTC'); @@ -766,7 +766,6 @@ describe('ScheduleStateSchema', () => { const state = ScheduleStateSchema.parse({ id: 'sched_test', flowName: 'test', - cronExpression: '* * * * *', createdAt: '2026-01-01T00:00:00Z', status: v, }); @@ -777,20 +776,23 @@ describe('ScheduleStateSchema', () => { it('should reject missing required fields', () => { expect(() => ScheduleStateSchema.parse({ flowName: 'test', - cronExpression: '* * * * *', createdAt: '2026-01-01T00:00:00Z', })).toThrow(); // missing id expect(() => ScheduleStateSchema.parse({ id: 'sched_003', - cronExpression: '* * * * *', createdAt: '2026-01-01T00:00:00Z', })).toThrow(); // missing flowName + // `cronExpression` was the third required key until #16320 retired it + // (retiredKey() accepts only absence, so the requiredness left with the + // key): a state without it now PARSES. The positive half lives here so the + // former "missing cronExpression" refusal cannot quietly come back; the + // authored-value refusal is pinned in `cron-typed-positions-retirement.test.ts`. expect(() => ScheduleStateSchema.parse({ id: 'sched_004', flowName: 'test', createdAt: '2026-01-01T00:00:00Z', - })).toThrow(); // missing cronExpression + })).not.toThrow(); }); }); diff --git a/packages/spec/src/automation/execution.zod.ts b/packages/spec/src/automation/execution.zod.ts index 1e4824f148..dd8c8c40ee 100644 --- a/packages/spec/src/automation/execution.zod.ts +++ b/packages/spec/src/automation/execution.zod.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { CronExpressionInputSchema } from '../shared/expression.zod'; +import { retiredKey } from '../shared/retired-key'; /** * Automation Execution Protocol @@ -509,6 +509,30 @@ export type ConcurrencyPolicy = z.input; // 6. Scheduled Execution Persistence // ========================================== +/** + * `ScheduleState.cronExpression` — RETIRED (ADR-0049 enforce-or-remove; + * maintainer ruling 2026-09-06, option A per family, #15954 / #16320). It was + * this schema's REQUIRED cron and was read by NOTHING: `ScheduleStateSchema` + * has no consumer outside `packages/spec`, and the schedule trigger that does + * run reads a flow start node's `config.schedule` through + * `trigger-schedule/schedule-trigger.ts` `normalizeSchedule` — a different + * shape this key never reached. The schema is not `.strict()`, so a bare + * deletion would be a silent strip (ADR-0104); the tombstone makes the removal + * audible in `tsc` (the input type is `never`) and at parse (this string is + * the issue message). Registered as `automation/ScheduleState:cronExpression` + * in `RETIRED_KEYS_BY_MAJOR[18]`; D3 semantic entry + * `schedule-state-cron-expression-retired`; no D2 conversion and no + * `os migrate meta` sentence — runtime state is not a stack collection member + * or a `sys_metadata` row, so the chain has no seam that ever runs. + */ +const SCHEDULE_STATE_CRON_EXPRESSION_RETIRED = + '`ScheduleState.cronExpression` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no scheduler consumed a `ScheduleState` row, and ' + + "the schedule trigger that does run reads a flow start node's `config.schedule`, a different " + + 'shape this key never reached. Delete the key; a scheduled flow declares its cadence on the ' + + "flow's start node (`config.schedule`), and the one cron slot the platform evaluates is " + + '`Job.schedule.expression` (`system/job.zod.ts`).'; + /** * Schedule State Schema * Tracks the runtime state of scheduled flow executions. @@ -522,8 +546,14 @@ export const ScheduleStateSchema = lazySchema(() => z.object({ /** Flow reference */ flowName: z.string().describe('Flow machine name'), - /** Schedule configuration */ - cronExpression: CronExpressionInputSchema.describe('Cron expression — cron`0 9 * * MON-FRI`'), + /** + * Tombstone (ADR-0049, #16320) — see `SCHEDULE_STATE_CRON_EXPRESSION_RETIRED`. + * The key was REQUIRED; a `retiredKey()` accepts only absence, so the + * requiredness leaves with it and `timezone` / `status` / `nextRunAt` now + * describe a cadence the row no longer declares. They stay: the ruling + * retires the cron position, not the def. + */ + cronExpression: retiredKey(SCHEDULE_STATE_CRON_EXPRESSION_RETIRED), timezone: z.string().default('UTC').describe('IANA timezone for cron evaluation'), /** Runtime state */ diff --git a/packages/spec/src/contracts/export-service.test.ts b/packages/spec/src/contracts/export-service.test.ts index a86c517bb4..55fa38d3f0 100644 --- a/packages/spec/src/contracts/export-service.test.ts +++ b/packages/spec/src/contracts/export-service.test.ts @@ -18,7 +18,7 @@ describe('Export Service Contract', () => { scheduleExport: async () => ({ name: 'test_schedule', object: 'account', - schedule: { cronExpression: '0 0 * * *', timezone: 'UTC' }, + schedule: { timezone: 'UTC' }, delivery: { method: 'storage' }, enabled: true, }), @@ -83,7 +83,7 @@ describe('Export Service Contract', () => { scheduleExport: async () => ({ name: 'test', object: 'account', - schedule: { cronExpression: '0 0 * * *', timezone: 'UTC' }, + schedule: { timezone: 'UTC' }, delivery: { method: 'storage' }, enabled: true, }), @@ -125,7 +125,7 @@ describe('Export Service Contract', () => { scheduleExport: async () => ({ name: 'test', object: 'account', - schedule: { cronExpression: '0 0 * * *', timezone: 'UTC' }, + schedule: { timezone: 'UTC' }, delivery: { method: 'storage' }, enabled: true, }), diff --git a/packages/spec/src/contracts/export-service.ts b/packages/spec/src/contracts/export-service.ts index 3a87ced142..c469ddfed6 100644 --- a/packages/spec/src/contracts/export-service.ts +++ b/packages/spec/src/contracts/export-service.ts @@ -131,9 +131,14 @@ export interface ScheduleExportInput { filter?: Record; /** Export template ID */ templateId?: string; - /** Schedule timing configuration */ + /** + * Schedule timing configuration. `cronExpression` left this block with the + * spec positions it mirrored (`ScheduleExportRequest.schedule.cronExpression` + * / `ScheduledExport.schedule.cronExpression`, retiredKey() tombstones under + * ADR-0049, #16320): the return type below refuses the key, so an input that + * still demanded it would ask the provider for a cadence it cannot store. + */ schedule: { - cronExpression: string; timezone?: string; }; /** Export delivery configuration */ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index df7ab777f7..8148fa88b5 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8968,6 +8968,117 @@ const tursoConfigTimeoutToTimeoutMs: MetadataConversion = { }, }; +/** + * `connector.syncConfig.schedule` — RETIRED (ADR-0049 enforce-or-remove; + * maintainer ruling 2026-09-06 on #15954, decision batch #56, option A — retire + * — per family; executed by #16320). The cron slot on connector-attached sync + * was declared, parsed into the `{ dialect: 'cron', source }` envelope and read + * by NOTHING: `syncConfig` has no reader outside `packages/spec`, no engine + * schedules a connector sync, and `@objectstack/formula`'s cronEngine has zero + * consumers outside its own package (the ADR-0058 D7 ledger row + * `cron-declared-unwired` recorded it `unevaluated`). An author who wrote + * `schedule: '0 *\/15 * * *'` held a fifteen-minute sync the platform never ran. + * + * A pure lossless delete: the key never had an effect to preserve. The + * `DataSyncConfig` def and every other key on it stay. + * + * WHY THIS ONE OF THE SEVEN cron-typed positions on the card gets a D2 + * conversion and its six siblings do not: it is the only one a stack manifest + * reaches — `stack.zod.ts` `connectors: z.array(DeclarativeConnectorEntrySchema)` + * → `connector.zod.ts` `syncConfig: DataSyncConfigSchema` — and a published + * connector row lands whole in `sys_metadata`, so the chain has a seam that + * sees the key (the `connector-error-mapping-removed` precedent). The other + * six (export API bodies, runtime schedule state, cache / DR operator config) + * are no stack collection member and no metadata type; a conversion there + * would be a transform with no seam that ever runs, so they take D3 semantic + * entries and their prescriptions carry no `os migrate meta` sentence. + * + * This family carries a D3 twin as well, `connector-sync-schedule-retired`, + * per the #15954 ruling's letter ("its D3 entry says so and names the + * measured zero in-repo authors and the NOT-MEASURED out-of-repo + * population"): the strip below is mechanical, but the cadence the author + * meant has no mechanical destination, and the author population outside + * this repo is NOT MEASURED — the twin carries both on the fields the upgrade + * guide, `spec-changes.json` and `os migrate meta` project; this comment + * projects nowhere. + * + * `retiredFromLoadPath`: `DataSyncConfigSchema` tombstones the key + * (`retiredKey`, tsc `never` + the parse-time prescription — the + * `errorMapping` posture on the same connector), so a live parse refuses + * loudly rather than absorbing a cadence the author believes is configured. + * This entry exists so stored 17.x rows replay clean + * (`applyConversionsToStoredItem`) and `os migrate meta --from 17` lists the + * mechanical edits for author sources. One notice per connector that authored + * the key; a connector whose `syncConfig` never carried it, or that has no + * `syncConfig` at all, keeps its identity (copy-on-write). + */ +const connectorSyncScheduleRemoved: MetadataConversion = { + id: 'connector-sync-schedule-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'connector.syncConfig.schedule', + summary: + "connector key 'syncConfig.schedule' removed (#16320, ADR-0049 — the cron slot on " + + 'connector-attached sync was parsed and never evaluated: no engine schedules a connector ' + + "sync, so the cadence an author declared never fired. The `DataSyncConfig` def and every " + + 'other key on it stay; a sync on a cadence is a `job` whose handler drives the connector. ' + + 'The residue — the cadence you meant, and the out-of-repo author population this repo ' + + 'could not measure — is the D3 twin `connector-sync-schedule-retired`)', + apply(stack, emit) { + return mapCollection(stack, 'connectors', (c, path) => { + const syncConfig = c.syncConfig; + if (!isDict(syncConfig)) return c; + const stripped = stripKeys(syncConfig, ['schedule'], emit, `${path}.syncConfig`); + return stripped === syncConfig ? c : { ...c, syncConfig: stripped }; + }); + }, + fixture: { + before: { + connectors: [ + { + name: 'sap_erp', + label: 'SAP ERP', + type: 'saas', + // The measured author shape: the bare cron string the schema used to + // wrap into the envelope, beside keys that stay. + syncConfig: { + strategy: 'incremental', + direction: 'bidirectional', + schedule: '0 */15 * * *', + realtimeSync: true, + batchSize: 500, + }, + }, + // A connector whose syncConfig never authored the key keeps its + // identity — the copy-on-write contract `stripKeys` / `mapCollection` + // are built on. + { name: 'warehouse_sync', label: 'Warehouse Sync', type: 'saas', syncConfig: { direction: 'import' } }, + // And one with no syncConfig at all. + { name: 'payments_api', label: 'Payments API', type: 'api' }, + ], + }, + after: { + connectors: [ + { + name: 'sap_erp', + label: 'SAP ERP', + type: 'saas', + syncConfig: { + strategy: 'incremental', + direction: 'bidirectional', + realtimeSync: true, + batchSize: 500, + }, + }, + { name: 'warehouse_sync', label: 'Warehouse Sync', type: 'saas', syncConfig: { direction: 'import' } }, + { name: 'payments_api', label: 'Payments API', type: 'api' }, + ], + }, + // One notice: the one connector that authored the key. + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -9063,6 +9174,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly; + authored: unknown; + issuePath: (string | number)[]; + /** Only the one stack-collection member owes the `os migrate meta` sentence. */ + migrateSentence: boolean; +} + +const SITES: RetiredSite[] = [ + { + registered: 'api/ScheduledExport:schedule.cronExpression', + qualified: 'ScheduledExport.schedule.cronExpression', + schema: ScheduledExportSchema, + wellFormed: EXPORT_WELL_FORMED, + authored: { ...EXPORT_WELL_FORMED, schedule: { ...EXPORT_WELL_FORMED.schedule, cronExpression: CRON } }, + issuePath: ['schedule', 'cronExpression'], + migrateSentence: false, + }, + { + registered: 'api/ScheduleExportRequest:schedule.cronExpression', + qualified: 'ScheduleExportRequest.schedule.cronExpression', + schema: ScheduleExportRequestSchema, + wellFormed: EXPORT_WELL_FORMED, + authored: { ...EXPORT_WELL_FORMED, schedule: { ...EXPORT_WELL_FORMED.schedule, cronExpression: CRON } }, + issuePath: ['schedule', 'cronExpression'], + migrateSentence: false, + }, + { + registered: 'automation/ScheduleState:cronExpression', + qualified: 'ScheduleState.cronExpression', + schema: ScheduleStateSchema, + wellFormed: STATE_WELL_FORMED, + authored: { ...STATE_WELL_FORMED, cronExpression: CRON }, + issuePath: ['cronExpression'], + migrateSentence: false, + }, + { + registered: 'integration/DataSyncConfig:schedule', + qualified: 'connector.syncConfig.schedule', + schema: DataSyncConfigSchema, + wellFormed: SYNC_WELL_FORMED, + authored: { ...SYNC_WELL_FORMED, schedule: CRON }, + issuePath: ['schedule'], + migrateSentence: true, + }, + { + registered: 'system/CacheWarmup:schedule', + qualified: 'CacheWarmup.schedule', + schema: CacheWarmupSchema, + wellFormed: WARMUP_WELL_FORMED, + authored: { ...WARMUP_WELL_FORMED, schedule: CRON }, + issuePath: ['schedule'], + migrateSentence: false, + }, + { + registered: 'system/BackupConfig:schedule', + qualified: 'BackupConfig.schedule', + schema: BackupConfigSchema, + wellFormed: BACKUP_WELL_FORMED, + authored: { ...BACKUP_WELL_FORMED, schedule: CRON }, + issuePath: ['schedule'], + migrateSentence: false, + }, + { + registered: 'system/DisasterRecoveryPlan:testing.schedule', + qualified: 'DisasterRecoveryPlan.testing.schedule', + schema: DisasterRecoveryPlanSchema, + wellFormed: DR_PLAN_WELL_FORMED, + authored: { ...DR_PLAN_WELL_FORMED, testing: { ...DR_TESTING_WELL_FORMED, schedule: CRON } }, + issuePath: ['testing', 'schedule'], + migrateSentence: false, + }, +]; + +/** The same tombstones seen through the shapes that nest them. */ +const CARRIERS: Array & { via: string }> = [ + { + via: 'Connector.syncConfig', + qualified: 'connector.syncConfig.schedule', + schema: ConnectorSchema, + wellFormed: CONNECTOR_WELL_FORMED, + authored: { ...CONNECTOR_WELL_FORMED, syncConfig: { ...SYNC_WELL_FORMED, schedule: CRON } }, + issuePath: ['syncConfig', 'schedule'], + }, + { + via: 'DeclarativeConnectorEntry.syncConfig (the `/meta/connector` write door inherits it)', + qualified: 'connector.syncConfig.schedule', + schema: DeclarativeConnectorEntrySchema, + wellFormed: CONNECTOR_WELL_FORMED, + authored: { ...CONNECTOR_WELL_FORMED, syncConfig: { ...SYNC_WELL_FORMED, schedule: CRON } }, + issuePath: ['syncConfig', 'schedule'], + }, + { + via: 'DisasterRecoveryPlan.backup', + qualified: 'BackupConfig.schedule', + schema: DisasterRecoveryPlanSchema, + wellFormed: DR_PLAN_WELL_FORMED, + authored: { ...DR_PLAN_WELL_FORMED, backup: { ...BACKUP_WELL_FORMED, schedule: CRON } }, + issuePath: ['backup', 'schedule'], + }, + { + via: 'DistributedCacheConfig.warmup', + qualified: 'CacheWarmup.schedule', + schema: DistributedCacheConfigSchema, + wellFormed: CACHE_WELL_FORMED, + authored: { ...CACHE_WELL_FORMED, warmup: { ...WARMUP_WELL_FORMED, schedule: CRON } }, + issuePath: ['warmup', 'schedule'], + }, +]; + +const CONVERSION_ID = 'connector-sync-schedule-removed'; +/** The connector family's D3 twin — the ruling's carrier of the population reading. */ +const SEMANTIC_TWIN_ID = 'connector-sync-schedule-retired'; +/** The four families with NO stack seam: a D3 semantic entry each and no D2. */ +const SEMANTIC_IDS = [ + 'export-schedule-cron-retired', + 'schedule-state-cron-expression-retired', + 'cache-warmup-schedule-retired', + 'disaster-recovery-schedules-retired', +]; +const HOUSE_MIGRATE_SENTENCE = + /Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\.$/; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function findIssue(schema: ZodTypeAny, authored: unknown, issuePath: (string | number)[], label: string) { + const result = schema.safeParse(authored); + expect(result.success, `${label} must be refused`).toBe(false); + if (result.success) return undefined; // narrowing; the assertion above already failed + const wanted = issuePath.join('.'); + const issue = result.error.issues.find((i) => i.path.join('.') === wanted); + expect(issue, `the refusal must surface at ${wanted}`).toBeDefined(); + return issue!; +} + +function expectTombstoneRefusal( + site: Pick, + migrateSentence: boolean, +) { + const issue = findIssue(site.schema, site.authored, site.issuePath, site.qualified); + if (!issue) return; + // The machine-readable half of the envelope this surface actually has: a + // `retiredKey()` tombstone raises `invalid_type` from its `z.never()`. + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(site.issuePath); + // The prescription IS the migration doc for whoever hits it — contract, not + // commentary: it opens with the qualified key, names the version and the + // ADR, says why the key was inert, and tells the author what to do. + expect(issue.message).toMatch( + new RegExp('^`' + escapeRegExp(site.qualified) + '` was removed in @objectstack/spec 17 \\(ADR-0049 enforce-or-remove\\) — nothing ever read it'), + ); + expect(issue.message).toMatch(/Delete the key/); + // Every prescription points the reader at the ONE cron slot the platform + // evaluates, so nobody re-declares the retired key as a repair. + expect(issue.message).toMatch(/`Job\.schedule\.expression`/); + // Customer-facing text carries the ADR, never an issue id. + expect(issue.message).toMatch(/ADR-0049/); + expect(issue.message).not.toMatch(/#\d{3,}/); + // ⭐ The per-family split, pinned in both directions. The sentence states a + // property of the TOOL — `os migrate meta` lists an edit for a key only where + // the chain has a seam that sees it — so it is TRUE for the one stack + // collection member and FALSE for the six others; the class pin + // (`retired-key-migrate-sentence.test.ts`) holds the wording, this pin holds + // WHERE it may appear. + if (migrateSentence) { + expect(issue.message).toMatch(HOUSE_MIGRATE_SENTENCE); + } else { + expect(issue.message).not.toMatch(/os migrate meta/); + } +} + +describe('[#16320] cron-typed positions retirement — refusal at every site', () => { + for (const site of SITES) { + it(`REJECTS an authored \`${site.qualified}\` at path \`${site.issuePath.join('.')}\`, carrying the prescription`, () => { + expectTombstoneRefusal(site, site.migrateSentence); + // Attribution control: the same document WITHOUT the key is accepted, so + // the refusal above is attributable to the retired key and nothing else. + expect(site.schema.safeParse(site.wellFormed).success, `${site.qualified}: well-formed control must parse`).toBe(true); + }); + } + + it('refuses the envelope spelling too — both shapes the old schema accepted are gone', () => { + // One site per shape of the old input: the bare string (above, all seven) + // and the `{ dialect, source }` envelope the parse used to normalize to. + const envelopeSites: Array<[RetiredSite, unknown]> = [ + [SITES[3]!, { ...SYNC_WELL_FORMED, schedule: CRON_ENVELOPE }], + [SITES[0]!, { ...EXPORT_WELL_FORMED, schedule: { ...EXPORT_WELL_FORMED.schedule, cronExpression: CRON_ENVELOPE } }], + ]; + for (const [site, authored] of envelopeSites) { + const issue = findIssue(site.schema, authored, site.issuePath, `${site.qualified} (envelope)`); + expect(issue?.code).toBe('invalid_type'); + } + }); + + for (const carrier of CARRIERS) { + it(`REJECTS \`${carrier.qualified}\` through \`${carrier.via}\`, at path \`${carrier.issuePath.join('.')}\``, () => { + expectTombstoneRefusal(carrier, carrier.qualified === 'connector.syncConfig.schedule'); + expect(carrier.schema.safeParse(carrier.wellFormed).success, `${carrier.via}: well-formed control must parse`).toBe(true); + }); + } + + it('REJECTS `connector.syncConfig.schedule` through the registry-bound `/meta/connector` schema', () => { + // The registry lookup is the real `/meta` entry point — a future rebinding + // that pointed `connector` at some third shape would pass the carrier pin + // above and still accept the key in production. + const schema = getMetadataTypeSchema('connector'); + expect(schema, 'no schema bound for `connector`').toBeDefined(); + const authored = { ...CONNECTOR_WELL_FORMED, syncConfig: { ...SYNC_WELL_FORMED, schedule: CRON } }; + // The same envelope the carriers get — path, code and the prescription + // with its migrate sentence — so a rebinding to a shape that refuses for + // some OTHER reason (a strict unknown-key verdict, say) reads red here. + const issue = findIssue(schema!, authored, ['syncConfig', 'schedule'], 'connector via /meta/connector'); + expect(issue?.code).toBe('invalid_type'); + expect(issue?.path).toEqual(['syncConfig', 'schedule']); + expect(issue?.message).toMatch(/^`connector\.syncConfig\.schedule` was removed in @objectstack\/spec 17/); + expect(issue?.message).toMatch(HOUSE_MIGRATE_SENTENCE); + expect(schema!.safeParse(CONNECTOR_WELL_FORMED).success).toBe(true); + }); + + it('REJECTS it in `stack.connectors[]` — the real authoring path, and the reason this family converts', async () => { + const { ObjectStackSchema } = await import('./stack.zod'); + const rejected = ObjectStackSchema.safeParse({ + connectors: [{ ...CONNECTOR_WELL_FORMED, syncConfig: { ...SYNC_WELL_FORMED, schedule: CRON } }], + }); + expect(rejected.success).toBe(false); + if (rejected.success) return; + const issue = rejected.error.issues.find((i) => i.path.join('.') === 'connectors.0.syncConfig.schedule'); + expect(issue, 'the refusal must surface through `connectors[]`').toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['connectors', 0, 'syncConfig', 'schedule']); + expect(issue!.message).toMatch(HOUSE_MIGRATE_SENTENCE); + // Positive control: the identical stack minus the retired key parses. + expect(ObjectStackSchema.safeParse({ connectors: [CONNECTOR_WELL_FORMED] }).success).toBe(true); + }); +}); + +describe('[#16320] no-materialize: parsed documents carry none of the seven keys', () => { + it('on every base schema', () => { + for (const site of SITES) { + const parsed = site.schema.parse(site.wellFormed) as Record; + let at: unknown = parsed; + for (const seg of site.issuePath.slice(0, -1)) at = (at as Record)[seg as string]; + expect(at, `${site.qualified}: the enclosing block must still parse`).toBeDefined(); + expect(at).not.toHaveProperty(String(site.issuePath[site.issuePath.length - 1])); + } + // Attribution: the surviving defaults still materialize, so the absences + // above are the tombstones' doing and not a broken parse. + expect(ScheduledExportSchema.parse(EXPORT_WELL_FORMED).schedule.timezone).toBe('America/New_York'); + expect(ScheduleExportRequestSchema.parse({ ...EXPORT_WELL_FORMED, schedule: {} }).schedule.timezone).toBe('UTC'); + expect(ScheduleStateSchema.parse(STATE_WELL_FORMED).timezone).toBe('UTC'); + expect(DataSyncConfigSchema.parse(SYNC_WELL_FORMED).realtimeSync).toBe(false); + expect(CacheWarmupSchema.parse(WARMUP_WELL_FORMED).concurrency).toBe(10); + expect(BackupConfigSchema.parse(BACKUP_WELL_FORMED).verifyAfterBackup).toBe(true); + }); + + it('`ScheduleState.cronExpression` was REQUIRED — the requiredness left with the key', () => { + // A tombstone accepts only absence, so a state that never declares a cron + // now parses; `timezone` / `status` / `nextRunAt` stay by the ruling (it + // retires the cron position, not the def) and keep their defaults. + const parsed = ScheduleStateSchema.parse(STATE_WELL_FORMED); + expect(parsed.status).toBe('active'); + expect(parsed.timezone).toBe('UTC'); + // The other required keys are still required — the requiredness that + // left is exactly the retired key's. + expect(ScheduleStateSchema.safeParse({ id: 'sched_002', createdAt: '2026-01-01T00:00:00Z' }).success).toBe(false); + }); +}); + +describe('[#16320] the tsc channel: the input type of all seven keys is `never`', () => { + it('fails tsc at every authoring site', () => { + const sched: ScheduledExport = { + ...EXPORT_WELL_FORMED, + // @ts-expect-error — `schedule.cronExpression` is a retiredKey() tombstone: its input type is `never`. + schedule: { ...EXPORT_WELL_FORMED.schedule, cronExpression: CRON }, + }; + const request: ScheduleExportRequest = { + ...EXPORT_WELL_FORMED, + // @ts-expect-error — the request body's twin tombstone. + schedule: { ...EXPORT_WELL_FORMED.schedule, cronExpression: CRON }, + }; + const state: ScheduleState = { + ...STATE_WELL_FORMED, + // @ts-expect-error — `cronExpression` is a retiredKey() tombstone (and no longer required). + cronExpression: CRON, + }; + const sync: DataSyncConfig = { + ...SYNC_WELL_FORMED, + // @ts-expect-error — `schedule` is a retiredKey() tombstone. + schedule: CRON, + }; + const connector: Connector = { + ...CONNECTOR_WELL_FORMED, + // @ts-expect-error — the tombstone reaches through the carrier. + syncConfig: { ...SYNC_WELL_FORMED, schedule: CRON }, + }; + const warmup: CacheWarmup = { + ...WARMUP_WELL_FORMED, + // @ts-expect-error — `schedule` is a retiredKey() tombstone. + schedule: CRON, + }; + const cache: DistributedCacheConfig = { + ...CACHE_WELL_FORMED, + // @ts-expect-error — the tombstone reaches through the carrier. + warmup: { ...WARMUP_WELL_FORMED, schedule: CRON }, + }; + const backup: BackupConfig = { + ...BACKUP_WELL_FORMED, + // @ts-expect-error — `schedule` is a retiredKey() tombstone. + schedule: CRON, + }; + const plan: DisasterRecoveryPlan = { + ...DR_PLAN_WELL_FORMED, + // @ts-expect-error — `testing.schedule` is a retiredKey() tombstone. + testing: { ...DR_TESTING_WELL_FORMED, schedule: CRON }, + }; + // The literals above are typed, so tsc is the assertion; at runtime the + // same values are refused, which keeps this case from being vacuous. + for (const [schema, value] of [ + [ScheduledExportSchema, sched], + [ScheduleExportRequestSchema, request], + [ScheduleStateSchema, state], + [DataSyncConfigSchema, sync], + [ConnectorSchema, connector], + [CacheWarmupSchema, warmup], + [DistributedCacheConfigSchema, cache], + [BackupConfigSchema, backup], + [DisasterRecoveryPlanSchema, plan], + ] as Array<[ZodTypeAny, unknown]>) { + expect(schema.safeParse(value).success).toBe(false); + } + }); +}); + +describe('[#16320] ADR-0087 registration — one shape per family', () => { + it('declares all seven sites under major 18', () => { + for (const site of SITES) { + expect(RETIRED_KEYS_BY_MAJOR[18], `${site.registered} must be declared`).toContain(site.registered); + } + }); + + it('the connector family converts (D2, retired from the load path) and is wired into the step-18 chain', () => { + const conversion = CONVERSIONS_BY_MAJOR[18]!.find((c) => c.id === CONVERSION_ID); + expect(conversion, `${CONVERSION_ID} must exist`).toBeDefined(); + expect(conversion!.toMajor).toBe(18); + // The tombstone owns the live refusal; the conversion replays stored rows + // and feeds `os migrate meta` — which is what makes the migrate sentence on + // the connector prescription TRUE of the tool. + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.surface).toBe('connector.syncConfig.schedule'); + // One notice per connector that authored the key — the fixture carries + // exactly one such connector beside two that keep their identity. + expect(conversion!.fixture.expectedNotices).toBe(1); + const step = MIGRATIONS_BY_MAJOR[18]; + expect(step).toBeDefined(); + expect(step!.conversionIds, `${CONVERSION_ID} must be graduated into the step-18 chain`).toContain(CONVERSION_ID); + }); + + it('the connector family ALSO carries the D3 twin the ruling names, with the population reading on projecting fields', () => { + // #15954, literally: "`connectors[].syncConfig.schedule` is the one + // stack-collection member: its D3 entry says so and names the measured + // zero in-repo authors and the NOT-MEASURED out-of-repo population." The + // strip is the D2's; what no conversion can carry — the population this + // repo could not measure — lives on `reason` / `acceptanceCriteria`, the + // fields `spec-changes.json`, the upgrade guide and `os migrate meta` + // project. A first cut of this pin asserted the twin's ABSENCE (mechanical + // strip ⇒ no residue, the `connector-error-mapping-removed` shape); that + // was a deviation from the ruling's letter, and it inverts here. + const step = MIGRATIONS_BY_MAJOR[18]!; + const twin = step.semantic.find((s) => s.id === SEMANTIC_TWIN_ID); + expect(twin, `${SEMANTIC_TWIN_ID} must be wired into the step-18 chain`).toBeDefined(); + expect(twin!.surface).toMatch(/connectors\[\]\.syncConfig\.schedule/); + // The wording IS the contract here — the ruling names what the entry says. + expect(twin!.reason).toMatch(/ZERO in-repo authors/); + expect(twin!.reason).toMatch(/NOT MEASURED/); + expect(twin!.acceptanceCriteria).toMatch(/by hand/); + // It names its D2 half, so a reader of either finds the other. + expect(twin!.replacement).toContain(CONVERSION_ID); + // Exactly one twin — the filter that once asserted emptiness now asserts the singleton. + expect(step.semantic.filter((s) => /sync-schedule|connector-sync/.test(s.id)).map((s) => s.id)).toEqual([SEMANTIC_TWIN_ID]); + }); + + it('replays the connector strip over a stored 17.x `connector` row — the seam `retiredFromLoadPath` exists for', () => { + // The live parse refuses (the tombstone); a row at rest has no author to + // teach, so the stored seam replays the FULL chain, retired entries + // included (ADR-0087 addendum). This is the family's own evidence for + // that seam, beside the object / action rows `stored.test.ts` pins. + const row = { ...CONNECTOR_WELL_FORMED, syncConfig: { ...SYNC_WELL_FORMED, schedule: CRON } }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('connector', row, { onNotice: (n) => notices.push(n) }); + expect(out.syncConfig).toEqual(SYNC_WELL_FORMED); + expect(out).not.toHaveProperty(['syncConfig', 'schedule']); + expect(notices.map((n) => n.conversionId)).toContain(CONVERSION_ID); + // The converted row is what the door now accepts — the seam hands the live schema a clean row. + expect(DeclarativeConnectorEntrySchema.safeParse(out).success).toBe(true); + // A row that never authored the key keeps its identity (copy-on-write). + expect(applyConversionsToStoredItem('connector', CONNECTOR_WELL_FORMED)).toEqual(CONNECTOR_WELL_FORMED); + }); + + it('the other four families take a D3 semantic entry each, and NO D2 conversion', () => { + const step = MIGRATIONS_BY_MAJOR[18]!; + for (const id of SEMANTIC_IDS) { + const entry = step.semantic.find((s) => s.id === id); + expect(entry, `${id} must be wired into the step-18 chain`).toBeDefined(); + expect(entry!.reason.length).toBeGreaterThan(0); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + // The route is stated where the next reader looks: why D3 semantic and + // not D2 — no stack seam (the additionalTypes precedent). + expect(entry!.reason).toMatch(/not a D2 conversion/); + } + // Deliberately no mechanical conversion for any of them — a transform + // with no seam that ever runs is the predicted failure this pin closes. + const strayConversions = step.conversionIds.filter((id) => /export|schedule-state|warmup|backup|disaster/.test(id)); + expect(strayConversions).toEqual([]); + expect(CONVERSIONS_BY_MAJOR[18]!.filter((c) => /export|schedule-state|warmup|backup|disaster/.test(c.id))).toEqual([]); + }); +}); diff --git a/packages/spec/src/integration/connector-author-shape.test.ts b/packages/spec/src/integration/connector-author-shape.test.ts index d801078bfc..7261e09441 100644 --- a/packages/spec/src/integration/connector-author-shape.test.ts +++ b/packages/spec/src/integration/connector-author-shape.test.ts @@ -60,7 +60,9 @@ import { // the bare `Connector` is now `z.input` — the shape the document annotates with // — and `ConnectorParsed` carries the parse result. The pinned FACT is // unchanged; the two names swapped sides, which is what the last describe block -// in this file now measures. +// in this file now measures. #16320 then retired `syncConfig.schedule` itself +// (ADR-0049 — nothing evaluated it), the one key whose TYPE differed between +// the two sides, so that block now measures the flip on the defaults alone. const SPEC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const SYNC_ARCHITECTURE = resolve(SPEC_DIR, 'docs/SYNC_ARCHITECTURE.md'); @@ -425,16 +427,23 @@ describe('[#5515] the bare `Connector` is the author shape; `ConnectorParsed` is // The fourth diagnostic, pinned as an ANNOTATION fact rather than fixed by // renaming this file's aliases. Direction stated before running: the SAME // literal is green under the bare `Connector` and red under `ConnectorParsed`, - // because `z.infer` is the post-parse shape — `syncConfig.schedule` becomes the - // `{ dialect, source }` envelope and every `.default()` key becomes required. - // Before ADR-0122 phase 2 these two probes read `ConnectorInput` and - // `Connector`. The literal and both verdicts are unchanged; only which name - // sits on which side moved, which is the whole claim of the flip as a test. + // because `z.infer` is the post-parse shape — every `.default()` key becomes + // required. Before ADR-0122 phase 2 these two probes read `ConnectorInput` + // and `Connector`; only which name sits on which side moved, which is the + // whole claim of the flip as a test. + // + // The literal used to carry `syncConfig: { schedule: '*/15 * * * *' }` as + // well — the one key whose TYPE differed between the sides (a bare cron + // string in, the `{ dialect, source }` envelope out), and the half of this + // block that asserted `dialect`. #16320 retired that key (ADR-0049; its + // refusal is owned by `cron-typed-positions-retirement.test.ts`), and no + // other key on `Connector` transforms its type at parse — so the flip is + // measured on the defaults alone, which were always the larger half. const literal = `{ name: 'sap_erp_connector', label: 'SAP ERP Integration', type: 'saas', - syncConfig: { schedule: '*/15 * * * *' }, + syncConfig: { strategy: 'incremental' }, }`; const probes = { 'author-connector': ` @@ -451,14 +460,19 @@ describe('[#5515] the bare `Connector` is the author shape; `ConnectorParsed` is const results = compileProbes(probes); - it('accepts the bare cron string and the omitted defaults under the bare `Connector`', () => { + it('accepts the omitted defaults under the bare `Connector`', () => { expect(render(results.get('author-connector')!)).toBe(''); }); - it('rejects the same literal under `ConnectorParsed`, on the cron envelope and the defaults', () => { + it('rejects the same literal under `ConnectorParsed`, on the defaults it left out', () => { const message = render(results.get('parsed-connector')!); - expect(message).toContain("Type 'string' is not assignable"); - expect(message).toContain('dialect'); + // TS2739 on the innermost mismatch first: the parse supplies `direction`, + // `realtimeSync`, `conflictResolution`, `batchSize`, `deleteMode` under + // `syncConfig` (and `enabled` / `status` one level up); `z.infer` demands + // them all of the author. + expect(message).toMatch(/TS2739: .* is missing the following properties/); + expect(message).toContain('direction'); + expect(message).toContain('realtimeSync'); }); it('a parse turns the one into the other — the annotation is the only difference', () => { @@ -466,13 +480,14 @@ describe('[#5515] the bare `Connector` is the author shape; `ConnectorParsed` is name: 'sap_erp_connector', label: 'SAP ERP Integration', type: 'saas', - syncConfig: { schedule: '*/15 * * * *' }, + syncConfig: { strategy: 'incremental' }, }); - expect(parsed.syncConfig!.schedule).toEqual({ dialect: 'cron', source: '*/15 * * * *' }); // The defaults the author left out, supplied by the parse. This is what // makes annotating the example with the parsed alias wrong rather than // merely inconvenient: it would demand the author write them all out. expect(parsed.syncConfig!.strategy).toBe('incremental'); + expect(parsed.syncConfig!.direction).toBe('import'); + expect(parsed.syncConfig).not.toHaveProperty('schedule'); expect(parsed.enabled).toBe(true); expect(parsed.status).toBe('inactive'); }); diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index 3a1289e46d..9e01146825 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -235,7 +235,8 @@ describe('DataSyncConfigSchema', () => { const config: DataSyncConfig = { strategy: 'incremental', direction: 'bidirectional', - schedule: '0 */6 * * *', + // `schedule` is a retiredKey() tombstone (#16320) — the refusal is + // pinned in `cron-typed-positions-retirement.test.ts`. realtimeSync: true, conflictResolution: 'latest_wins', batchSize: 1000, diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 6e83add4c0..66ef141b9e 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { CronExpressionInputSchema } from '../shared/expression.zod'; import { WebhookSchema } from '../automation/webhook.zod'; import { ConnectorAuthConfigSchema, ConnectorInstanceAuthSchema } from '../shared/connector-auth.zod'; import { FieldMappingSchema as BaseFieldMappingSchema } from '../shared/mapping.zod'; @@ -233,6 +232,37 @@ export const ConnectorConflictResolutionSchema = lazySchema(() => z.enum([ export type ConnectorConflictResolution = z.input; +/** + * `connector.syncConfig.schedule` — RETIRED (ADR-0049 enforce-or-remove; + * maintainer ruling 2026-09-06, option A per family, #15954 / #16320). The + * cron slot on connector-attached sync was declared, parsed into the + * `{ dialect: 'cron', source }` envelope and read by NOTHING: `syncConfig` has + * no reader outside `packages/spec`, no engine schedules a connector sync, and + * `@objectstack/formula`'s cronEngine has zero consumers outside its own + * package. `DataSyncConfigSchema` is not `.strict()`, so a bare deletion would + * be a silent strip (ADR-0104); the tombstone makes the removal audible in + * `tsc` (the input type is `never`) and at parse (this string is the issue + * message), and it reaches every carrier — `ConnectorSchema.syncConfig`, + * `DeclarativeConnectorEntrySchema` (`stack.connectors[]`) and the + * `/meta/connector` door. Registered as `integration/DataSyncConfig:schedule` + * in `RETIRED_KEYS_BY_MAJOR[18]`. This is the ONE position of the seven that a + * stack manifest reaches, so unlike its siblings it carries a D2 conversion, + * `connector-sync-schedule-removed` (one strip per `connectors[]` entry that + * authored the key), the house `os migrate meta` sentence — which must be + * true of the tool, and here is — and, per the #15954 ruling's letter, a D3 + * twin `connector-sync-schedule-retired` that carries the population reading + * on fields that PROJECT (this comment does not): zero in-repo authors + * (examples, docs, skills swept with controls; objectui at the pinned sha + * clean); out-of-repo stacks NOT MEASURED from this repo. + */ +const SYNC_SCHEDULE_RETIRED = + '`connector.syncConfig.schedule` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no engine schedules a connector sync, so the cron ' + + 'was parsed and never fired. Delete the key; sync on a cadence is a `job` ' + + '(`Job.schedule.expression`, the one cron slot the platform evaluates) whose handler drives ' + + 'the connector, and `realtimeSync` is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + /** * Data Synchronization Configuration */ @@ -251,10 +281,8 @@ export const DataSyncConfigSchema = lazySchema(() => z.object({ 'bidirectional', // Both ways ]).optional().default('import').describe('Sync direction'), - /** - * Sync frequency (cron expression) - */ - schedule: CronExpressionInputSchema.optional().describe('Cron expression for scheduled sync — cron`0 */15 * * *`'), + /** Tombstone (ADR-0049, #16320) — see `SYNC_SCHEDULE_RETIRED`; D2 `connector-sync-schedule-removed`. */ + schedule: retiredKey(SYNC_SCHEDULE_RETIRED), /** * Enable real-time sync via webhooks diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__ScheduleExportRequest__schedule.cronExpression.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__ScheduleExportRequest__schedule.cronExpression.ts new file mode 100644 index 0000000000..6c2906868d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__ScheduleExportRequest__schedule.cronExpression.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16320 — the export-schedule family's second position, +// `ScheduleExportRequest.schedule.cronExpression`: the same cron slot on the +// request body of `POST /api/v1/data/export/schedules`, which no server route +// implements. Same reading, same route (a `retiredKey()` tombstone on a +// non-strict `z.object`, ADR-0104), same major, same absence of a D2 +// conversion (an API request body is not a stack collection member — the +// `kernel/MetadataPluginConfig:additionalTypes` precedent), same nested +// spelling (no authorable-surface row of its own; `api/ScheduleExportRequest:schedule` +// is the row). See `18.api__ScheduledExport__schedule.cronExpression.ts` for +// the retirement record. +// D3 semantic entry: `export-schedule-cron-retired`. +export const entry = 'api/ScheduleExportRequest:schedule.cronExpression'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__ScheduledExport__schedule.cronExpression.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__ScheduledExport__schedule.cronExpression.ts new file mode 100644 index 0000000000..437bc1b1dc --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__ScheduledExport__schedule.cronExpression.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing +// reads (#15954 ruling, director decision batch #56, maintainer 「其他同意」, +// 2026-09-06: option A — retire — per family). Export-schedule family, first +// of two positions: `ScheduledExport.schedule.cronExpression`. Declared, parsed +// into the `{ dialect: 'cron', source }` envelope and read by NOTHING — the +// whole `ExportJobApiContracts` family has zero consumers, rest-server serves +// no `/api/v1/data/export` route, and `IExportService` has no provider binding +// (its own header records that), so `POST /api/v1/data/export/schedules` is a +// declared contract nothing implements and the cron inside it never fired. +// Tombstoned with `retiredKey()`: the schema is a non-strict `z.object`, so a +// bare deletion would be a silent strip (ADR-0104). +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// Registered here but NOT in `src/conversions/registry.ts`, for the reason +// `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain +// walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata +// type onto one of its collections; an export schedule is an API body and is +// neither, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription therefore carries no `os migrate meta` sentence (it +// must be true of the tool) and reaches authors through the tombstone (`tsc` + +// the parse) and the D3 semantic entry named below. +// +// A NESTED site: the authorable-surface ratchet walks top-level def +// properties only (`api/ScheduledExport:schedule` is the row), so no +// `[RETIRED]` row exists for the cron itself and gate (b) of +// `build-schemas.ts` neither demands nor refuses this entry — it is here for +// the spec-changes / upgrade-guide projection, spelled the way +// `api/BatchEndpointsConfig:operations.upsertMany` is. +// D3 semantic entry: `export-schedule-cron-retired`. +export const entry = 'api/ScheduledExport:schedule.cronExpression'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.automation__ScheduleState__cronExpression.ts b/packages/spec/src/migrations/entries/retired-keys/18.automation__ScheduleState__cronExpression.ts new file mode 100644 index 0000000000..bdf6e66254 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.automation__ScheduleState__cronExpression.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing +// reads (#15954 ruling, decision batch #56, 2026-09-06: option A — retire — per +// family). Automation family: `ScheduleState.cronExpression`, the schema's +// REQUIRED cron, read by NOTHING — `ScheduleStateSchema` has no consumer outside +// `packages/spec`, and the schedule trigger that does run reads a flow start +// node's `config.schedule` through `trigger-schedule/schedule-trigger.ts` +// `normalizeSchedule`, a different shape this key never reached. Tombstoned +// with `retiredKey()` (non-strict `z.object`, ADR-0104); the requiredness +// leaves with the key, since a tombstone accepts only absence. +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// Registered here but NOT in `src/conversions/registry.ts`: runtime schedule +// state is not a stack collection member and `scheduleState` is no metadata +// type, so a MetadataConversion would be a transform with no seam that ever +// runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent). No +// `os migrate meta` sentence, for the same reason. +// D3 semantic entry: `schedule-state-cron-expression-retired`. +export const entry = 'automation/ScheduleState:cronExpression'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__DataSyncConfig__schedule.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__DataSyncConfig__schedule.ts new file mode 100644 index 0000000000..27e49e114a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__DataSyncConfig__schedule.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing +// reads (#15954 ruling, decision batch #56, 2026-09-06: option A — retire — per +// family). Connector family: `DataSyncConfig.schedule`, the cron slot on +// connector-attached sync (`ConnectorSchema.syncConfig`). Declared, parsed into +// the cron envelope and read by NOTHING — `syncConfig` has no reader outside +// `packages/spec`, no engine schedules a connector sync, and +// `@objectstack/formula`'s cronEngine has zero consumers outside its package. +// Tombstoned with `retiredKey()` (non-strict `z.object`, ADR-0104); the +// tombstone reaches every carrier — `Connector.syncConfig`, +// `DeclarativeConnectorEntry` (`stack.connectors[]`) and the `/meta/connector` +// door — through the one `DataSyncConfigSchema` they all nest. +// +// THE ONE POSITION OF THE SEVEN A STACK MANIFEST REACHES (`stack.zod.ts` +// `connectors: z.array(DeclarativeConnectorEntrySchema)` → `syncConfig`), so +// unlike its six siblings this family takes the `connector-error-mapping-removed` +// shape: a D2 conversion, `connector-sync-schedule-removed` (one strip per +// `connectors[]` entry that authored the key, `retiredFromLoadPath`), wired +// into the step-18 chain, and the house `os migrate meta --from 17` sentence +// on the prescription — which must be true of the tool, and here is. And a +// D3 twin, `connector-sync-schedule-retired`, per the #15954 ruling's letter +// ("its D3 entry says so and names the measured zero in-repo authors and the +// NOT-MEASURED out-of-repo population"): the strip is the D2's; the twin +// carries the population reading on fields that PROJECT (`reason`, +// `acceptanceCriteria` → the upgrade guide, `spec-changes.json`, `os migrate +// meta`), which this comment does not. +// +// Measured author population (the only family whose entry owes one, since it +// is the only stack-collection member; the projecting copy is the D3 twin's +// `reason`): zero in-repo authors — `examples/**`, +// `skills/**`, `content/docs/**` (generated references excluded) and every +// package outside `packages/spec` swept for `syncConfig` + `schedule`, with the +// declaring file lighting the control; objectui at the pinned sha +// `53ded82bf7a4` has no `syncConfig.schedule` (its `syncConfig` hits are the +// react offline hook's own key, `ui/offline.zod.ts`). Out-of-repo stacks are +// NOT MEASURABLE from this repo and are not claimed zero. +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +export const entry = 'integration/DataSyncConfig:schedule'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__BackupConfig__schedule.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__BackupConfig__schedule.ts new file mode 100644 index 0000000000..cd2c3e1884 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__BackupConfig__schedule.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing +// reads (#15954 ruling, decision batch #56, 2026-09-06: option A — retire — per +// family). Backup / DR-testing family, first of two positions: +// `BackupConfig.schedule`. Declared, parsed into the cron envelope and read by +// NOTHING — `BackupConfigSchema` has no consumer outside `packages/spec`, so +// no automated backup ever ran on it. Tombstoned with `retiredKey()` +// (non-strict `z.object`, ADR-0104). +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// Registered here but NOT in `src/conversions/registry.ts`: a disaster-recovery +// plan is operator configuration, never a stack collection member or a +// `sys_metadata` row, so a MetadataConversion would be a transform with no +// seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` +// precedent). No `os migrate meta` sentence, for the same reason. +// D3 semantic entry: `disaster-recovery-schedules-retired`. +export const entry = 'system/BackupConfig:schedule'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__CacheWarmup__schedule.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheWarmup__schedule.ts new file mode 100644 index 0000000000..dbcc2f412f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheWarmup__schedule.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing +// reads (#15954 ruling, decision batch #56, 2026-09-06: option A — retire — per +// family). Cache-warmup family: `CacheWarmup.schedule`. Declared, parsed into +// the cron envelope and read by NOTHING — `CacheWarmupSchema` has no consumer +// outside `packages/spec`, so no warmup ever ran on a schedule. Tombstoned with +// `retiredKey()` (non-strict `z.object`, ADR-0104). The `strategy` enum keeps +// its `scheduled` member: a value, not a position this ruling names, and +// exactly as inert before (nothing reads the def). +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// Registered here but NOT in `src/conversions/registry.ts`: a cache config is +// plugin TS configuration, never a stack collection member or a +// `sys_metadata` row, so a MetadataConversion would be a transform with no +// seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` +// precedent). No `os migrate meta` sentence, for the same reason. +// D3 semantic entry: `cache-warmup-schedule-retired`. +export const entry = 'system/CacheWarmup:schedule'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__DisasterRecoveryPlan__testing.schedule.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__DisasterRecoveryPlan__testing.schedule.ts new file mode 100644 index 0000000000..748ced5b64 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__DisasterRecoveryPlan__testing.schedule.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #16320 — the backup / DR-testing family's second position, +// `DisasterRecoveryPlan.testing.schedule`: the periodic DR-test cron, read by +// NOTHING (`DisasterRecoveryPlanSchema` has no consumer outside +// `packages/spec`). Same route (a `retiredKey()` tombstone on a non-strict +// `z.object`, ADR-0104), same major, same absence of a D2 conversion (see +// `18.system__BackupConfig__schedule.ts` for the retirement record). +// +// A NESTED site: the authorable-surface ratchet walks top-level def +// properties only (`system/DisasterRecoveryPlan:testing` is the row), so no +// `[RETIRED]` row exists for the cron itself and gate (b) of +// `build-schemas.ts` neither demands nor refuses this entry — it is here for +// the spec-changes / upgrade-guide projection, spelled the way +// `api/BatchEndpointsConfig:operations.upsertMany` is. +// D3 semantic entry: `disaster-recovery-schedules-retired`. +export const entry = 'system/DisasterRecoveryPlan:testing.schedule'; diff --git a/packages/spec/src/migrations/entries/semantic/18.cache-warmup-schedule-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cache-warmup-schedule-retired.ts new file mode 100644 index 0000000000..ad3ee245af --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.cache-warmup-schedule-retired.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'cache-warmup-schedule-retired', + surface: 'cache warmup cron: `CacheWarmup.schedule` (`system/cache.zod.ts`)', + replacement: + 'nothing to re-declare — delete the key. No cache-warmup engine exists on the platform, so ' + + 'there is no live mechanism to declare a warmup cadence to. The one cron slot the platform ' + + 'evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a warmup on a cadence is a ' + + 'job whose handler you write. The `strategy` enum keeps its `scheduled` member — a value, ' + + 'not a position the ruling names, and exactly as inert before', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. The key was parsed into ' + + 'the cron envelope and read by NOTHING: `CacheWarmupSchema` has no consumer outside ' + + '`packages/spec` (the ADR-0058 D7 ledger row `cron-declared-unwired` recorded it ' + + '`unevaluated`), so `strategy: \'scheduled\'` plus a cron warmed nothing. Why D3 semantic ' + + 'and not a D2 conversion: a cache configuration is plugin TS configuration, never a stack ' + + 'collection member or a `sys_metadata` row, so a conversion would be a transform with no ' + + 'seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent). The ' + + 'prescription therefore carries no `os migrate meta` sentence.', + acceptanceCriteria: + 'No `CacheWarmup` literal — standalone or as `DistributedCacheConfig.warmup` — carries ' + + '`schedule`. TypeScript authors get the refusal at compile time (the key is typed ' + + '`never`); a value reaching the parse is refused with the prescription (`invalid_type` ' + + 'at path `schedule`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified ' + + 'as such: nothing ever read the key, so removing it removes no behaviour.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-sync-schedule-retired.ts b/packages/spec/src/migrations/entries/semantic/18.connector-sync-schedule-retired.ts new file mode 100644 index 0000000000..c32ac9cbdb --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-sync-schedule-retired.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The D3 twin of the D2 conversion `connector-sync-schedule-removed` — the one +// of the seven #16320 cron-typed retirements whose family carries BOTH shapes. +// The #15954 ruling (director decision batch #56, 2026-09-06) names this +// family's D3 entry as the carrier of the author-population reading: +// "`connectors[].syncConfig.schedule` is the one stack-collection member: its +// D3 entry says so and names the measured zero in-repo authors and the +// NOT-MEASURED out-of-repo population." The strip is the D2's (mechanical, +// `retiredFromLoadPath`, one notice per authoring `connectors[]` entry); what +// no conversion can carry — the cadence the author meant, and the population +// this repo cannot measure — lives below, on the fields that PROJECT: `reason` +// → `spec-changes.json` `rationale`, the upgrade guide's "Why not automatic" +// and `os migrate meta`'s `why:`; `acceptanceCriteria` → "Done when" and +// `verify:`. A code comment projects nowhere, which is why the sentence is here. +export const entry: SemanticMigration = { + id: 'connector-sync-schedule-retired', + surface: + 'connector sync cron: `connectors[].syncConfig.schedule` (`DataSyncConfig.schedule`, ' + + '`integration/connector.zod.ts`) — the one stack-collection member of the #16320 family', + replacement: + 'delete the key — the D2 conversion `connector-sync-schedule-removed` lists that edit for ' + + 'every `connectors[]` entry that authored it (the `os migrate meta` mechanical edit list, ' + + 'from 17) and replays it over stored 17.x rows. What the conversion cannot write is the ' + + 'cadence the author meant: ' + + 'a sync on a cadence is a `job` (`Job.schedule.expression`, `system/job.zod.ts` — the one ' + + 'cron slot the platform evaluates) whose handler drives the connector, and that job is ' + + 'yours to declare. `realtimeSync` and every other `syncConfig` key are unchanged', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. `DataSyncConfig.schedule` ' + + 'was parsed into the cron envelope and read by NOTHING: `syncConfig` has no reader outside ' + + '`packages/spec`, no engine schedules a connector sync, and `@objectstack/formula`\'s ' + + 'cronEngine has zero consumers outside its own package (the ADR-0058 D7 ledger row ' + + '`cron-declared-unwired` recorded it `unevaluated`). This is the ONE of the seven retired ' + + 'positions a stack manifest reaches (`stack.connectors[]` → `Connector.syncConfig`), so it ' + + 'is the one with a D2 conversion — and the one whose D3 entry the ruling names as the ' + + 'carrier of the population reading. Why a D3 beside the D2: the strip is lossless for the ' + + 'SCHEMA, not for the author — the cadence a connector declared has no mechanical ' + + 'destination (a `job` is a different def, with a handler to write), so deleting the key ' + + 'is the tool\'s half and re-declaring the cadence where it was wanted is yours. Measured ' + + 'author population: ZERO in-repo authors — `examples/**`, `skills/**`, hand-written ' + + '`content/docs/**`, `apps/**` and every package outside `packages/spec` swept for ' + + '`syncConfig` beside `schedule`, with the declaring file lighting the control; objectui at ' + + 'the pinned sha `53ded82bf7a4` has none (its `syncConfig` hits are the react offline ' + + 'hook\'s own key). Out-of-repo stacks and stored `sys_metadata` rows are NOT MEASURED ' + + 'from this repo and are not claimed zero — that population is the residue this entry ' + + 'delegates to you.', + acceptanceCriteria: + 'Verify YOUR population by hand, since this repo could not: `os migrate meta` (from 17) ' + + 'over your stack lists zero remaining `connector-sync-schedule-removed` edits, and a grep ' + + 'of your sources for `syncConfig` beside `schedule` finds nothing — then, for every ' + + 'connector that had declared a cadence, decide whether a `job` (`Job.schedule.expression`) ' + + 'driving it is wanted, and declare it if so. TypeScript authors get the refusal at compile ' + + 'time (the key is typed `never`); a value reaching the parse — through ' + + '`Connector.syncConfig`, `stack.connectors[]` or the `/meta/connector` door — is refused ' + + 'with the prescription (`invalid_type` at path `syncConfig.schedule`). ⚠️ Runtime ' + + 'behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the ' + + 'key, so no sync that ran before stops — none ran on a cadence before, and none does after.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.disaster-recovery-schedules-retired.ts b/packages/spec/src/migrations/entries/semantic/18.disaster-recovery-schedules-retired.ts new file mode 100644 index 0000000000..fa9d1c9701 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.disaster-recovery-schedules-retired.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'disaster-recovery-schedules-retired', + surface: + 'backup / DR-testing cron positions: `BackupConfig.schedule` / ' + + '`DisasterRecoveryPlan.testing.schedule` (`system/disaster-recovery.zod.ts`)', + replacement: + 'nothing to re-declare — delete the keys. No backup engine and no DR-test runner exist on ' + + 'the platform, so there is no live mechanism to declare a backup or test cadence to. The ' + + 'one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`): ' + + 'a backup or DR test on a cadence is a job whose handler you write', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. Both positions were ' + + 'parsed into the cron envelope and read by NOTHING: neither `BackupConfigSchema` nor ' + + '`DisasterRecoveryPlanSchema` has a consumer outside `packages/spec` (the ADR-0058 D7 ' + + 'ledger row `cron-declared-unwired` recorded both `unevaluated`), so an operator who ' + + 'wrote `schedule: \'0 2 * * *\'` held a nightly backup the platform never took. Why D3 ' + + 'semantic and not a D2 conversion: a disaster-recovery plan is operator configuration, ' + + 'never a stack collection member or a `sys_metadata` row, so a conversion would be a ' + + 'transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` ' + + 'precedent). The prescriptions therefore carry no `os migrate meta` sentence.', + acceptanceCriteria: + 'No `BackupConfig` literal — standalone or as `DisasterRecoveryPlan.backup` — carries ' + + '`schedule`, and no `DisasterRecoveryPlan.testing` block does. TypeScript authors get the ' + + 'refusal at compile time (each key is typed `never`); a value reaching the parse is ' + + 'refused with the prescription (`invalid_type` at path `schedule` / `testing.schedule`). ' + + '⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ' + + 'ever read the keys, so removing them removes no behaviour.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.export-schedule-cron-retired.ts b/packages/spec/src/migrations/entries/semantic/18.export-schedule-cron-retired.ts new file mode 100644 index 0000000000..3c77c28268 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.export-schedule-cron-retired.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'export-schedule-cron-retired', + surface: + 'export-schedule cron positions: `ScheduledExport.schedule.cronExpression` / ' + + '`ScheduleExportRequest.schedule.cronExpression` (`api/export.zod.ts`)', + replacement: + 'nothing to re-declare — delete the key. No export scheduler exists on the platform: ' + + 'rest-server serves no `/api/v1/data/export` route, `IExportService` has no provider ' + + 'binding, and nothing ever read the cron, so there is no live mechanism to declare an ' + + 'export cadence to. The one cron slot the platform evaluates is `Job.schedule.expression` ' + + '(`system/job.zod.ts`, evaluated by `croner` through service-job): a recurring export is a ' + + 'job whose handler performs the export. The `schedule` block and its `timezone` stay on ' + + 'both schemas — the ruling retires the cron position, not the block', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. Two positions in the ' + + 'declared export-job API contract carried a `CronExpressionInputSchema` slot that the parse ' + + 'normalized into the `{ dialect: \'cron\', source }` envelope and NOTHING read: the whole ' + + '`ExportJobApiContracts` family has zero consumers, rest-server serves no ' + + '`/api/v1/data/export` route, and `IExportService` has no provider — so ' + + '`POST /api/v1/data/export/schedules` is a declared contract nothing implements, and an ' + + 'author who wrote `cronExpression: \'0 6 * * MON\'` reasonably expected a weekly export ' + + 'that never ran (the ADR-0058 D7 ledger row `cron-declared-unwired` recorded exactly ' + + 'this, `unevaluated`). Why D3 semantic and not a D2 conversion: the chain walks a ' + + 'normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its ' + + 'collections; an export schedule is an API request/response body and is neither, so a ' + + 'conversion would be a transform with no seam that ever runs (the ' + + '`kernel/MetadataPluginConfig:additionalTypes` precedent). The prescriptions therefore ' + + 'carry no `os migrate meta` sentence.', + acceptanceCriteria: + 'No `ScheduledExport` or `ScheduleExportRequest` literal carries `schedule.cronExpression`. ' + + 'TypeScript authors get the refusal at compile time (the key is typed `never`); a value ' + + 'reaching the parse is refused with the prescription (`invalid_type` at path ' + + '`schedule.cronExpression`). `schedule.timezone` still parses and still defaults to ' + + '`UTC`. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: ' + + 'nothing ever read the keys, so removing them removes no behaviour — no export ran on a ' + + 'schedule before and none runs after.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.schedule-state-cron-expression-retired.ts b/packages/spec/src/migrations/entries/semantic/18.schedule-state-cron-expression-retired.ts new file mode 100644 index 0000000000..c8027987d8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.schedule-state-cron-expression-retired.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'schedule-state-cron-expression-retired', + surface: 'flow schedule state cron: `ScheduleState.cronExpression` (`automation/execution.zod.ts`)', + replacement: + 'nothing to re-declare — delete the key. A scheduled flow declares its cadence on the ' + + 'flow\'s start node (`config.schedule`), which `trigger-schedule/schedule-trigger.ts` ' + + '`normalizeSchedule` reads; `ScheduleState` never fed that path. The one cron slot the ' + + 'platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`)', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. The schema\'s REQUIRED ' + + 'cron was parsed into the envelope and read by NOTHING: `ScheduleStateSchema` has no ' + + 'consumer outside `packages/spec`, and the schedule trigger that does run reads a flow ' + + 'start node\'s `config.schedule` — a different shape this key never reached (the ADR-0058 ' + + 'D7 ledger row `cron-declared-unwired` recorded it `unevaluated`). Because a ' + + '`retiredKey()` accepts only absence, the requiredness leaves with the key: `timezone`, ' + + '`status` and `nextRunAt` now describe a cadence the row no longer declares, and they ' + + 'stay because the ruling retires the cron position, not the def. Why D3 semantic and not ' + + 'a D2 conversion: runtime schedule state is not a stack collection member and no ' + + 'metadata type, so a conversion would be a transform with no seam that ever runs (the ' + + '`kernel/MetadataPluginConfig:additionalTypes` precedent). The prescription therefore ' + + 'carries no `os migrate meta` sentence.', + acceptanceCriteria: + 'No `ScheduleState` literal carries `cronExpression`, and none is REQUIRED to: a state ' + + 'with `id`, `flowName` and `createdAt` alone parses. TypeScript authors get the refusal ' + + 'at compile time (the key is typed `never`); a value reaching the parse is refused with ' + + 'the prescription (`invalid_type` at path `cronExpression`). ⚠️ Runtime behaviour is ' + + 'deliberately UNCHANGED and must be verified as such: nothing ever read the key, so ' + + 'removing it removes no behaviour.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ab1cd97668..20483505bd 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5402,7 +5402,15 @@ const step18: MigrationStep = { 'loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey ' + 'tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 ' + 'conversion (a manager config is no stack collection member); the rename is folded into ' + - 'the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key.', + 'the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. ' + + 'It also retires the seven cron-typed positions nothing evaluated (#16320, the #15954 ' + + 'ruling — option A per family, ADR-0049): the two export-schedule crons, ' + + '`ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule` and ' + + 'the two disaster-recovery crons were parsed into the cron envelope and read by nothing ' + + '(the D7 ledger row `cron-declared-unwired`). All seven are retiredKey tombstones in ' + + 'RETIRED_KEYS_BY_MAJOR[18]; only the connector one converts (`connector-sync-schedule-removed`), ' + + 'because `stack.connectors[]` is the one carrier a manifest reaches — the other four ' + + 'families take a D3 semantic entry each and no `os migrate meta` sentence.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5427,6 +5435,7 @@ const step18: MigrationStep = { 'connector-health-and-trigger-durations-unit-in-key', 'memory-persistence-auto-save-interval-to-ms', 'turso-config-timeout-to-timeout-ms', + 'connector-sync-schedule-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -5921,6 +5930,32 @@ const step18: MigrationStep = { + 'value, so no source rewrite ships and `objectstack migrate meta` has ' + 'nothing to visit.', }, + { + id: 'cache-warmup-schedule-retired', + surface: 'cache warmup cron: `CacheWarmup.schedule` (`system/cache.zod.ts`)', + replacement: + 'nothing to re-declare — delete the key. No cache-warmup engine exists on the platform, so ' + + 'there is no live mechanism to declare a warmup cadence to. The one cron slot the platform ' + + 'evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a warmup on a cadence is a ' + + 'job whose handler you write. The `strategy` enum keeps its `scheduled` member — a value, ' + + 'not a position the ruling names, and exactly as inert before', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. The key was parsed into ' + + 'the cron envelope and read by NOTHING: `CacheWarmupSchema` has no consumer outside ' + + '`packages/spec` (the ADR-0058 D7 ledger row `cron-declared-unwired` recorded it ' + + '`unevaluated`), so `strategy: \'scheduled\'` plus a cron warmed nothing. Why D3 semantic ' + + 'and not a D2 conversion: a cache configuration is plugin TS configuration, never a stack ' + + 'collection member or a `sys_metadata` row, so a conversion would be a transform with no ' + + 'seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent). The ' + + 'prescription therefore carries no `os migrate meta` sentence.', + acceptanceCriteria: + 'No `CacheWarmup` literal — standalone or as `DistributedCacheConfig.warmup` — carries ' + + '`schedule`. TypeScript authors get the refusal at compile time (the key is typed ' + + '`never`); a value reaching the parse is refused with the prescription (`invalid_type` ' + + 'at path `schedule`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified ' + + 'as such: nothing ever read the key, so removing it removes no behaviour.', + }, { id: 'cbp-master-detail-required-forced', surface: 'object.fields..required on a `master_detail` reference under ' @@ -6359,6 +6394,64 @@ const step18: MigrationStep = { + 'fail tsc on upgrade; the fix is choosing a shipped driver, never ' + 'widening a local mirror of the enum.', }, + // The D3 twin of the D2 conversion `connector-sync-schedule-removed` — the one + // of the seven #16320 cron-typed retirements whose family carries BOTH shapes. + // The #15954 ruling (director decision batch #56, 2026-09-06) names this + // family's D3 entry as the carrier of the author-population reading: + // "`connectors[].syncConfig.schedule` is the one stack-collection member: its + // D3 entry says so and names the measured zero in-repo authors and the + // NOT-MEASURED out-of-repo population." The strip is the D2's (mechanical, + // `retiredFromLoadPath`, one notice per authoring `connectors[]` entry); what + // no conversion can carry — the cadence the author meant, and the population + // this repo cannot measure — lives below, on the fields that PROJECT: `reason` + // → `spec-changes.json` `rationale`, the upgrade guide's "Why not automatic" + // and `os migrate meta`'s `why:`; `acceptanceCriteria` → "Done when" and + // `verify:`. A code comment projects nowhere, which is why the sentence is here. + { + id: 'connector-sync-schedule-retired', + surface: + 'connector sync cron: `connectors[].syncConfig.schedule` (`DataSyncConfig.schedule`, ' + + '`integration/connector.zod.ts`) — the one stack-collection member of the #16320 family', + replacement: + 'delete the key — the D2 conversion `connector-sync-schedule-removed` lists that edit for ' + + 'every `connectors[]` entry that authored it (the `os migrate meta` mechanical edit list, ' + + 'from 17) and replays it over stored 17.x rows. What the conversion cannot write is the ' + + 'cadence the author meant: ' + + 'a sync on a cadence is a `job` (`Job.schedule.expression`, `system/job.zod.ts` — the one ' + + 'cron slot the platform evaluates) whose handler drives the connector, and that job is ' + + 'yours to declare. `realtimeSync` and every other `syncConfig` key are unchanged', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. `DataSyncConfig.schedule` ' + + 'was parsed into the cron envelope and read by NOTHING: `syncConfig` has no reader outside ' + + '`packages/spec`, no engine schedules a connector sync, and `@objectstack/formula`\'s ' + + 'cronEngine has zero consumers outside its own package (the ADR-0058 D7 ledger row ' + + '`cron-declared-unwired` recorded it `unevaluated`). This is the ONE of the seven retired ' + + 'positions a stack manifest reaches (`stack.connectors[]` → `Connector.syncConfig`), so it ' + + 'is the one with a D2 conversion — and the one whose D3 entry the ruling names as the ' + + 'carrier of the population reading. Why a D3 beside the D2: the strip is lossless for the ' + + 'SCHEMA, not for the author — the cadence a connector declared has no mechanical ' + + 'destination (a `job` is a different def, with a handler to write), so deleting the key ' + + 'is the tool\'s half and re-declaring the cadence where it was wanted is yours. Measured ' + + 'author population: ZERO in-repo authors — `examples/**`, `skills/**`, hand-written ' + + '`content/docs/**`, `apps/**` and every package outside `packages/spec` swept for ' + + '`syncConfig` beside `schedule`, with the declaring file lighting the control; objectui at ' + + 'the pinned sha `53ded82bf7a4` has none (its `syncConfig` hits are the react offline ' + + 'hook\'s own key). Out-of-repo stacks and stored `sys_metadata` rows are NOT MEASURED ' + + 'from this repo and are not claimed zero — that population is the residue this entry ' + + 'delegates to you.', + acceptanceCriteria: + 'Verify YOUR population by hand, since this repo could not: `os migrate meta` (from 17) ' + + 'over your stack lists zero remaining `connector-sync-schedule-removed` edits, and a grep ' + + 'of your sources for `syncConfig` beside `schedule` finds nothing — then, for every ' + + 'connector that had declared a cadence, decide whether a `job` (`Job.schedule.expression`) ' + + 'driving it is wanted, and declare it if so. TypeScript authors get the refusal at compile ' + + 'time (the key is typed `never`); a value reaching the parse — through ' + + '`Connector.syncConfig`, `stack.connectors[]` or the `/meta/connector` door — is refused ' + + 'with the prescription (`invalid_type` at path `syncConfig.schedule`). ⚠️ Runtime ' + + 'behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the ' + + 'key, so no sync that ran before stops — none ran on a cadence before, and none does after.', + }, { id: 'dashboard-header-modal-target-page-only', surface: @@ -6688,6 +6781,35 @@ const step18: MigrationStep = { + '`intervalSeconds` off the request response and waits that many seconds between polls, ' + 'exactly as `interval` did — the value and its unit are unchanged, only the key name moves.', }, + { + id: 'disaster-recovery-schedules-retired', + surface: + 'backup / DR-testing cron positions: `BackupConfig.schedule` / ' + + '`DisasterRecoveryPlan.testing.schedule` (`system/disaster-recovery.zod.ts`)', + replacement: + 'nothing to re-declare — delete the keys. No backup engine and no DR-test runner exist on ' + + 'the platform, so there is no live mechanism to declare a backup or test cadence to. The ' + + 'one cron slot the platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`): ' + + 'a backup or DR test on a cadence is a job whose handler you write', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. Both positions were ' + + 'parsed into the cron envelope and read by NOTHING: neither `BackupConfigSchema` nor ' + + '`DisasterRecoveryPlanSchema` has a consumer outside `packages/spec` (the ADR-0058 D7 ' + + 'ledger row `cron-declared-unwired` recorded both `unevaluated`), so an operator who ' + + 'wrote `schedule: \'0 2 * * *\'` held a nightly backup the platform never took. Why D3 ' + + 'semantic and not a D2 conversion: a disaster-recovery plan is operator configuration, ' + + 'never a stack collection member or a `sys_metadata` row, so a conversion would be a ' + + 'transform with no seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` ' + + 'precedent). The prescriptions therefore carry no `os migrate meta` sentence.', + acceptanceCriteria: + 'No `BackupConfig` literal — standalone or as `DisasterRecoveryPlan.backup` — carries ' + + '`schedule`, and no `DisasterRecoveryPlan.testing` block does. TypeScript authors get the ' + + 'refusal at compile time (each key is typed `never`); a value reaching the parse is ' + + 'refused with the prescription (`invalid_type` at path `schedule` / `testing.schedule`). ' + + '⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ' + + 'ever read the keys, so removing them removes no behaviour.', + }, { id: 'driver-options-timeout-to-timeout-ms', surface: '`DriverOptions.timeout` (data/driver.zod.ts) — the per-call options argument of every `IDataDriver` method', @@ -7348,6 +7470,44 @@ const step18: MigrationStep = { + 'legacy branch index only when the record predates the engine build that ' + 'writes `branch`.', }, + { + id: 'export-schedule-cron-retired', + surface: + 'export-schedule cron positions: `ScheduledExport.schedule.cronExpression` / ' + + '`ScheduleExportRequest.schedule.cronExpression` (`api/export.zod.ts`)', + replacement: + 'nothing to re-declare — delete the key. No export scheduler exists on the platform: ' + + 'rest-server serves no `/api/v1/data/export` route, `IExportService` has no provider ' + + 'binding, and nothing ever read the cron, so there is no live mechanism to declare an ' + + 'export cadence to. The one cron slot the platform evaluates is `Job.schedule.expression` ' + + '(`system/job.zod.ts`, evaluated by `croner` through service-job): a recurring export is a ' + + 'job whose handler performs the export. The `schedule` block and its `timezone` stay on ' + + 'both schemas — the ruling retires the cron position, not the block', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. Two positions in the ' + + 'declared export-job API contract carried a `CronExpressionInputSchema` slot that the parse ' + + 'normalized into the `{ dialect: \'cron\', source }` envelope and NOTHING read: the whole ' + + '`ExportJobApiContracts` family has zero consumers, rest-server serves no ' + + '`/api/v1/data/export` route, and `IExportService` has no provider — so ' + + '`POST /api/v1/data/export/schedules` is a declared contract nothing implements, and an ' + + 'author who wrote `cronExpression: \'0 6 * * MON\'` reasonably expected a weekly export ' + + 'that never ran (the ADR-0058 D7 ledger row `cron-declared-unwired` recorded exactly ' + + 'this, `unevaluated`). Why D3 semantic and not a D2 conversion: the chain walks a ' + + 'normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its ' + + 'collections; an export schedule is an API request/response body and is neither, so a ' + + 'conversion would be a transform with no seam that ever runs (the ' + + '`kernel/MetadataPluginConfig:additionalTypes` precedent). The prescriptions therefore ' + + 'carry no `os migrate meta` sentence.', + acceptanceCriteria: + 'No `ScheduledExport` or `ScheduleExportRequest` literal carries `schedule.cronExpression`. ' + + 'TypeScript authors get the refusal at compile time (the key is typed `never`); a value ' + + 'reaching the parse is refused with the prescription (`invalid_type` at path ' + + '`schedule.cronExpression`). `schedule.timezone` still parses and still defaults to ' + + '`UTC`. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: ' + + 'nothing ever read the keys, so removing them removes no behaviour — no export ran on a ' + + 'schedule before and none runs after.', + }, { id: 'field-master-detail-set-null-refused', surface: "object field `deleteBehavior: 'set_null'` authored on a `master_detail` field", @@ -9139,6 +9299,36 @@ const step18: MigrationStep = { + 'of the ten keys ever reached it. No code imports `CrudEndpointPattern(Schema)` from ' + '`@objectstack/spec/api` (TS2305 after upgrade).', }, + { + id: 'schedule-state-cron-expression-retired', + surface: 'flow schedule state cron: `ScheduleState.cronExpression` (`automation/execution.zod.ts`)', + replacement: + 'nothing to re-declare — delete the key. A scheduled flow declares its cadence on the ' + + 'flow\'s start node (`config.schedule`), which `trigger-schedule/schedule-trigger.ts` ' + + '`normalizeSchedule` reads; `ScheduleState` never fed that path. The one cron slot the ' + + 'platform evaluates is `Job.schedule.expression` (`system/job.zod.ts`)', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06 on #15954 (director decision ' + + 'batch #56, option A — retire — per family), executed by #16320. The schema\'s REQUIRED ' + + 'cron was parsed into the envelope and read by NOTHING: `ScheduleStateSchema` has no ' + + 'consumer outside `packages/spec`, and the schedule trigger that does run reads a flow ' + + 'start node\'s `config.schedule` — a different shape this key never reached (the ADR-0058 ' + + 'D7 ledger row `cron-declared-unwired` recorded it `unevaluated`). Because a ' + + '`retiredKey()` accepts only absence, the requiredness leaves with the key: `timezone`, ' + + '`status` and `nextRunAt` now describe a cadence the row no longer declares, and they ' + + 'stay because the ruling retires the cron position, not the def. Why D3 semantic and not ' + + 'a D2 conversion: runtime schedule state is not a stack collection member and no ' + + 'metadata type, so a conversion would be a transform with no seam that ever runs (the ' + + '`kernel/MetadataPluginConfig:additionalTypes` precedent). The prescription therefore ' + + 'carries no `os migrate meta` sentence.', + acceptanceCriteria: + 'No `ScheduleState` literal carries `cronExpression`, and none is REQUIRED to: a state ' + + 'with `id`, `flowName` and `createdAt` alone parses. TypeScript authors get the refusal ' + + 'at compile time (the key is typed `never`); a value reaching the parse is refused with ' + + 'the prescription (`invalid_type` at path `cronExpression`). ⚠️ Runtime behaviour is ' + + 'deliberately UNCHANGED and must be verified as such: nothing ever read the key, so ' + + 'removing it removes no behaviour.', + }, { id: 'scim-provider-object-retired', surface: @@ -10854,6 +11044,51 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // advertises. Its three ledger child rows collapse into the one `overrides` // row. Closes #14365's question about `overrides.*.operations` — no record left. 'api/RouteGenerationConfig:overrides', + // #16320 — the export-schedule family's second position, + // `ScheduleExportRequest.schedule.cronExpression`: the same cron slot on the + // request body of `POST /api/v1/data/export/schedules`, which no server route + // implements. Same reading, same route (a `retiredKey()` tombstone on a + // non-strict `z.object`, ADR-0104), same major, same absence of a D2 + // conversion (an API request body is not a stack collection member — the + // `kernel/MetadataPluginConfig:additionalTypes` precedent), same nested + // spelling (no authorable-surface row of its own; `api/ScheduleExportRequest:schedule` + // is the row). See `18.api__ScheduledExport__schedule.cronExpression.ts` for + // the retirement record. + // D3 semantic entry: `export-schedule-cron-retired`. + 'api/ScheduleExportRequest:schedule.cronExpression', + // #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing + // reads (#15954 ruling, director decision batch #56, maintainer 「其他同意」, + // 2026-09-06: option A — retire — per family). Export-schedule family, first + // of two positions: `ScheduledExport.schedule.cronExpression`. Declared, parsed + // into the `{ dialect: 'cron', source }` envelope and read by NOTHING — the + // whole `ExportJobApiContracts` family has zero consumers, rest-server serves + // no `/api/v1/data/export` route, and `IExportService` has no provider binding + // (its own header records that), so `POST /api/v1/data/export/schedules` is a + // declared contract nothing implements and the cron inside it never fired. + // Tombstoned with `retiredKey()`: the schema is a non-strict `z.object`, so a + // bare deletion would be a silent strip (ADR-0104). + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // Registered here but NOT in `src/conversions/registry.ts`, for the reason + // `kernel/MetadataPluginConfig:additionalTypes` gives: the conversion chain + // walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata + // type onto one of its collections; an export schedule is an API body and is + // neither, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription therefore carries no `os migrate meta` sentence (it + // must be true of the tool) and reaches authors through the tombstone (`tsc` + + // the parse) and the D3 semantic entry named below. + // + // A NESTED site: the authorable-surface ratchet walks top-level def + // properties only (`api/ScheduledExport:schedule` is the row), so no + // `[RETIRED]` row exists for the cron itself and gate (b) of + // `build-schemas.ts` neither demands nor refuses this entry — it is here for + // the spec-changes / upgrade-guide projection, spelled the way + // `api/BatchEndpointsConfig:operations.upsertMany` is. + // D3 semantic entry: `export-schedule-cron-retired`. + 'api/ScheduledExport:schedule.cronExpression', // #14788 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-03, option // D). `SessionUserSchema.language` (`api/auth.zod.ts`) was declared with a // permanent default of `'en'` and described as "Preferred language", and had @@ -10938,6 +11173,27 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // construction configuration, never a stored row; the semantic entry // `websocket-durations-unit-in-key` carries the prescription. 'api/WebSocketServerConfig:heartbeatInterval', + // #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing + // reads (#15954 ruling, decision batch #56, 2026-09-06: option A — retire — per + // family). Automation family: `ScheduleState.cronExpression`, the schema's + // REQUIRED cron, read by NOTHING — `ScheduleStateSchema` has no consumer outside + // `packages/spec`, and the schedule trigger that does run reads a flow start + // node's `config.schedule` through `trigger-schedule/schedule-trigger.ts` + // `normalizeSchedule`, a different shape this key never reached. Tombstoned + // with `retiredKey()` (non-strict `z.object`, ADR-0104); the requiredness + // leaves with the key, since a tombstone accepts only absence. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // Registered here but NOT in `src/conversions/registry.ts`: runtime schedule + // state is not a stack collection member and `scheduleState` is no metadata + // type, so a MetadataConversion would be a transform with no seam that ever + // runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent). No + // `os migrate meta` sentence, for the same reason. + // D3 semantic entry: `schedule-state-cron-expression-retired`. + 'automation/ScheduleState:cronExpression', // #15680 (stack card 5/6 of #14478) — ruling B, and the one key in this card // that the gate did NOT list. It is here because it is not a second key: the // `auto` persistence arm resolves to the same Node.js file adapter as the @@ -11136,6 +11392,46 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // by it). The rename does not change that; it makes the declaration honest // about its unit for whoever implements the loop. 'integration/ConnectorTrigger:interval', + // #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing + // reads (#15954 ruling, decision batch #56, 2026-09-06: option A — retire — per + // family). Connector family: `DataSyncConfig.schedule`, the cron slot on + // connector-attached sync (`ConnectorSchema.syncConfig`). Declared, parsed into + // the cron envelope and read by NOTHING — `syncConfig` has no reader outside + // `packages/spec`, no engine schedules a connector sync, and + // `@objectstack/formula`'s cronEngine has zero consumers outside its package. + // Tombstoned with `retiredKey()` (non-strict `z.object`, ADR-0104); the + // tombstone reaches every carrier — `Connector.syncConfig`, + // `DeclarativeConnectorEntry` (`stack.connectors[]`) and the `/meta/connector` + // door — through the one `DataSyncConfigSchema` they all nest. + // + // THE ONE POSITION OF THE SEVEN A STACK MANIFEST REACHES (`stack.zod.ts` + // `connectors: z.array(DeclarativeConnectorEntrySchema)` → `syncConfig`), so + // unlike its six siblings this family takes the `connector-error-mapping-removed` + // shape: a D2 conversion, `connector-sync-schedule-removed` (one strip per + // `connectors[]` entry that authored the key, `retiredFromLoadPath`), wired + // into the step-18 chain, and the house `os migrate meta --from 17` sentence + // on the prescription — which must be true of the tool, and here is. And a + // D3 twin, `connector-sync-schedule-retired`, per the #15954 ruling's letter + // ("its D3 entry says so and names the measured zero in-repo authors and the + // NOT-MEASURED out-of-repo population"): the strip is the D2's; the twin + // carries the population reading on fields that PROJECT (`reason`, + // `acceptanceCriteria` → the upgrade guide, `spec-changes.json`, `os migrate + // meta`), which this comment does not. + // + // Measured author population (the only family whose entry owes one, since it + // is the only stack-collection member; the projecting copy is the D3 twin's + // `reason`): zero in-repo authors — `examples/**`, + // `skills/**`, `content/docs/**` (generated references excluded) and every + // package outside `packages/spec` swept for `syncConfig` + `schedule`, with the + // declaring file lighting the control; objectui at the pinned sha + // `53ded82bf7a4` has no `syncConfig.schedule` (its `syncConfig` hits are the + // react offline hook's own key, `ui/offline.zod.ts`). Out-of-repo stacks are + // NOT MEASURABLE from this repo and are not claimed zero. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + 'integration/DataSyncConfig:schedule', // #14676 — the same tombstone seen through the second carrier. // `DeclarativeConnectorEntrySchema` is `ConnectorSchema.superRefine(...)`, so the // `errorMapping` tombstone on the base is inherited by the shape that @@ -12035,6 +12331,25 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // D2 conversion: not a stack collection member, not a stored row. // See `system-object-storage-durations-unit-in-key`. 'system/AccessControlConfig:maxAge', + // #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing + // reads (#15954 ruling, decision batch #56, 2026-09-06: option A — retire — per + // family). Backup / DR-testing family, first of two positions: + // `BackupConfig.schedule`. Declared, parsed into the cron envelope and read by + // NOTHING — `BackupConfigSchema` has no consumer outside `packages/spec`, so + // no automated backup ever ran on it. Tombstoned with `retiredKey()` + // (non-strict `z.object`, ADR-0104). + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // Registered here but NOT in `src/conversions/registry.ts`: a disaster-recovery + // plan is operator configuration, never a stack collection member or a + // `sys_metadata` row, so a MetadataConversion would be a transform with no + // seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` + // precedent). No `os migrate meta` sentence, for the same reason. + // D3 semantic entry: `disaster-recovery-schedules-retired`. + 'system/BackupConfig:schedule', // #15679 (stack card 4/6 of #14478) — ruling B. `circuitBreaker.resetTimeout` // said "Seconds before half-open state" in prose only, while the `lockout` block // three lines down on the SAME schema already spelled `lockTimeoutMs`. One shape, @@ -12053,6 +12368,26 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // never a stored metadata row, so the conversion chain has no seam that sees it. // See `system-cache-durations-unit-in-key`. 'system/CacheTier:ttl', + // #16320 — ADR-0049 enforce-or-remove on the seven cron-typed positions nothing + // reads (#15954 ruling, decision batch #56, 2026-09-06: option A — retire — per + // family). Cache-warmup family: `CacheWarmup.schedule`. Declared, parsed into + // the cron envelope and read by NOTHING — `CacheWarmupSchema` has no consumer + // outside `packages/spec`, so no warmup ever ran on a schedule. Tombstoned with + // `retiredKey()` (non-strict `z.object`, ADR-0104). The `strategy` enum keeps + // its `scheduled` member: a value, not a position this ruling names, and + // exactly as inert before (nothing reads the def). + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // Registered here but NOT in `src/conversions/registry.ts`: a cache config is + // plugin TS configuration, never a stack collection member or a + // `sys_metadata` row, so a MetadataConversion would be a transform with no + // seam that ever runs (the `kernel/MetadataPluginConfig:additionalTypes` + // precedent). No `os migrate meta` sentence, for the same reason. + // D3 semantic entry: `cache-warmup-schedule-retired`. + 'system/CacheWarmup:schedule', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the @@ -12127,6 +12462,21 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // Tombstoned with `retiredKey()`. No D2 conversion, for its parent's reason. // See `system-collaboration-durations-unit-in-key`. 'system/CollaborationSessionConfig:snapshot.interval', + // #16320 — the backup / DR-testing family's second position, + // `DisasterRecoveryPlan.testing.schedule`: the periodic DR-test cron, read by + // NOTHING (`DisasterRecoveryPlanSchema` has no consumer outside + // `packages/spec`). Same route (a `retiredKey()` tombstone on a non-strict + // `z.object`, ADR-0104), same major, same absence of a D2 conversion (see + // `18.system__BackupConfig__schedule.ts` for the retirement record). + // + // A NESTED site: the authorable-surface ratchet walks top-level def + // properties only (`system/DisasterRecoveryPlan:testing` is the row), so no + // `[RETIRED]` row exists for the cron itself and gate (b) of + // `build-schemas.ts` neither demands nor refuses this entry — it is here for + // the spec-changes / upgrade-guide projection, spelled the way + // `api/BatchEndpointsConfig:operations.upsertMany` is. + // D3 semantic entry: `disaster-recovery-schedules-retired`. + 'system/DisasterRecoveryPlan:testing.schedule', // #15679 (stack card 4/6 of #14478) — ruling B. `FailoverConfig.healthCheckInterval` // said "Health check interval in seconds" in prose and nothing else. Renamed to // `healthCheckIntervalSeconds`; the value and the 30 default are unchanged. diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index 78a47d454c..40128761bd 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -260,15 +260,18 @@ function typedExpressionUnionParams(dialect: TypedExpressionDialect): { error: ( * `{ 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. + * (`TYPED_EXPRESSION_SOURCE_REQUIRED.cron`). Two slots carry it: + * `CronSchedule.expression` (`system/job.zod.ts`) and + * `KnowledgeRefreshPolicy.cron` (`ai/knowledge-source.zod.ts`); authors write + * `'0 9 * * 1-5'` without manually wrapping. The seven other cron-typed + * positions nothing evaluated were retired by #16320 (ADR-0049 — a slot with + * no engine is declared, not enforced), so a new one needs a reader first. * * 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. + * the knowledge-refresh slot is `[EXPERIMENTAL — not enforced]` by design and + * reaches no engine, and no grammar is restated here. */ export const CronExpressionInputSchema = z.union([ typedExpressionStringArm('cron'), diff --git a/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts b/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts index 69546c235a..5a402bed26 100644 --- a/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts +++ b/packages/spec/src/shared/typed-expression-envelope-dialect.test.ts @@ -152,10 +152,15 @@ describe('controls and the author-facing type', () => { }); /** - * 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. + * Through the stack: the typed positions a `defineStack` manifest can reach + * (`jobs[].schedule.expression`, `objects[].titleFormat`) refuse at the named + * path via `ObjectStackDefinitionSchema` — the choke point `os validate` + * parses through. There were three when this narrowing landed: + * `connectors[].syncConfig.schedule` was the third, and #16320 retired it + * (ADR-0049 — nothing evaluated it). It stays in this block as the tombstone + * it now is: the envelope that used to draw the dialect verdict draws the + * retired-key refusal at the same path, so the roster shrinks HERE rather than + * a stale control quietly passing a cron through a slot that no longer exists. */ 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 }; @@ -172,12 +177,11 @@ describe('through `ObjectStackDefinitionSchema` — the stack-reachable typed sl 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}}')], + manifest, jobs: [job('0 1 * * *')], 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}}' }); }); @@ -193,10 +197,20 @@ describe('through `ObjectStackDefinitionSchema` — the stack-reachable typed sl ]); }); - 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('[#16320] `connectors[].syncConfig.schedule` is no longer a typed slot — the tombstone refuses ANY value at `connectors.0.syncConfig.schedule` as `invalid_type`, never as a dialect verdict', () => { + // The foreign envelope this case used to narrow on, the cron envelope the + // slot used to normalize TO, and the bare string it used to accept: all + // three draw the same retired-key refusal now, at the same path. + for (const authored of [{ dialect: 'template', source: '{{x}}' }, { dialect: 'cron', source: '*/15 * * * *' }, '*/15 * * * *']) { + const issues = stackIssues({ manifest, connectors: [connector(authored)] }); + expect(issues, JSON.stringify(authored)).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'invalid_type', path: 'connectors.0.syncConfig.schedule' }); + expect(issues[0]!.message).not.toBe(TYPED_EXPRESSION_DIALECT_ONLY.cron); + expect(issues[0]!.message).toMatch(/^`connector\.syncConfig\.schedule` was removed in @objectstack\/spec 17/); + } + // Control: the same connector minus the key parses. + const control = ObjectStackDefinitionSchema.safeParse({ manifest, connectors: [{ name: 'sap', label: 'SAP', type: 'saas' as const }] }); + expect(control.success, control.success ? '' : JSON.stringify(control.error.issues)).toBe(true); }); it('`objects[].titleFormat` refuses a `cron` envelope at `objects.0.titleFormat`', () => { diff --git a/packages/spec/src/system/cache.test.ts b/packages/spec/src/system/cache.test.ts index 82f22587cb..2c34d778ed 100644 --- a/packages/spec/src/system/cache.test.ts +++ b/packages/spec/src/system/cache.test.ts @@ -238,13 +238,16 @@ describe('CacheWarmupSchema', () => { expect(result.concurrency).toBe(20); }); - it('should accept scheduled warmup', () => { + it('still accepts the `scheduled` strategy value — the `schedule` cron key beside it is retired', () => { + // `schedule` is a retiredKey() tombstone (#16320); the refusal is pinned in + // `cron-typed-positions-retirement.test.ts`. The enum member is a value the + // ruling did not name and stays exactly as inert as it was. const result = CacheWarmupSchema.parse({ enabled: true, strategy: 'scheduled', - schedule: '0 0 * * *', }); - expect(result.schedule).toEqual({ dialect: 'cron', source: '0 0 * * *' }); + expect(result.strategy).toBe('scheduled'); + expect(result).not.toHaveProperty('schedule'); }); }); diff --git a/packages/spec/src/system/cache.zod.ts b/packages/spec/src/system/cache.zod.ts index e1f1860752..ab8192e19a 100644 --- a/packages/spec/src/system/cache.zod.ts +++ b/packages/spec/src/system/cache.zod.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { CronExpressionInputSchema } from '../shared/expression.zod'; /** * @module system/cache @@ -168,6 +167,26 @@ export type CacheAvalanchePrevention = z.input; +/** + * `CacheWarmup.schedule` — RETIRED (ADR-0049 enforce-or-remove; maintainer + * ruling 2026-09-06, option A per family, #15954 / #16320). Declared, parsed + * into the cron envelope and read by NOTHING: `CacheWarmupSchema` has no + * consumer outside `packages/spec`, so no warmup ever ran on a schedule. Not + * `.strict()`, so a bare deletion would be a silent strip (ADR-0104); the + * tombstone makes the removal audible in `tsc` and at parse. Registered as + * `system/CacheWarmup:schedule` in `RETIRED_KEYS_BY_MAJOR[18]`; D3 semantic + * entry `cache-warmup-schedule-retired`; no D2 conversion — a cache config is + * plugin TS configuration, not a stack collection member. The `strategy` enum + * keeps its `scheduled` member: it is a value, not a position this ruling + * names, and it was exactly as inert before (nothing reads the def). + */ +const CACHE_WARMUP_SCHEDULE_RETIRED = + '`CacheWarmup.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'nothing ever read it: no cache-warmup engine exists on the platform, so a scheduled warmup ' + + 'never ran. Delete the key. The one cron slot the platform evaluates is ' + + '`Job.schedule.expression` (`system/job.zod.ts`): a warmup on a cadence is a job whose handler ' + + 'you write.'; + /** * Cache Warmup Strategy Schema * @@ -179,8 +198,8 @@ export const CacheWarmupSchema = lazySchema(() => z.object({ /** Warmup strategy */ strategy: z.enum(['eager', 'lazy', 'scheduled']).default('lazy') .describe('Warmup strategy: eager (at startup), lazy (on first access), scheduled (cron)'), - /** Cron schedule for scheduled warmup */ - schedule: CronExpressionInputSchema.optional().describe('Cron expression for scheduled warmup'), + /** Tombstone (ADR-0049, #16320) — see `CACHE_WARMUP_SCHEDULE_RETIRED`. */ + schedule: retiredKey(CACHE_WARMUP_SCHEDULE_RETIRED), /** Keys/patterns to warm up */ patterns: z.array(z.string()).optional().describe('Key patterns to warm up (e.g., "user:*", "config:*")'), /** Maximum concurrent warmup operations */ diff --git a/packages/spec/src/system/disaster-recovery.test.ts b/packages/spec/src/system/disaster-recovery.test.ts index f099fd2c69..382a61f204 100644 --- a/packages/spec/src/system/disaster-recovery.test.ts +++ b/packages/spec/src/system/disaster-recovery.test.ts @@ -58,7 +58,8 @@ describe('BackupConfigSchema', () => { it('should accept full backup config with encryption', () => { const config = BackupConfigSchema.parse({ strategy: 'full', - schedule: '0 2 * * 0', + // `schedule` is a retiredKey() tombstone (#16320) — the refusal is + // pinned in `cron-typed-positions-retirement.test.ts`. retention: { days: 365, minCopies: 12 }, destination: { type: 'gcs', bucket: 'backups', region: 'us-central1' }, encryption: { enabled: true, algorithm: 'AES-256-GCM', keyId: 'kms-key-123' }, @@ -165,7 +166,6 @@ describe('DisasterRecoveryPlanSchema', () => { rto: { value: 30, unit: 'minutes' }, backup: { strategy: 'incremental', - schedule: '0 */6 * * *', retention: { days: 90, minCopies: 5 }, destination: { type: 's3', bucket: 'dr-backups', region: 'us-east-1' }, encryption: { enabled: true }, @@ -190,7 +190,6 @@ describe('DisasterRecoveryPlanSchema', () => { }, testing: { enabled: true, - schedule: '0 3 1 * *', notificationChannel: '#dr-alerts', }, runbookUrl: 'https://docs.example.com/dr-runbook', diff --git a/packages/spec/src/system/disaster-recovery.zod.ts b/packages/spec/src/system/disaster-recovery.zod.ts index a30f4a5867..e01e3df534 100644 --- a/packages/spec/src/system/disaster-recovery.zod.ts +++ b/packages/spec/src/system/disaster-recovery.zod.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { CronExpressionInputSchema } from '../shared/expression.zod'; /** * Backup Strategy Schema @@ -16,7 +15,6 @@ import { CronExpressionInputSchema } from '../shared/expression.zod'; * ```typescript * const backup: BackupConfig = { * strategy: 'incremental', - * schedule: '0 2 * * *', * retention: { days: 30, minCopies: 3 }, * encryption: { enabled: true, algorithm: 'AES-256-GCM' }, * }; @@ -48,14 +46,42 @@ export type BackupRetention = z.input; /** Post-parse shape of {@link BackupRetention} — defaults applied, transforms run (ADR-0122). */ export type BackupRetentionParsed = z.infer; +/** + * The two disaster-recovery cron positions — RETIRED (ADR-0049 + * enforce-or-remove; maintainer ruling 2026-09-06, option A per family, + * #15954 / #16320). `BackupConfig.schedule` and + * `DisasterRecoveryPlan.testing.schedule` were declared, parsed into the cron + * envelope and read by NOTHING: neither schema has a consumer outside + * `packages/spec`, so no backup and no DR test ever ran on a schedule. Neither + * is `.strict()`, so a bare deletion would be a silent strip (ADR-0104); the + * tombstones make the removal audible in `tsc` and at parse. Registered as + * `system/BackupConfig:schedule` and, by its nested spelling (no + * authorable-surface row of its own), `system/DisasterRecoveryPlan:testing.schedule` + * in `RETIRED_KEYS_BY_MAJOR[18]`; D3 semantic entry + * `disaster-recovery-schedules-retired`; no D2 conversion — a DR plan is + * plugin/operator configuration, not a stack collection member. + */ +const BACKUP_SCHEDULE_RETIRED = + '`BackupConfig.schedule` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'nothing ever read it: no backup engine exists on the platform, so an automated backup never ' + + 'ran on it. Delete the key. The one cron slot the platform evaluates is ' + + '`Job.schedule.expression` (`system/job.zod.ts`): a backup on a cadence is a job whose handler ' + + 'you write.'; +const DR_TESTING_SCHEDULE_RETIRED = + '`DisasterRecoveryPlan.testing.schedule` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no disaster-recovery test runner exists on the ' + + 'platform, so a periodic DR test never ran. Delete the key. The one cron slot the platform ' + + 'evaluates is `Job.schedule.expression` (`system/job.zod.ts`): a DR test on a cadence is a ' + + 'job whose handler you write.'; + /** * Backup Configuration Schema */ export const BackupConfigSchema = lazySchema(() => z.object({ /** Backup strategy */ strategy: BackupStrategySchema.default('incremental').describe('Backup strategy'), - /** Cron schedule for automated backups */ - schedule: CronExpressionInputSchema.optional().describe('Cron expression for backup schedule — cron`0 2 * * *`'), + /** Tombstone (ADR-0049, #16320) — see `BACKUP_SCHEDULE_RETIRED`. */ + schedule: retiredKey(BACKUP_SCHEDULE_RETIRED), /** Retention policy */ retention: BackupRetentionSchema.describe('Backup retention policy'), /** Storage destination */ @@ -201,7 +227,6 @@ export type RTOParsed = z.infer; * rto: { value: 1, unit: 'hours' }, * backup: { * strategy: 'incremental', - * schedule: '0 0,6,12,18 * * *', * retention: { days: 90, minCopies: 5 }, * destination: { type: 's3', bucket: 'backup-bucket', region: 'us-east-1' }, * }, @@ -251,8 +276,8 @@ export const DisasterRecoveryPlanSchema = lazySchema(() => z.object({ testing: z.object({ /** Enable periodic DR testing */ enabled: z.boolean().default(false).describe('Enable automated DR testing'), - /** Cron schedule for DR tests */ - schedule: CronExpressionInputSchema.optional().describe('Cron expression for DR test schedule'), + /** Tombstone (ADR-0049, #16320) — see `DR_TESTING_SCHEDULE_RETIRED`. */ + schedule: retiredKey(DR_TESTING_SCHEDULE_RETIRED), /** Notification channel for test results */ notificationChannel: z.string().optional().describe('Notification channel for DR test results'), }).optional().describe('Automated disaster recovery testing'), diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index f233ebe4fa..5381d60503 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -423,7 +423,7 @@ a bare string (auto-wrapped) or their helper, and read the same variable scope. | Dialect | Helper | Grammar | Carriers | |:---|:---|:---|:---| -| `cron` | `` cron`0 6 * * MON` `` | 5- or 6-field cron, or one of `@yearly` `@annually` `@monthly` `@weekly` `@daily` `@hourly` `@reboot` | `Job.schedule.expression` (canonical), `connector.schedule`, `automation/execution.cronExpression`, `api/export.cronExpression` | +| `cron` | `` cron`0 6 * * MON` `` | 5- or 6-field cron, or one of `@yearly` `@annually` `@monthly` `@weekly` `@daily` `@hourly` `@reboot` | `Job.schedule.expression` (canonical) | | `template` | `` tmpl`Hello {{ record.first_name }}` `` | `{{ path }}` or `{{ path \| formatter[:arg] }}` — double braces only, no conditionals; the formatter whitelist is `TEMPLATE_FORMATTERS`, exported from `@objectstack/formula` | `system/email-template` `subject` / `bodyHtml` / `bodyText`, `ai/model-registry` `promptTemplate.system` / `.user`, `Object.titleFormat` (deprecated → `nameField`, ADR-0079) | `shared/expression.zod.ts` declares both surfaces and their carriers.