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