diff --git a/.changeset/cron-typed-positions-retired.md b/.changeset/cron-typed-positions-retired.md new file mode 100644 index 0000000000..5634313061 --- /dev/null +++ b/.changeset/cron-typed-positions-retired.md @@ -0,0 +1,136 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: delete the seven cron-typed positions nothing evaluated — export schedules, `ScheduleState.cronExpression`, `DataSyncConfig.schedule`, `CacheWarmup.schedule`, backup / DR-test schedules (ADR-0049) + + + +**BREAKING** — seven authorable positions across five schemas are DELETED. 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, by the +route the maintainer ruled on 2026-09-10: **直接删** — a bare deletion, with no +`retiredKey()` tombstone, no ADR-0087 D2 conversion and no D3 semantic entry. + +Seven positions 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`. + +| family | schema | deleted 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 an upgrading author actually observes.** None of the five schemas is `.strict()`, so +a bare deletion means Zod DROPS the key at the PARSE: an existing document still parses and +still loads, and the value is discarded there without a word. There is nothing for +`objectstack migrate meta` to list and nothing for the ADR-0087 chain to replay — the value +was already inert before this change, and it is inert after. + +The parse is not the only channel, and the two that speak are worth stating exactly, +because a reader who stops at "non-strict schema" will conclude the opposite: + +- **`os validate` / `os build` NAME the dropped key**, for the one deleted position a stack + manifest reaches (`connectors[].syncConfig.schedule`). `os validate` exits 0 and reports + `connectors..syncConfig.schedule: 'schedule' is not a declared connector key, so its + value is dropped at load.` — in the text face and in `--json`'s `warnings`; `os build` + prints the same line under `Undeclared authoring keys — dropped at load (#3786)`. The + channel is `lintUnknownAuthoringKeys`, which walks every stack collection whose entry + schema is strip-mode, and `connectors` is one. **`os validate --strict` treats that warning + as an error and EXITS 1**, so a pipeline running `--strict` over an otherwise-clean stack + refuses the upgraded manifest until the key is deleted. `os migrate meta` still lists + nothing, in either direction. +- **`tsc`**: a TypeScript author annotating with `Connector`, `ScheduledExport`, + `ScheduleState`, `CacheWarmup`, `BackupConfig` or `DisasterRecoveryPlan` gets an + excess-property error at the key and deletes it. + +The other six positions are not reachable from a stack manifest, so no CLI walk visits them: +for those the parse-level strip really is the whole of it. + +**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 (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. + +**One published TS MEMBER does leave, and "no def leaves" does not cover it.** The required +`cronExpression: string` member is deleted from `ScheduleExportInput` in +`contracts/export-service.ts` — the input type of `IExportService.scheduleExport`, a +published runtime TS interface (both names are in `api-surface/contracts.json`). It follows +the two spec positions it mirrored: with `ScheduledExport.schedule.cronExpression` gone, an +input demanding the key would ask a provider for a cadence it cannot store. The interface, +the method and every other member stay. Measured blast radius: no source outside +`packages/spec` names `ScheduleExportInput` or `IExportService` — 0 hits in this repo +(positive control: a symbol of the same class resolves outside `packages/spec` in the same +sweep) and 0 in `objectui` (control: 1326 files there import `@objectstack/spec`). An +implementor that *does* exist off-tree drops the member from its object literal; a caller +constructing a `ScheduleExportInput` drops it from the literal it passes. + +**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). + +## This change states no before/after rewrite, because there is none + +A breaking changeset in this repo normally states the old spelling beside the new one. +This one has no such pair to state: the same document PARSES before and after, the value +was inert in both, and no conversion can be written for it — so a metadata upgrader has no +edit to make and `os migrate meta` has nothing to list. That is a statement about the +migration chain, not about silence: `os validate` / `os build` do name the dropped +connector key and `os validate --strict` refuses on it (above), and `tsc` names the key and +the line for a TypeScript author. What follows is guidance for authoring a cadence going +forward, not a rewrite of an existing document. + +## What to write instead + +There is no replacement on any of the five schemas: 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: + +```ts +// A connector that used to carry `syncConfig.schedule: '*/15 * * * *'` declares +// the cadence as a job instead; the handler drives the connector. +defineStack({ + connectors: [{ name: 'sap_erp', label: 'SAP ERP', type: 'saas', syncConfig: { strategy: 'incremental' } }], + jobs: [{ name: 'sap_erp_sync', schedule: { expression: '*/15 * * * *' }, handler: 'syncSapErp' }], +}); +``` + +The retirement kit, in the shape the 2026-09-10 ruling prescribes: + +- the key is DELETED 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 site keeps a source comment recording what + left, why nothing ever read it, and what does work instead +- **no ADR-0087 registration at all** — no `RETIRED_KEYS_BY_MAJOR[18]` entry, no D2 + conversion, no D3 semantic entry, and nothing added to the protocol-18 chain step. That is + the ruling: 「直接删」, taken over the seat's written recommendation to keep the connector + family's D2, on the reading 「我们的客户也不会按照你的设想的版本按顺序升级」 +- the four baseline rows that existed (`automation/ScheduleState:cronExpression`, + `integration/DataSyncConfig:schedule`, `system/BackupConfig:schedule`, + `system/CacheWarmup:schedule`) are deleted from `authorable-surface/` in this same commit, + each carrying the #4650 proof the build computes for itself: the def is not reachable from + the 26 metadata-type roots. The three nested positions never had a row of their own +- 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 gone, 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`): per site, the authored value is + accepted and stripped and the enclosing block still parses, on the base schema and through + every nesting carrier (`Connector.syncConfig`, `stack.connectors[]`, the `/meta/connector` + door, `DisasterRecoveryPlan.backup`, `DistributedCacheConfig.warmup`); the `tsc` channel; + and — with lit and dark controls — that no `RETIRED_KEYS_BY_MAJOR` entry, no D2 conversion + and no D3 semantic entry names any of the seven +- generated baselines and docs follow the schema: the five reference pages are regenerated, + the published `objectstack-formula` skill's `cron` row drops the 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*; deleting keys removes neither diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 9803a1a0bc..9a3a1fa8fe 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -751,14 +751,13 @@ 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 | | **timezone** | `string` | optional (default: `"UTC"`) | IANA timezone | ### Nested Shape: `ScheduleExportRequest.delivery` @@ -825,7 +824,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 +836,6 @@ 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 | | **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..3c5997babf 100644 --- a/content/docs/references/automation/execution.mdx +++ b/content/docs/references/automation/execution.mdx @@ -345,7 +345,6 @@ 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` | | **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..c2d4bc239d 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,6 @@ 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 * * *` | | **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 +344,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 +629,6 @@ 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 * * *` | | **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 +650,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 +766,6 @@ 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 * * *` | | **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 +828,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..9d76ea5187 100644 --- a/content/docs/references/system/cache.mdx +++ b/content/docs/references/system/cache.mdx @@ -198,7 +198,6 @@ 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 | | **patterns** | `string[]` | optional | Key patterns to warm up (e.g., "user:*", "config:*") | | **concurrency** | `number` | optional (default: `10`) | Maximum concurrent warmup operations | @@ -214,14 +213,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 +251,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 +261,6 @@ 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 | | **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..c75bdeaff6 100644 --- a/content/docs/references/system/disaster-recovery.mdx +++ b/content/docs/references/system/disaster-recovery.mdx @@ -30,11 +30,10 @@ 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 | +| **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 +108,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 +136,10 @@ 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 | +| **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 +152,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 +168,6 @@ 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 | | **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..a9ce5f744c 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 key was + // DELETED OUTRIGHT — no `retiredKey()` tombstone, no D2 conversion and no D3 + // semantic entry (maintainer ruling 2026-09-10 on the retirement PR) — so there is + // no `CronExpressionInputSchema` member left at any of the seven, 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 deletion site in the schema source; it + // reaches no ADR-0087 entry, because the ruling registered none. 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..39631c0a11 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -283,7 +283,6 @@ "automation/ScheduleState:consecutiveFailures", "automation/ScheduleState:createdAt", "automation/ScheduleState:createdBy", - "automation/ScheduleState:cronExpression", "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..abb4665747 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -76,7 +76,6 @@ "integration/DataSyncConfig:direction", "integration/DataSyncConfig:filters", "integration/DataSyncConfig:realtimeSync", - "integration/DataSyncConfig:schedule", "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..3d2fc43ca2 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -121,7 +121,6 @@ "system/BackupConfig:destination", "system/BackupConfig:encryption", "system/BackupConfig:retention", - "system/BackupConfig:schedule", "system/BackupConfig:strategy", "system/BackupConfig:verifyAfterBackup", "system/BackupRetention:days", @@ -198,7 +197,6 @@ "system/CacheWarmup:concurrency", "system/CacheWarmup:enabled", "system/CacheWarmup:patterns", - "system/CacheWarmup:schedule", "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..74f2afccc3 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` was deleted outright (#16320) — the strip 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` was deleted outright (#16320) — the strip 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..0a5a391422 100644 --- a/packages/spec/src/api/export.zod.ts +++ b/packages/spec/src/api/export.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 { BaseResponseSchema } from './contract.zod'; /** @@ -559,7 +558,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'] }, * } */ @@ -572,8 +571,19 @@ export const ScheduledExportSchema = lazySchema(() => z.object({ fields: z.array(z.string()).optional().describe('Fields to include'), filter: z.record(z.string(), z.unknown()).optional().describe('Record filter criteria'), templateId: z.string().optional().describe('Export template ID for field mappings'), + /** + * Schedule timing configuration. + * + * `cronExpression` was DELETED here in @objectstack/spec 18 (ADR-0049 + * enforce-or-remove, #16320): the whole `ExportJobApiContracts` family has zero + * consumers, rest-server serves no `/api/v1/data/export` route and `IExportService` + * has no provider binding, so the cron was parsed and never fired. Deleted outright — + * no `retiredKey()` tombstone, no D2 conversion, no D3 semantic entry (maintainer + * ruling 2026-09-10 on the retirement PR). The mechanism that does work is + * `Job.schedule.expression` (`system/job.zod.ts`), the one cron slot the platform + * evaluates: a recurring export is a job whose handler you write. + */ schedule: z.object({ - cronExpression: CronExpressionInputSchema.describe('Cron expression for schedule'), timezone: z.string().default('UTC').describe('IANA timezone'), }).describe('Schedule timing configuration'), delivery: z.object({ @@ -702,8 +712,19 @@ export const ScheduleExportRequestSchema = lazySchema(() => z.object({ fields: z.array(z.string()).optional().describe('Fields to include'), filter: z.record(z.string(), z.unknown()).optional().describe('Record filter criteria'), templateId: z.string().optional().describe('Export template ID for field mappings'), + /** + * Schedule timing configuration. + * + * `cronExpression` was DELETED here in @objectstack/spec 18 (ADR-0049 + * enforce-or-remove, #16320): the whole `ExportJobApiContracts` family has zero + * consumers, rest-server serves no `/api/v1/data/export` route and `IExportService` + * has no provider binding, so the cron was parsed and never fired. Deleted outright — + * no `retiredKey()` tombstone, no D2 conversion, no D3 semantic entry (maintainer + * ruling 2026-09-10 on the retirement PR). The mechanism that does work is + * `Job.schedule.expression` (`system/job.zod.ts`), the one cron slot the platform + * evaluates: a recurring export is a job whose handler you write. + */ schedule: z.object({ - cronExpression: CronExpressionInputSchema.describe('Cron expression for schedule'), 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..9b5efa87e1 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` was deleted outright (#16320) — the strip 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 deleted it, 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 strip 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..3b0de1c1f7 100644 --- a/packages/spec/src/automation/execution.zod.ts +++ b/packages/spec/src/automation/execution.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'; /** * Automation Execution Protocol @@ -522,8 +521,19 @@ 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`'), + /* + * `cronExpression` was DELETED here in @objectstack/spec 18 (ADR-0049 + * enforce-or-remove, #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. Deleted outright — no `retiredKey()` tombstone, no + * D2 conversion, no D3 semantic entry (maintainer ruling 2026-09-10 on the + * retirement PR). `timezone` / `status` / `nextRunAt` stay: the ruling retires the + * cron position, not the def. A scheduled flow declares its cadence on the flow's + * start node (`config.schedule`); the one cron slot the platform evaluates is + * `Job.schedule.expression` (`system/job.zod.ts`). + */ 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..cf8ec4b906 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`, both DELETED under ADR-0049, #16320): + * the return type below no longer carries 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/cron-typed-positions-retirement.test.ts b/packages/spec/src/cron-typed-positions-retirement.test.ts new file mode 100644 index 0000000000..3ce4014c1d --- /dev/null +++ b/packages/spec/src/cron-typed-positions-retirement.test.ts @@ -0,0 +1,439 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import type { ZodTypeAny } from 'zod'; + +import { ScheduledExportSchema, ScheduleExportRequestSchema, type ScheduledExport, type ScheduleExportRequest } from './api/export.zod'; +import { ScheduleStateSchema, type ScheduleState } from './automation/execution.zod'; +import { CONVERSIONS_BY_MAJOR } from './conversions/registry'; +import { + ConnectorSchema, + DataSyncConfigSchema, + DeclarativeConnectorEntrySchema, + type Connector, + type DataSyncConfig, +} from './integration/connector.zod'; +import { getMetadataTypeSchema } from './kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from './migrations/registry'; +import { CacheWarmupSchema, DistributedCacheConfigSchema, type CacheWarmup, type DistributedCacheConfig } from './system/cache.zod'; +import { + BackupConfigSchema, + DisasterRecoveryPlanSchema, + type BackupConfig, + type DisasterRecoveryPlan, +} from './system/disaster-recovery.zod'; + +// ─── [#16320] the seven cron-typed positions nothing evaluated are DELETED ──── +// +// ADR-0049 enforce-or-remove. Every position was declared, parsed into the +// `{ dialect: 'cron', source }` envelope and read by NOTHING (the ADR-0058 D7 +// ledger row `cron-declared-unwired` had all seven `unevaluated`). +// +// ⚠️ The removal route is BARE DELETION, by maintainer ruling of 2026-09-10 on +// the retirement PR — 「直接删」, taken over the seat's written recommendation to +// keep a tombstone and the connector family's D2 conversion, on the reading that +// customers do not upgrade major by major in order. So NONE of the seven carries +// a `retiredKey()` tombstone, a `RETIRED_KEYS_BY_MAJOR[18]` entry, an ADR-0087 D2 +// conversion or a D3 semantic entry. +// +// That makes the PARSE-layer consequence a SILENT STRIP, not a refusal: none of +// the five schemas is `.strict()`, so zod drops an authored value and answers +// `success: true` (ADR-0104's shape). These pins record exactly that — what an +// author who keeps writing one of these keys gets from the SCHEMA — so the day +// someone changes the route, the change is loud here rather than invisible in +// the field. +// +// ⚠️ The parse is NOT the whole channel, and the difference is measured rather +// than reasoned. Above the parse, `lintUnknownAuthoringKeys` (#3786) walks every +// `PLURAL_TO_SINGULAR` collection whose entry schema is strip-mode, and +// `connectors: 'connector'` is one of them — so for the ONE of the seven a stack +// manifest reaches, the CLI NAMES the dropped key: +// +// • `os validate` — exit 0, and prints (`--json` carries the same string in +// `warnings`): +// connectors.sap_erp.syncConfig.schedule: 'schedule' is not a declared +// connector key, so its value is dropped at load. +// • `os validate --strict` — exit 1. Measured on an otherwise-clean stack: +// the same manifest WITHOUT the key is 0 warnings / exit 0, WITH it is +// 1 warning / exit 1. A CI running `--strict` REFUSES the upgraded manifest. +// • `os build` — the same line, under `Undeclared authoring keys (1) — +// dropped at load (#3786)`. +// • `os migrate meta` — still lists nothing, in either direction. There is no +// prescription to make, which is the half the bare deletion really does own. +// +// ⇒ ⛔ Do not read these pins as "the author is never told". They pin the schema +// layer. The author-facing loss is louder than a bare `safeParse` suggests, and +// it is louder than the ruling comment's cost statement assumed. + +const CRON = '0 6 * * MON'; +/** The envelope the old schema normalized the bare string into — dropped just the same. */ +const CRON_ENVELOPE = { dialect: 'cron', source: CRON }; + +// ── Well-formed fixtures: every required key, none of the deleted ones ────── + +const EXPORT_WELL_FORMED = { + name: 'weekly_account_export', + object: 'account', + schedule: { timezone: 'America/New_York' }, + delivery: { method: 'email' as const, recipients: ['admin@example.com'] }, +}; +const STATE_WELL_FORMED = { id: 'sched_001', flowName: 'daily_report', createdAt: '2026-01-01T00:00:00Z' }; +const SYNC_WELL_FORMED = { strategy: 'incremental' as const, direction: 'bidirectional' as const, batchSize: 500 }; +const CONNECTOR_WELL_FORMED = { name: 'sap_erp', label: 'SAP ERP', type: 'saas' as const, syncConfig: SYNC_WELL_FORMED }; +const WARMUP_WELL_FORMED = { enabled: true, strategy: 'scheduled' as const, patterns: ['config:*'] }; +const CACHE_WELL_FORMED = { + enabled: true, + tiers: [{ name: 'l1', type: 'memory' as const }], + invalidation: [], + warmup: WARMUP_WELL_FORMED, +}; +const BACKUP_WELL_FORMED = { retention: { days: 30 }, destination: { type: 's3' as const, bucket: 'backups' } }; +const DR_TESTING_WELL_FORMED = { enabled: true, notificationChannel: '#dr-alerts' }; +const DR_PLAN_WELL_FORMED = { + rpo: { value: 15 }, + rto: { value: 1, unit: 'hours' as const }, + backup: BACKUP_WELL_FORMED, + testing: DR_TESTING_WELL_FORMED, +}; + +interface DeletedSite { + /** The spelling the key WOULD have had in `RETIRED_KEYS_BY_MAJOR` — pinned absent below. */ + registered: string; + /** How the position reads to an author. */ + qualified: string; + schema: ZodTypeAny; + wellFormed: Record; + authored: unknown; + /** Path to the deleted key inside the parsed document. */ + keyPath: (string | number)[]; +} + +const SITES: DeletedSite[] = [ + { + 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 } }, + keyPath: ['schedule', 'cronExpression'], + }, + { + 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 } }, + keyPath: ['schedule', 'cronExpression'], + }, + { + registered: 'automation/ScheduleState:cronExpression', + qualified: 'ScheduleState.cronExpression', + schema: ScheduleStateSchema, + wellFormed: STATE_WELL_FORMED, + authored: { ...STATE_WELL_FORMED, cronExpression: CRON }, + keyPath: ['cronExpression'], + }, + { + registered: 'integration/DataSyncConfig:schedule', + qualified: 'connector.syncConfig.schedule', + schema: DataSyncConfigSchema, + wellFormed: SYNC_WELL_FORMED, + authored: { ...SYNC_WELL_FORMED, schedule: CRON }, + keyPath: ['schedule'], + }, + { + registered: 'system/CacheWarmup:schedule', + qualified: 'CacheWarmup.schedule', + schema: CacheWarmupSchema, + wellFormed: WARMUP_WELL_FORMED, + authored: { ...WARMUP_WELL_FORMED, schedule: CRON }, + keyPath: ['schedule'], + }, + { + registered: 'system/BackupConfig:schedule', + qualified: 'BackupConfig.schedule', + schema: BackupConfigSchema, + wellFormed: BACKUP_WELL_FORMED, + authored: { ...BACKUP_WELL_FORMED, schedule: CRON }, + keyPath: ['schedule'], + }, + { + 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 } }, + keyPath: ['testing', 'schedule'], + }, +]; + +/** The same deletions 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 } }, + keyPath: ['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 } }, + keyPath: ['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 } }, + keyPath: ['backup', 'schedule'], + }, + { + via: 'DistributedCacheConfig.warmup', + qualified: 'CacheWarmup.schedule', + schema: DistributedCacheConfigSchema, + wellFormed: CACHE_WELL_FORMED, + authored: { ...CACHE_WELL_FORMED, warmup: { ...WARMUP_WELL_FORMED, schedule: CRON } }, + keyPath: ['warmup', 'schedule'], + }, +]; + +/** The five ADR-0087 entry ids an earlier round of this card carried — pinned absent. */ +const NEVER_REGISTERED_IDS = [ + 'connector-sync-schedule-removed', + 'connector-sync-schedule-retired', + 'export-schedule-cron-retired', + 'schedule-state-cron-expression-retired', + 'cache-warmup-schedule-retired', + 'disaster-recovery-schedules-retired', +]; + +/** Walk to the enclosing block of a key path, then read the leaf. */ +function readAt(doc: unknown, keyPath: (string | number)[]): { block: Record; leaf: string } { + let at: unknown = doc; + for (const seg of keyPath.slice(0, -1)) at = (at as Record)[seg as string]; + return { block: at as Record, leaf: String(keyPath[keyPath.length - 1]) }; +} + +describe('[#16320] the seven cron-typed positions no longer exist on their schemas', () => { + for (const site of SITES) { + it(`\`${site.qualified}\` is gone — an authored value is accepted and STRIPPED, never materialized`, () => { + const parsed = site.schema.safeParse(site.authored); + // Bare deletion on a non-strict schema: no refusal, the value is dropped. + expect(parsed.success, `${site.qualified}: a non-strict schema strips, it does not refuse`).toBe(true); + if (!parsed.success) return; + const { block, leaf } = readAt(parsed.data, site.keyPath); + expect(block, `${site.qualified}: the enclosing block must still parse`).toBeDefined(); + expect(block).not.toHaveProperty(leaf); + // Attribution control: the same document WITHOUT the key parses too, so + // the absence above is the deletion and not a broken parse. + expect(site.schema.safeParse(site.wellFormed).success, `${site.qualified}: well-formed control must parse`).toBe(true); + }); + } + + it('the envelope spelling is dropped too — both shapes the old schema accepted are gone', () => { + const envelopeSites: Array<[DeletedSite, 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 parsed = site.schema.safeParse(authored); + expect(parsed.success, `${site.qualified} (envelope)`).toBe(true); + if (!parsed.success) continue; + const { block, leaf } = readAt(parsed.data, site.keyPath); + expect(block).not.toHaveProperty(leaf); + } + }); + + for (const carrier of CARRIERS) { + it(`\`${carrier.qualified}\` is gone through \`${carrier.via}\` as well`, () => { + const parsed = carrier.schema.safeParse(carrier.authored); + expect(parsed.success, carrier.via).toBe(true); + if (!parsed.success) return; + const { block, leaf } = readAt(parsed.data, carrier.keyPath); + expect(block, `${carrier.via}: the enclosing block must still parse`).toBeDefined(); + expect(block).not.toHaveProperty(leaf); + expect(carrier.schema.safeParse(carrier.wellFormed).success, `${carrier.via}: well-formed control must parse`).toBe(true); + }); + } + + it('the surviving keys still materialize — the absences above are the deletions, not a dead 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', () => { + 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 deleted key's. + expect(ScheduleStateSchema.safeParse({ id: 'sched_002', createdAt: '2026-01-01T00:00:00Z' }).success).toBe(false); + }); +}); + +describe('[#16320] the one manifest-reachable position — what an upgrading stack actually gets', () => { + it('`/meta/connector` (the registry-bound door) accepts the key and strips it', () => { + // The registry lookup is the real `/meta` entry point — a future rebinding + // that pointed `connector` at some third shape would pass the carrier pins + // above and still behave differently in production. + const schema = getMetadataTypeSchema('connector'); + expect(schema, 'no schema bound for `connector`').toBeDefined(); + const parsed = schema!.safeParse({ ...CONNECTOR_WELL_FORMED, syncConfig: { ...SYNC_WELL_FORMED, schedule: CRON } }); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect((parsed.data as { syncConfig: Record }).syncConfig).not.toHaveProperty('schedule'); + expect((parsed.data as { syncConfig: Record }).syncConfig.batchSize).toBe(500); + }); + + it('`stack.connectors[]` — the real authoring path — accepts the key and strips it', async () => { + // ⚠️ THE CONSEQUENCE OF THE 直接删 RULING, pinned. `DataSyncConfig.schedule` + // is the only one of the seven a stack manifest reaches (`stack.zod.ts` + // `connectors[]` → `connector.zod.ts` `syncConfig` → `schedule`). With no + // tombstone the manifest still LOADS and the cadence the author wrote is + // dropped — the ADR-0104 silent-strip shape at the PARSE, accepted + // deliberately by the ruling. + // + // ⛔ Silent at the parse is not silent to the author, and the module + // docblock carries the measurement: on this exact path `os validate` prints + // `connectors..syncConfig.schedule: 'schedule' is not a declared + // connector key, so its value is dropped at load.`, `os build` prints it + // under its undeclared-keys block, and `os validate --strict` EXITS 1 on it. + // This assertion is about `ObjectStackSchema` alone; it does not measure — + // and must not be quoted as — what the CLI tells the author. + const { ObjectStackSchema } = await import('./stack.zod'); + const parsed = ObjectStackSchema.safeParse({ + connectors: [{ ...CONNECTOR_WELL_FORMED, syncConfig: { ...SYNC_WELL_FORMED, schedule: CRON } }], + }); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + const connectors = (parsed.data as { connectors: Array<{ syncConfig: Record }> }).connectors; + expect(connectors[0]!.syncConfig).not.toHaveProperty('schedule'); + expect(connectors[0]!.syncConfig.strategy).toBe('incremental'); + // Positive control: the identical stack minus the deleted key parses too. + expect(ObjectStackSchema.safeParse({ connectors: [CONNECTOR_WELL_FORMED] }).success).toBe(true); + }); +}); + +describe('[#16320] the tsc channel: the seven keys are not in their input types', () => { + it('fails tsc at every authoring site', () => { + const sched: ScheduledExport = { + ...EXPORT_WELL_FORMED, + // @ts-expect-error — `schedule.cronExpression` was deleted; it is not a key of this type. + schedule: { ...EXPORT_WELL_FORMED.schedule, cronExpression: CRON }, + }; + const request: ScheduleExportRequest = { + ...EXPORT_WELL_FORMED, + // @ts-expect-error — the request body's twin position, deleted with it. + schedule: { ...EXPORT_WELL_FORMED.schedule, cronExpression: CRON }, + }; + const state: ScheduleState = { + ...STATE_WELL_FORMED, + // @ts-expect-error — `cronExpression` was deleted (and was required before). + cronExpression: CRON, + }; + const sync: DataSyncConfig = { + ...SYNC_WELL_FORMED, + // @ts-expect-error — `schedule` was deleted. + schedule: CRON, + }; + const connector: Connector = { + ...CONNECTOR_WELL_FORMED, + // @ts-expect-error — the deletion reaches through the carrier. + syncConfig: { ...SYNC_WELL_FORMED, schedule: CRON }, + }; + const warmup: CacheWarmup = { + ...WARMUP_WELL_FORMED, + // @ts-expect-error — `schedule` was deleted. + schedule: CRON, + }; + const cache: DistributedCacheConfig = { + ...CACHE_WELL_FORMED, + // @ts-expect-error — the deletion reaches through the carrier. + warmup: { ...WARMUP_WELL_FORMED, schedule: CRON }, + }; + const backup: BackupConfig = { + ...BACKUP_WELL_FORMED, + // @ts-expect-error — `schedule` was deleted. + schedule: CRON, + }; + const plan: DisasterRecoveryPlan = { + ...DR_PLAN_WELL_FORMED, + // @ts-expect-error — `testing.schedule` was deleted. + testing: { ...DR_TESTING_WELL_FORMED, schedule: CRON }, + }; + // tsc is the assertion above. At runtime the same values parse and lose the + // key, which is what keeps this case from being vacuous — and is precisely + // why the tsc channel is the ONLY loud one the bare deletion leaves. + for (const [schema, value, keyPath] of [ + [ScheduledExportSchema, sched, ['schedule', 'cronExpression']], + [ScheduleExportRequestSchema, request, ['schedule', 'cronExpression']], + [ScheduleStateSchema, state, ['cronExpression']], + [DataSyncConfigSchema, sync, ['schedule']], + [ConnectorSchema, connector, ['syncConfig', 'schedule']], + [CacheWarmupSchema, warmup, ['schedule']], + [DistributedCacheConfigSchema, cache, ['warmup', 'schedule']], + [BackupConfigSchema, backup, ['schedule']], + [DisasterRecoveryPlanSchema, plan, ['testing', 'schedule']], + ] as Array<[ZodTypeAny, unknown, (string | number)[]]>) { + const parsed = schema.safeParse(value); + expect(parsed.success).toBe(true); + if (!parsed.success) continue; + const { block, leaf } = readAt(parsed.data, keyPath); + expect(block).not.toHaveProperty(leaf); + } + }); +}); + +describe('[#16320] 直接删 — the ADR-0087 surfaces carry NOTHING for these seven', () => { + const registered = new Set(Object.values(RETIRED_KEYS_BY_MAJOR).flatMap((keys) => [...keys])); + + it('no `RETIRED_KEYS_BY_MAJOR` entry names any of the seven, at any major', () => { + for (const site of SITES) expect(registered.has(site.registered), site.registered).toBe(false); + // Lit control — the table is populated and this reader can see it. A key + // retired the tombstone way on the very same connector schema. + expect(registered.has('integration/Connector:errorMapping')).toBe(true); + // Dark control — a fabricated spelling must read absent, so the assertions + // above are membership readings and not a broken lookup. + expect(registered.has('integration/DataSyncConfig:noSuchKeyEverExisted')).toBe(false); + }); + + it('no D2 conversion covers them — not by id, and not by surface', () => { + const conversions = Object.values(CONVERSIONS_BY_MAJOR).flatMap((entries) => [...entries]); + const ids = new Set(conversions.map((c) => c.id)); + for (const id of NEVER_REGISTERED_IDS) expect(ids.has(id), id).toBe(false); + const surfaces = conversions.map((c) => c.surface); + expect(surfaces.some((s) => s.includes('syncConfig.schedule'))).toBe(false); + expect(surfaces.some((s) => s.includes('cronExpression'))).toBe(false); + // Lit control — the registry really is loaded and its surfaces really are + // readable: the sibling connector retirement that DID convert is here. + expect(ids.has('connector-error-mapping-removed')).toBe(true); + expect(surfaces.some((s) => s.includes('errorMapping'))).toBe(true); + // Dark control. + expect(ids.has('no-such-conversion-ever-existed')).toBe(false); + }); + + it('no D3 semantic entry and no step-18 chain reference survives', () => { + const step18 = MIGRATIONS_BY_MAJOR[18]; + expect(step18, 'step 18 must exist').toBeDefined(); + for (const id of NEVER_REGISTERED_IDS) { + expect(step18!.conversionIds.includes(id), `step18.conversionIds must not name ${id}`).toBe(false); + } + const semanticIds = new Set(Object.values(MIGRATIONS_BY_MAJOR).flatMap((step) => step.semantic.map((s) => s.id))); + for (const id of NEVER_REGISTERED_IDS) expect(semanticIds.has(id), id).toBe(false); + // Lit control — the semantic table is loaded and this reader sees it. + expect(semanticIds.has('connector-error-mapping-removed') || semanticIds.size > 0).toBe(true); + expect(step18!.conversionIds.includes('connector-error-mapping-removed')).toBe(true); + // Dark control. + expect(semanticIds.has('no-such-semantic-entry-ever-existed')).toBe(false); + }); +}); diff --git a/packages/spec/src/integration/connector-author-shape.test.ts b/packages/spec/src/integration/connector-author-shape.test.ts index d801078bfc..03cfe62221 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 deleted that key (ADR-0049; its + // absence 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..38496daef8 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` was deleted outright (#16320) — the strip 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..c0e6eb6ccb 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'; @@ -251,10 +250,18 @@ export const DataSyncConfigSchema = lazySchema(() => z.object({ 'bidirectional', // Both ways ]).optional().default('import').describe('Sync direction'), - /** - * Sync frequency (cron expression) + /* + * `syncConfig.schedule` was DELETED here in @objectstack/spec 18 (ADR-0049 + * enforce-or-remove, #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. Deleted outright — no `retiredKey()` tombstone, no D2 + * conversion, no D3 semantic entry (maintainer ruling 2026-09-10 on the retirement + * PR). `realtimeSync` is unchanged; sync on a cadence is a `job` + * (`Job.schedule.expression`, the one cron slot the platform evaluates) whose + * handler drives the connector. */ - schedule: CronExpressionInputSchema.optional().describe('Cron expression for scheduled sync — cron`0 */15 * * *`'), /** * Enable real-time sync via webhooks diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 635b99bf4f..3086d0c432 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5402,7 +5402,24 @@ 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 DELETED OUTRIGHT — no ' + + 'retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 ' + + 'semantic entry — so this step replays nothing for them and `migrate meta` lists no ' + + 'edit: the keys simply stop existing. That the chain is silent does NOT make the ' + + 'deletion silent to an author: the PARSE strips (no schema here is `.strict()`), but ' + + 'above it `lintUnknownAuthoringKeys` (#3786) names the dropped key for the one ' + + 'position a stack manifest reaches — `os validate` and `os build` both print ' + + '`connectors..syncConfig.schedule: \'schedule\' is not a declared connector ' + + 'key, so its value is dropped at load.`, and `os validate --strict` EXITS 1 on that ' + + 'warning. The other six positions are unreachable from a manifest, so for those the ' + + 'parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 ' + + 'on the retirement PR, taken over the seat recommendation to keep the connector D2, on ' + + 'the reading that customers do not upgrade major by major in order.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', diff --git a/packages/spec/src/shared/expression.zod.ts b/packages/spec/src/shared/expression.zod.ts index d3ddffa7bd..16c11ad2e1 100644 --- a/packages/spec/src/shared/expression.zod.ts +++ b/packages/spec/src/shared/expression.zod.ts @@ -333,15 +333,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..546b6e95fd 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,17 @@ 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 DELETED it + * (ADR-0049 — nothing evaluated it; deleted outright, no tombstone, by the + * maintainer ruling of 2026-09-10). It stays in this block as the absence it + * now is: `DataSyncConfigSchema` is not `.strict()`, so every shape that used + * to draw a dialect verdict at that path is now dropped in silence — 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 +179,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 +199,23 @@ 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 — every shape is STRIPPED at `connectors.0.syncConfig.schedule`, drawing no verdict at all', () => { + // 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 are dropped now. `DataSyncConfigSchema` is not `.strict()` and the + // key was deleted with no `retiredKey()` tombstone, so there is no issue to + // read — the ADR-0104 silent-strip shape, accepted deliberately by the + // ruling and pinned here so a route change is loud. + for (const authored of [{ dialect: 'template', source: '{{x}}' }, { dialect: 'cron', source: '*/15 * * * *' }, '*/15 * * * *']) { + expect(stackIssues({ manifest, connectors: [connector(authored)] }), JSON.stringify(authored)).toEqual([]); + const parsed = ObjectStackDefinitionSchema.safeParse({ manifest, connectors: [connector(authored)] }); + expect(parsed.success).toBe(true); + if (!parsed.success) continue; + expect(parsed.data.connectors?.[0]?.syncConfig).not.toHaveProperty('schedule'); + } + // 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..3730aa5840 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` was deleted outright (#16320); the strip 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..813cf7828c 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 @@ -179,8 +178,17 @@ 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'), + /* + * `CacheWarmup.schedule` was DELETED here in @objectstack/spec 18 (ADR-0049 + * enforce-or-remove, #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. Deleted outright — no `retiredKey()` tombstone, no + * D2 conversion, no D3 semantic entry (maintainer ruling 2026-09-10 on the + * retirement PR). The `strategy` enum keeps its `scheduled` member: it is a value, + * not a position this ruling names, and it was exactly as inert before. 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. + */ /** 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..8250b7cbec 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` was deleted outright (#16320) — the strip 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..c7d4e9d7a2 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' }, * }; @@ -54,8 +52,15 @@ export type BackupRetentionParsed = z.infer; 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 * * *`'), + /* + * `BackupConfig.schedule` was DELETED here in @objectstack/spec 18 (ADR-0049 + * enforce-or-remove, #16320): declared, parsed into the cron envelope and read by + * nothing — no backup engine exists on the platform, so an automated backup never + * ran on it. Deleted outright — no `retiredKey()` tombstone, no D2 conversion, no D3 + * semantic entry (maintainer ruling 2026-09-10 on the retirement PR). 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 policy */ retention: BackupRetentionSchema.describe('Backup retention policy'), /** Storage destination */ @@ -201,7 +206,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 +255,15 @@ 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'), + /* + * `DisasterRecoveryPlan.testing.schedule` was DELETED here in @objectstack/spec 18 + * (ADR-0049 enforce-or-remove, #16320): declared, parsed into the cron envelope and + * read by nothing — no disaster-recovery test runner exists on the platform, so a + * periodic DR test never ran. Deleted outright — no `retiredKey()` tombstone, no D2 + * conversion, no D3 semantic entry (maintainer ruling 2026-09-10 on the retirement + * PR). 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. + */ /** 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.