diff --git a/.changeset/inert-deadline-keys-retired.md b/.changeset/inert-deadline-keys-retired.md new file mode 100644 index 0000000000..decce162bc --- /dev/null +++ b/.changeset/inert-deadline-keys-retired.md @@ -0,0 +1,140 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): retire the fourteen inert deadline keys of the incident-response, training and change-management schemas (#14477, ADR-0049) + + + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`; the migration prescriptions are +registered under protocol major 18, where `os migrate meta` users will look). +Maintainer ruling 2026-09-02 on the census card (ruled A: retire per family): +ADR-0049 enforce-or-remove decides it — declared-but-unenforced deadline +surface with zero measured readers comes off. + +Fourteen hour/minute/day-shaped deadline, SLA and duration key sites — twelve +distinct names, because `durationMinutes` and `estimatedMinutes` each occur at +two sites — sat on the exported incident-response, training and +change-management schemas and in the generated reference docs, and **nothing +read them**: the schemas are exported from `@objectstack/spec/system`, mounted +by no stack key, registered as no metadata type, absent from the 2026-06 +liveness ledgers, and the reader census over every package outside +`packages/spec` (tests and changelogs excluded) and over objectui at the +pinned sha returned zero hits for every key. An author could write +`triageDeadlineHours: 4`, `validityDays: 365` or `regulatorDeadlineHours: 72` +and reasonably expect the platform to escalate, expire or notify — it never +did, and it never said so. Six of the keys carried defaults (30 minutes, +1 hour, 2555 days; 365, 30 and 14 days) that were materialized into every +parsed document without ever being consulted. A compliance-shaped deadline +that fails silently is the worst form of the shape ADR-0049 names. + +**What is refused:** authoring any of the keys below, with any value, on the +base schema and through every carrier that nests it (`Incident.responsePhases[]`, +`IncidentResponsePolicy.notificationMatrix`, `TrainingPlan.courses[]`, +`ChangeRequest.impact` / `.rollbackPlan` / `.implementation`). None of the +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). + +| schema | retired keys | +|:--|:--| +| `IncidentResponsePhase` | `targetHours` | +| `IncidentNotificationRule` | `withinMinutes`, `regulatorDeadlineHours` | +| `IncidentNotificationMatrix` | `escalationTimeoutMinutes` (default 30) | +| `IncidentResponsePolicy` | `triageDeadlineHours` (default 1), `retentionDays` (default 2555) | +| `TrainingCourse` | `durationMinutes`, `validityDays` | +| `TrainingPlan` | `recertificationIntervalDays` (default 365), `gracePeriodDays` (default 30), `reminderDaysBefore` (default 14) | +| `ChangeImpact` | `downtime.durationMinutes` | +| `RollbackPlan` | `steps[].estimatedMinutes` | +| `ChangeRequest` | `implementation.steps[].estimatedMinutes` | + +**What stays, byte-identical:** every other key of the three families with its +default and its (absent) readers, and every export — no def leaves the public +surface. Parsed documents no longer carry the six former defaults. + +**Held, not touched:** the `ESignatureConfig` pair (`expirationDays`, +`reminderDays` in `data/document.zod.ts`) — the ruling left that branch open +pending the e-signature roadmap answer; it stays on the card. + +## FROM → TO + +```ts +// before — parsed green; no engine ever read a single one of these numbers +const policy: IncidentResponsePolicy = { + notificationMatrix: { + rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], + withinMinutes: 15, notifyRegulators: true, regulatorDeadlineHours: 72 }], + escalationTimeoutMinutes: 45, + }, + defaultResponseTeam: 'security_team', + triageDeadlineHours: 2, + retentionDays: 3650, +}; +const course: TrainingCourse = { + id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', + category: 'security_awareness', targetRoles: ['all_employees'], + durationMinutes: 60, validityDays: 365, +}; +const rollback: RollbackPlan = { + description: 'Restore from backup', + steps: [{ order: 1, description: 'Restore backup', estimatedMinutes: 15 }], +}; + +// after — delete the keys; there is no replacement because no incident-response, +// training-management or change-management engine exists to keep a deadline. +// Record retention is the object-level `lifecycle` block (ADR-0057), declared on +// the object that stores the records. +const policy: IncidentResponsePolicy = { + notificationMatrix: { + rules: [{ severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], + notifyRegulators: true }], + }, + defaultResponseTeam: 'security_team', +}; +const course: TrainingCourse = { + id: 'COURSE-SEC-001', title: 'Security Fundamentals', description: '…', + category: 'security_awareness', targetRoles: ['all_employees'], +}; +const rollback: RollbackPlan = { + description: 'Restore from backup', + steps: [{ order: 1, description: 'Restore backup' }], +}; +``` + +One-line fix: delete the key wherever it is authored. There is no +`os migrate meta` edit list for these keys — none of the schemas is a stack +collection member, 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: + +- `retiredKey()` tombstones at all fourteen sites (`packages/spec/src/system/ + incident-response.zod.ts`, `training.zod.ts`, `change-management.zod.ts`; + each file's section comment records what the shape was and why no D2 + conversion exists) +- ADR-0087 registration: fourteen `RETIRED_KEYS_BY_MAJOR[18]` entries (the + three nested change-management sites spelled `ChangeImpact:downtime.durationMinutes`, + `RollbackPlan:steps.estimatedMinutes`, `ChangeRequest:implementation.steps.estimatedMinutes`) + and three D3 semantic entries, one per family +- no liveness-ledger row: none of the three families is an enrolled ledger + type, so there is no row to keep or drop +- pin tests (`deadline-keys-retirement.test.ts`): a refusal pin per site + asserting the issue path, code and prescription on the base schema and + through the nesting carriers; the tsc `never` channel; no-materialize pins + for the six former defaults; the ADR-0087 registration; and a tree-scoped + absence pin over every authored source in the repo +- generated baselines and docs follow the schema: `authorable-surface/` gains + eleven `[RETIRED]` rows, `authorable-defaults/` loses six rows, the three + system reference pages are regenerated, and the gitignored `json-schema/` + output is re-emitted on the next build +- `json-schema.manifest/` is unchanged, and correctly so: it ratchets def + *names*, and retiring keys removes no def from the published surface +- `spec-changes.json` and the protocol upgrade guide are unchanged too: both + project the migration chain at the current protocol major (17), so these + protocol-18 registrations reach them at the 18 cut +- zero authored occurrences in this repo's examples, skills and hand-written + docs, and zero hits in objectui at the pinned sha, so no in-repo source + changes ride along beyond the three families' own unit tests diff --git a/content/docs/references/system/change-management.mdx b/content/docs/references/system/change-management.mdx index d8a37bbfa7..8dc1add70a 100644 --- a/content/docs/references/system/change-management.mdx +++ b/content/docs/references/system/change-management.mdx @@ -30,14 +30,14 @@ const result = ChangeImpactSchema.parse(data); | **level** | `Enum<'low' \| 'medium' \| 'high' \| 'critical'>` | ✅ | Impact level | | **affectedSystems** | `string[]` | ✅ | Affected systems | | **affectedUsers** | `number` | optional | Affected user count | -| **downtime** | `{ required: boolean; durationMinutes?: number }` | optional | Downtime information | +| **downtime** | `{ required: boolean }` | optional | Downtime information | ### Nested Shape: `ChangeImpact.downtime` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **required** | `boolean` | ✅ | Downtime required | -| **durationMinutes** | `number` | optional | Downtime duration | +| **durationMinutes** | `never` | optional | [REMOVED] `ChangeImpact.downtime.durationMinutes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine scheduled a maintenance window or measured an outage against it, so the declared downtime was never enforced, announced or compared with what happened. Delete the key. There is no replacement, because no change-management engine exists. | --- @@ -84,14 +84,14 @@ const result = ChangeImpactSchema.parse(data); | **level** | `Enum<'low' \| 'medium' \| 'high' \| 'critical'>` | ✅ | Impact level | | **affectedSystems** | `string[]` | ✅ | Affected systems | | **affectedUsers** | `number` | optional | Affected user count | -| **downtime** | `{ required: boolean; durationMinutes?: number }` | optional | Downtime information | +| **downtime** | `{ required: boolean }` | optional | Downtime information | ### Nested Shape: `ChangeRequest.implementation` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **description** | `string` | ✅ | Implementation description | -| **steps** | `{ order: number; description: string; estimatedMinutes: number }[]` | ✅ | Implementation steps | +| **steps** | `{ order: number; description: string }[]` | ✅ | Implementation steps | | **testing** | `string` | optional | Testing procedure | ### Nested Shape: `ChangeRequest.rollbackPlan` @@ -99,7 +99,7 @@ const result = ChangeImpactSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **description** | `string` | ✅ | Rollback description | -| **steps** | `{ order: number; description: string; estimatedMinutes: number }[]` | ✅ | Rollback steps | +| **steps** | `{ order: number; description: string }[]` | ✅ | Rollback steps | | **testProcedure** | `string` | optional | Test procedure | ### Nested Shape: `ChangeRequest.schedule` @@ -177,7 +177,7 @@ const result = ChangeImpactSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **description** | `string` | ✅ | Rollback description | -| **steps** | `{ order: number; description: string; estimatedMinutes: number }[]` | ✅ | Rollback steps | +| **steps** | `{ order: number; description: string }[]` | ✅ | Rollback steps | | **testProcedure** | `string` | optional | Test procedure | ### Nested Shape: `RollbackPlan.steps[number]` @@ -186,7 +186,7 @@ const result = ChangeImpactSchema.parse(data); | :--- | :--- | :--- | :--- | | **order** | `number` | ✅ | Step order | | **description** | `string` | ✅ | Step description | -| **estimatedMinutes** | `number` | ✅ | Estimated duration | +| **estimatedMinutes** | `never` | optional | [REMOVED] `RollbackPlan.steps[].estimatedMinutes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine executed, timed or summed rollback steps, so the estimate was never compared with anything. Delete the key from every step. There is no replacement, because no change-management engine exists. | --- diff --git a/content/docs/references/system/incident-response.mdx b/content/docs/references/system/incident-response.mdx index 7030aa780c..3636251453 100644 --- a/content/docs/references/system/incident-response.mdx +++ b/content/docs/references/system/incident-response.mdx @@ -49,7 +49,7 @@ Security incident record per ISO 27001:2022 A.5.24–A.5.28 | **resolvedAt** | `number` | optional | Resolution timestamp | | **affectedSystems** | `string[]` | ✅ | Affected systems | | **affectedDataClassifications** | `Enum<'pii' \| 'phi' \| 'pci' \| 'financial' \| 'confidential' \| 'internal' \| 'public'>[]` | optional | Affected data classifications | -| **responsePhases** | `{ phase: Enum<'identification' \| 'containment' \| 'eradication' \| 'recovery' \| 'lessons_learned'>; description: string; assignedTo: string; targetHours: number; … }[]` | optional | Incident response phases | +| **responsePhases** | `{ phase: Enum<'identification' \| 'containment' \| 'eradication' \| 'recovery' \| 'lessons_learned'>; description: string; assignedTo: string; completedAt?: number; … }[]` | optional | Incident response phases | | **rootCause** | `string` | optional | Root cause analysis | | **correctiveActions** | `string[]` | optional | Corrective actions taken or planned | | **lessonsLearned** | `string` | optional | Lessons learned from the incident | @@ -79,7 +79,7 @@ Incident response phase with timing and assignment | **phase** | `Enum<'identification' \| 'containment' \| 'eradication' \| 'recovery' \| 'lessons_learned'>` | ✅ | Response phase name | | **description** | `string` | ✅ | Phase description and objectives | | **assignedTo** | `string` | ✅ | Responsible team or role | -| **targetHours** | `number` | ✅ | Target completion time in hours | +| **targetHours** | `never` | optional | [REMOVED] `IncidentResponsePhase.targetHours` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine tracked a response phase against a clock, so the target was never checked, never escalated and never reported. Delete the key. There is no replacement, because no incident-response engine exists to keep a phase deadline. | | **completedAt** | `number` | optional | Actual completion timestamp | | **notes** | `string` | optional | Phase notes and findings | @@ -113,8 +113,8 @@ Incident notification matrix with escalation policies | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **rules** | `{ severity: Enum<'critical' \| 'high' \| 'medium' \| 'low'>; channels: Enum<'email' \| 'sms' \| 'slack' \| 'pagerduty' \| 'webhook'>[]; recipients: string[]; withinMinutes: number; … }[]` | ✅ | Notification rules by severity level | -| **escalationTimeoutMinutes** | `number` | optional (default: `30`) | Auto-escalation timeout in minutes | +| **rules** | `{ severity: Enum<'critical' \| 'high' \| 'medium' \| 'low'>; channels: Enum<'email' \| 'sms' \| 'slack' \| 'pagerduty' \| 'webhook'>[]; recipients: string[]; notifyRegulators: boolean }[]` | ✅ | Notification rules by severity level | +| **escalationTimeoutMinutes** | `never` | optional | [REMOVED] `IncidentNotificationMatrix.escalationTimeoutMinutes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine walked `escalationChain` on a timer, so the timeout never fired, and its default of 30 minutes was materialized into every parsed matrix without ever being consulted. Delete the key. There is no replacement, because no escalation engine exists. | | **escalationChain** | `string[]` | optional (default: `[]`) | Ordered escalation chain of roles | ### Nested Shape: `IncidentNotificationMatrix.rules[number]` @@ -126,9 +126,9 @@ Incident notification rule per severity level | **severity** | `Enum<'critical' \| 'high' \| 'medium' \| 'low'>` | ✅ | Minimum severity to trigger notification | | **channels** | `Enum<'email' \| 'sms' \| 'slack' \| 'pagerduty' \| 'webhook'>[]` | ✅ | Notification channels | | **recipients** | `string[]` | ✅ | Roles or teams to notify | -| **withinMinutes** | `number` | ✅ | Notification deadline in minutes from detection | +| **withinMinutes** | `never` | optional | [REMOVED] `IncidentNotificationRule.withinMinutes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no dispatcher sent an incident notification, so no deadline for one was ever measured. Delete the key. There is no replacement, because no incident-notification engine exists to keep the deadline. | | **notifyRegulators** | `boolean` | optional (default: `false`) | Whether to notify regulatory authorities | -| **regulatorDeadlineHours** | `number` | optional | Regulatory notification deadline in hours | +| **regulatorDeadlineHours** | `never` | optional | [REMOVED] `IncidentNotificationRule.regulatorDeadlineHours` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine notified a regulator, so a regulatory deadline declared here (a GDPR 72-hour window, for example) was never tracked, and a compliance author who wrote it held a promise the platform did not keep. Delete the key. There is no replacement, because no regulatory-notification engine exists. | --- @@ -144,9 +144,9 @@ Incident notification rule per severity level | **severity** | `Enum<'critical' \| 'high' \| 'medium' \| 'low'>` | ✅ | Minimum severity to trigger notification | | **channels** | `Enum<'email' \| 'sms' \| 'slack' \| 'pagerduty' \| 'webhook'>[]` | ✅ | Notification channels | | **recipients** | `string[]` | ✅ | Roles or teams to notify | -| **withinMinutes** | `number` | ✅ | Notification deadline in minutes from detection | +| **withinMinutes** | `never` | optional | [REMOVED] `IncidentNotificationRule.withinMinutes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no dispatcher sent an incident notification, so no deadline for one was ever measured. Delete the key. There is no replacement, because no incident-notification engine exists to keep the deadline. | | **notifyRegulators** | `boolean` | optional (default: `false`) | Whether to notify regulatory authorities | -| **regulatorDeadlineHours** | `number` | optional | Regulatory notification deadline in hours | +| **regulatorDeadlineHours** | `never` | optional | [REMOVED] `IncidentNotificationRule.regulatorDeadlineHours` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine notified a regulator, so a regulatory deadline declared here (a GDPR 72-hour window, for example) was never tracked, and a compliance author who wrote it held a promise the platform did not keep. Delete the key. There is no replacement, because no regulatory-notification engine exists. | --- @@ -162,7 +162,7 @@ Incident response phase with timing and assignment | **phase** | `Enum<'identification' \| 'containment' \| 'eradication' \| 'recovery' \| 'lessons_learned'>` | ✅ | Response phase name | | **description** | `string` | ✅ | Phase description and objectives | | **assignedTo** | `string` | ✅ | Responsible team or role | -| **targetHours** | `number` | ✅ | Target completion time in hours | +| **targetHours** | `never` | optional | [REMOVED] `IncidentResponsePhase.targetHours` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine tracked a response phase against a clock, so the target was never checked, never escalated and never reported. Delete the key. There is no replacement, because no incident-response engine exists to keep a phase deadline. | | **completedAt** | `number` | optional | Actual completion timestamp | | **notes** | `string` | optional | Phase notes and findings | @@ -178,19 +178,19 @@ Organization-level incident response policy per ISO 27001:2022 | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | Enable incident response management | -| **notificationMatrix** | `{ rules: object[]; escalationTimeoutMinutes: number; escalationChain: string[] }` | ✅ | Notification and escalation matrix | +| **notificationMatrix** | `{ rules: object[]; escalationChain: string[] }` | ✅ | Notification and escalation matrix | | **defaultResponseTeam** | `string` | ✅ | Default incident response team or role | -| **triageDeadlineHours** | `number` | optional (default: `1`) | Maximum hours to begin triage after detection | +| **triageDeadlineHours** | `never` | optional | [REMOVED] `IncidentResponsePolicy.triageDeadlineHours` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine timed the interval between detection and triage, so the deadline was never kept, and its default of 1 hour was materialized into every parsed policy without ever being consulted. Delete the key. There is no replacement, because no incident-response engine exists to keep a triage window. | | **requirePostIncidentReview** | `boolean` | optional (default: `true`) | Require post-incident review for all incidents | | **regulatoryNotificationThreshold** | `Enum<'critical' \| 'high' \| 'medium' \| 'low'>` | optional (default: `"high"`) | Minimum severity requiring regulatory notification | -| **retentionDays** | `number` | optional (default: `2555`) | Incident record retention period in days (default ~7 years) | +| **retentionDays** | `never` | optional | [REMOVED] `IncidentResponsePolicy.retentionDays` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no sweeper deleted incident records on a schedule, so the retention period was never applied, and its default of 2555 days was materialized into every parsed policy without ever being consulted. Delete the key. Retention on this platform is the object-level `lifecycle` block (ADR-0057), enforced by the LifecycleService over the records of an object — declare it on the object that stores incident records, not on this policy document. | ### Nested Shape: `IncidentResponsePolicy.notificationMatrix` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **rules** | `{ severity: Enum<'critical' \| 'high' \| 'medium' \| 'low'>; channels: Enum<'email' \| 'sms' \| 'slack' \| 'pagerduty' \| 'webhook'>[]; recipients: string[]; withinMinutes: number; … }[]` | ✅ | Notification rules by severity level | -| **escalationTimeoutMinutes** | `number` | optional (default: `30`) | Auto-escalation timeout in minutes | +| **rules** | `{ severity: Enum<'critical' \| 'high' \| 'medium' \| 'low'>; channels: Enum<'email' \| 'sms' \| 'slack' \| 'pagerduty' \| 'webhook'>[]; recipients: string[]; notifyRegulators: boolean }[]` | ✅ | Notification rules by severity level | +| **escalationTimeoutMinutes** | `never` | optional | [REMOVED] `IncidentNotificationMatrix.escalationTimeoutMinutes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine walked `escalationChain` on a timer, so the timeout never fired, and its default of 30 minutes was materialized into every parsed matrix without ever being consulted. Delete the key. There is no replacement, because no escalation engine exists. | | **escalationChain** | `string[]` | optional (default: `[]`) | Ordered escalation chain of roles | diff --git a/content/docs/references/system/training.mdx b/content/docs/references/system/training.mdx index 194bdb6f15..2e2091f89c 100644 --- a/content/docs/references/system/training.mdx +++ b/content/docs/references/system/training.mdx @@ -71,10 +71,10 @@ Security training course definition | **title** | `string` | ✅ | Course title | | **description** | `string` | ✅ | Course description and learning objectives | | **category** | `Enum<'security_awareness' \| 'data_protection' \| 'incident_response' \| 'access_control' \| 'phishing_awareness' \| 'compliance' \| 'secure_development' \| … +3 more>` | ✅ | Training category | -| **durationMinutes** | `number` | ✅ | Estimated course duration in minutes | +| **durationMinutes** | `never` | optional | [REMOVED] `TrainingCourse.durationMinutes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no training engine scheduled, timed or reported a course, so the duration was a number the platform displayed nowhere and acted on never. Delete the key. There is no replacement, because no training-management engine exists. | | **mandatory** | `boolean` | optional (default: `false`) | Whether training is mandatory | | **targetRoles** | `string[]` | ✅ | Target roles or groups | -| **validityDays** | `number` | optional | Certification validity period in days | +| **validityDays** | `never` | optional | [REMOVED] `TrainingCourse.validityDays` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine computed a certification expiry from it, so a certificate declared valid for 365 days never expired on the platform and never triggered recertification. Delete the key. There is no replacement, because no training-management engine exists to keep a validity window. | | **passingScore** | `number` | optional | Minimum passing score percentage | | **version** | `string` | optional | Course content version | @@ -104,11 +104,11 @@ Organizational training plan per ISO 27001:2022 A.6.3 | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | Enable training management | | **courses** | `{ id: string; title: string; description: string; category: Enum<'security_awareness' \| 'data_protection' \| 'incident_response' \| …>; … }[]` | ✅ | Training courses | -| **recertificationIntervalDays** | `number` | optional (default: `365`) | Default recertification interval in days | +| **recertificationIntervalDays** | `never` | optional | [REMOVED] `TrainingPlan.recertificationIntervalDays` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine re-assigned training on an interval, so the interval never elapsed into anything, and its default of 365 days was materialized into every parsed plan without ever being consulted. Delete the key. There is no replacement, because no training-management engine exists. | | **trackCompletion** | `boolean` | optional (default: `true`) | Track training completion for compliance | -| **gracePeriodDays** | `number` | optional (default: `30`) | Grace period in days after certification expiry | +| **gracePeriodDays** | `never` | optional | [REMOVED] `TrainingPlan.gracePeriodDays` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine escalated an expired certification, so a grace period before that escalation had nothing to delay, and its default of 30 days was materialized into every parsed plan without ever being consulted. Delete the key. There is no replacement, because no training-management engine exists. | | **sendReminders** | `boolean` | optional (default: `true`) | Send reminders for upcoming training deadlines | -| **reminderDaysBefore** | `number` | optional (default: `14`) | Days before deadline to send first reminder | +| **reminderDaysBefore** | `never` | optional | [REMOVED] `TrainingPlan.reminderDaysBefore` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine sent a training reminder, so the lead time was never counted down, and its default of 14 days was materialized into every parsed plan without ever being consulted. Delete the key. There is no replacement, because no training-reminder engine exists. | ### Nested Shape: `TrainingPlan.courses[number]` @@ -120,10 +120,10 @@ Security training course definition | **title** | `string` | ✅ | Course title | | **description** | `string` | ✅ | Course description and learning objectives | | **category** | `Enum<'security_awareness' \| 'data_protection' \| 'incident_response' \| …>` | ✅ | Training category | -| **durationMinutes** | `number` | ✅ | Estimated course duration in minutes | +| **durationMinutes** | `never` | optional | [REMOVED] `TrainingCourse.durationMinutes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no training engine scheduled, timed or reported a course, so the duration was a number the platform displayed nowhere and acted on never. Delete the key. There is no replacement, because no training-management engine exists. | | **mandatory** | `boolean` | optional (default: `false`) | Whether training is mandatory | | **targetRoles** | `string[]` | ✅ | Target roles or groups | -| **validityDays** | `number` | optional | Certification validity period in days | +| **validityDays** | `never` | optional | [REMOVED] `TrainingCourse.validityDays` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no engine computed a certification expiry from it, so a certificate declared valid for 365 days never expired on the platform and never triggered recertification. Delete the key. There is no replacement, because no training-management engine exists to keep a validity window. | | **passingScore** | `number` | optional | Minimum passing score percentage | | **version** | `string` | optional | Course content version | diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index a90f9c1633..5ab5ff1c11 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -109,13 +109,10 @@ "system/HttpDestinationConfig:method = \"POST\"", "system/HttpDestinationConfig:timeout = 30000", "system/IncidentNotificationMatrix:escalationChain = []", - "system/IncidentNotificationMatrix:escalationTimeoutMinutes = 30", "system/IncidentNotificationRule:notifyRegulators = false", "system/IncidentResponsePolicy:enabled = true", "system/IncidentResponsePolicy:regulatoryNotificationThreshold = \"high\"", "system/IncidentResponsePolicy:requirePostIncidentReview = true", - "system/IncidentResponsePolicy:retentionDays = 2555", - "system/IncidentResponsePolicy:triageDeadlineHours = 1", "system/Job:enabled = true", "system/KeyRotationPolicy:autoRotate = true", "system/KeyRotationPolicy:enabled = false", @@ -259,9 +256,6 @@ "system/TracingConfig:traceIdGenerator = \"random\"", "system/TrainingCourse:mandatory = false", "system/TrainingPlan:enabled = true", - "system/TrainingPlan:gracePeriodDays = 30", - "system/TrainingPlan:recertificationIntervalDays = 365", - "system/TrainingPlan:reminderDaysBefore = 14", "system/TrainingPlan:sendReminders = true", "system/TrainingPlan:trackCompletion = true" ] diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index a96c863615..a223202d98 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -498,27 +498,27 @@ "system/Incident:status", "system/Incident:title", "system/IncidentNotificationMatrix:escalationChain", - "system/IncidentNotificationMatrix:escalationTimeoutMinutes", + "system/IncidentNotificationMatrix:escalationTimeoutMinutes [RETIRED]", "system/IncidentNotificationMatrix:rules", "system/IncidentNotificationRule:channels", "system/IncidentNotificationRule:notifyRegulators", "system/IncidentNotificationRule:recipients", - "system/IncidentNotificationRule:regulatorDeadlineHours", + "system/IncidentNotificationRule:regulatorDeadlineHours [RETIRED]", "system/IncidentNotificationRule:severity", - "system/IncidentNotificationRule:withinMinutes", + "system/IncidentNotificationRule:withinMinutes [RETIRED]", "system/IncidentResponsePhase:assignedTo", "system/IncidentResponsePhase:completedAt", "system/IncidentResponsePhase:description", "system/IncidentResponsePhase:notes", "system/IncidentResponsePhase:phase", - "system/IncidentResponsePhase:targetHours", + "system/IncidentResponsePhase:targetHours [RETIRED]", "system/IncidentResponsePolicy:defaultResponseTeam", "system/IncidentResponsePolicy:enabled", "system/IncidentResponsePolicy:notificationMatrix", "system/IncidentResponsePolicy:regulatoryNotificationThreshold", "system/IncidentResponsePolicy:requirePostIncidentReview", - "system/IncidentResponsePolicy:retentionDays", - "system/IncidentResponsePolicy:triageDeadlineHours", + "system/IncidentResponsePolicy:retentionDays [RETIRED]", + "system/IncidentResponsePolicy:triageDeadlineHours [RETIRED]", "system/IntervalSchedule:intervalMs", "system/IntervalSchedule:type", "system/Job:_lock", @@ -1305,19 +1305,19 @@ "system/TracingConfig:traceIdGenerator", "system/TrainingCourse:category", "system/TrainingCourse:description", - "system/TrainingCourse:durationMinutes", + "system/TrainingCourse:durationMinutes [RETIRED]", "system/TrainingCourse:id", "system/TrainingCourse:mandatory", "system/TrainingCourse:passingScore", "system/TrainingCourse:targetRoles", "system/TrainingCourse:title", - "system/TrainingCourse:validityDays", + "system/TrainingCourse:validityDays [RETIRED]", "system/TrainingCourse:version", "system/TrainingPlan:courses", "system/TrainingPlan:enabled", - "system/TrainingPlan:gracePeriodDays", - "system/TrainingPlan:recertificationIntervalDays", - "system/TrainingPlan:reminderDaysBefore", + "system/TrainingPlan:gracePeriodDays [RETIRED]", + "system/TrainingPlan:recertificationIntervalDays [RETIRED]", + "system/TrainingPlan:reminderDaysBefore [RETIRED]", "system/TrainingPlan:sendReminders", "system/TrainingPlan:trackCompletion", "system/TrainingRecord:assignedAt", diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__ChangeImpact__downtime.durationMinutes.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__ChangeImpact__downtime.durationMinutes.ts new file mode 100644 index 0000000000..1fed3669ee --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__ChangeImpact__downtime.durationMinutes.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription 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, so no `[RETIRED]` row exists for it 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 +// `kernel/Manifest:contributes.actions` and +// `api/BatchEndpointsConfig:operations.upsertMany` are. +// D3 semantic entry: `change-management-duration-keys-retired`. +export const entry = 'system/ChangeImpact:downtime.durationMinutes'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__ChangeRequest__implementation.steps.estimatedMinutes.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__ChangeRequest__implementation.steps.estimatedMinutes.ts new file mode 100644 index 0000000000..dc3da3b424 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__ChangeRequest__implementation.steps.estimatedMinutes.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription 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, so no `[RETIRED]` row exists for it 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 +// `kernel/Manifest:contributes.actions` and +// `api/BatchEndpointsConfig:operations.upsertMany` are. +// D3 semantic entry: `change-management-duration-keys-retired`. +export const entry = 'system/ChangeRequest:implementation.steps.estimatedMinutes'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentNotificationMatrix__escalationTimeoutMinutes.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentNotificationMatrix__escalationTimeoutMinutes.ts new file mode 100644 index 0000000000..eff22f2664 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentNotificationMatrix__escalationTimeoutMinutes.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `incident-response-deadline-keys-retired`. +export const entry = 'system/IncidentNotificationMatrix:escalationTimeoutMinutes'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentNotificationRule__regulatorDeadlineHours.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentNotificationRule__regulatorDeadlineHours.ts new file mode 100644 index 0000000000..4b60f363e6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentNotificationRule__regulatorDeadlineHours.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `incident-response-deadline-keys-retired`. +export const entry = 'system/IncidentNotificationRule:regulatorDeadlineHours'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentNotificationRule__withinMinutes.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentNotificationRule__withinMinutes.ts new file mode 100644 index 0000000000..147a442a12 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentNotificationRule__withinMinutes.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `incident-response-deadline-keys-retired`. +export const entry = 'system/IncidentNotificationRule:withinMinutes'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentResponsePhase__targetHours.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentResponsePhase__targetHours.ts new file mode 100644 index 0000000000..e66a688a19 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentResponsePhase__targetHours.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `incident-response-deadline-keys-retired`. +export const entry = 'system/IncidentResponsePhase:targetHours'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentResponsePolicy__retentionDays.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentResponsePolicy__retentionDays.ts new file mode 100644 index 0000000000..bc9054da15 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentResponsePolicy__retentionDays.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `incident-response-deadline-keys-retired`. +export const entry = 'system/IncidentResponsePolicy:retentionDays'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentResponsePolicy__triageDeadlineHours.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentResponsePolicy__triageDeadlineHours.ts new file mode 100644 index 0000000000..6a27946648 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__IncidentResponsePolicy__triageDeadlineHours.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `incident-response-deadline-keys-retired`. +export const entry = 'system/IncidentResponsePolicy:triageDeadlineHours'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__RollbackPlan__steps.estimatedMinutes.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__RollbackPlan__steps.estimatedMinutes.ts new file mode 100644 index 0000000000..1db46e1efd --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__RollbackPlan__steps.estimatedMinutes.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription 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, so no `[RETIRED]` row exists for it 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 +// `kernel/Manifest:contributes.actions` and +// `api/BatchEndpointsConfig:operations.upsertMany` are. +// D3 semantic entry: `change-management-duration-keys-retired`. +export const entry = 'system/RollbackPlan:steps.estimatedMinutes'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingCourse__durationMinutes.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingCourse__durationMinutes.ts new file mode 100644 index 0000000000..3a65bfaebd --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingCourse__durationMinutes.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `training-deadline-keys-retired`. +export const entry = 'system/TrainingCourse:durationMinutes'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingCourse__validityDays.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingCourse__validityDays.ts new file mode 100644 index 0000000000..6530b96ffe --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingCourse__validityDays.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `training-deadline-keys-retired`. +export const entry = 'system/TrainingCourse:validityDays'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingPlan__gracePeriodDays.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingPlan__gracePeriodDays.ts new file mode 100644 index 0000000000..00753c1066 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingPlan__gracePeriodDays.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `training-deadline-keys-retired`. +export const entry = 'system/TrainingPlan:gracePeriodDays'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingPlan__recertificationIntervalDays.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingPlan__recertificationIntervalDays.ts new file mode 100644 index 0000000000..4627b43f09 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingPlan__recertificationIntervalDays.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `training-deadline-keys-retired`. +export const entry = 'system/TrainingPlan:recertificationIntervalDays'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingPlan__reminderDaysBefore.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingPlan__reminderDaysBefore.ts new file mode 100644 index 0000000000..f99439578c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__TrainingPlan__reminderDaysBefore.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// published authorable surface, read by NOTHING — the schemas are mounted by +// no stack key and registered as no metadata type, and the reader census over +// every package outside `packages/spec` (and objectui at the pinned sha) +// returned zero hits — so an author who wrote it held a deadline the platform +// never kept. +// +// 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 none of these schemas is a stack collection +// member, so a MetadataConversion would be a transform with no seam that ever +// runs. The prescription reaches authors through the tombstone (`tsc` + the +// parse) and the D3 semantic entry named below. +// D3 semantic entry: `training-deadline-keys-retired`. +export const entry = 'system/TrainingPlan:reminderDaysBefore'; diff --git a/packages/spec/src/migrations/entries/semantic/18.change-management-duration-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.change-management-duration-keys-retired.ts new file mode 100644 index 0000000000..f105431ee8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.change-management-duration-keys-retired.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'change-management-duration-keys-retired', + surface: + 'change-management duration keys: `ChangeImpact.downtime.durationMinutes`, ' + + '`RollbackPlan.steps[].estimatedMinutes`, ' + + '`ChangeRequest.implementation.steps[].estimatedMinutes`', + replacement: + 'nothing to re-declare — delete the keys. No change-management engine exists on the ' + + 'platform: nothing schedules a maintenance window, executes or times an implementation ' + + 'or rollback step, or compares an estimate with what happened, so there is no live ' + + 'mechanism to declare a duration to', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + + 'family). Three minute-shaped keys, at three nested sites, sat in the exported ' + + 'change-management schemas and in the generated reference docs — an author could write ' + + '`estimatedMinutes: 15` on a rollback step and reasonably expect it to feed a schedule — ' + + 'and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted ' + + 'by no stack key, registered as no metadata type, absent from the 2026-06 liveness ' + + 'ledgers, and the reader census over every package outside `packages/spec` (tests and ' + + 'changelogs excluded) and over objectui at the pinned sha returned zero hits for every ' + + 'key. All three sites are NESTED (`downtime.durationMinutes`, `steps[].estimatedMinutes` ' + + 'twice), so the authorable-surface ratchet — which walks top-level def properties — never ' + + 'listed them; their `RETIRED_KEYS_BY_MAJOR[18]` entries carry the nested spelling for the ' + + 'spec-changes / upgrade-guide projection. 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; none of these schemas is either, so a conversion would be ' + + 'a transform with no seam that ever runs (the ' + + '`kernel/MetadataPluginConfig:additionalTypes` precedent).', + acceptanceCriteria: + 'No `ChangeImpact.downtime` block carries `durationMinutes`, and no implementation or ' + + 'rollback step — in a `RollbackPlan` or inside a `ChangeRequest` — carries ' + + '`estimatedMinutes`. 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 the nested path of the key, e.g. `rollbackPlan.steps.0.estimatedMinutes`). ' + + '⚠️ 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.incident-response-deadline-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.incident-response-deadline-keys-retired.ts new file mode 100644 index 0000000000..67131a9c9a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.incident-response-deadline-keys-retired.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'incident-response-deadline-keys-retired', + surface: + 'incident-response deadline keys: `IncidentResponsePhase.targetHours`, ' + + '`IncidentNotificationRule.withinMinutes` / `regulatorDeadlineHours`, ' + + '`IncidentNotificationMatrix.escalationTimeoutMinutes`, ' + + '`IncidentResponsePolicy.triageDeadlineHours` / `retentionDays`', + replacement: + 'nothing to re-declare — delete the keys. No incident-response engine exists on the ' + + 'platform: nothing tracks a phase against a clock, sends or times an incident ' + + 'notification, notifies a regulator, walks the escalation chain on a timer or sweeps ' + + 'incident records on a schedule, so there is no live mechanism to declare a deadline ' + + 'to. Retention of stored records is the object-level `lifecycle` block (ADR-0057), ' + + 'declared on the object that stores the records and enforced by the LifecycleService — ' + + 'not a number on this policy document', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + + 'family). Six hour/minute/day-shaped keys sat on the published authorable surface and ' + + 'in the generated reference docs — an author could write `triageDeadlineHours: 4` and ' + + 'reasonably expect the platform to escalate after four hours — and read by NOTHING: ' + + 'the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, ' + + 'registered as no metadata type, absent from the 2026-06 liveness ledgers, and the ' + + 'reader census over every package outside `packages/spec` (tests and changelogs ' + + 'excluded) and over objectui at the pinned sha returned zero hits for every key. Three ' + + 'of the six carried defaults (30 minutes, 1 hour, 2555 days) that were materialized ' + + 'into every parsed document without ever being consulted. A compliance-shaped deadline ' + + 'that fails silently is the worst form of the declared-but-unenforced shape ADR-0049 ' + + 'names; tagging it `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not ' + + 'take. 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; none ' + + 'of these schemas is either, so a conversion would be a transform with no seam that ' + + 'ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent).', + acceptanceCriteria: + 'No `IncidentResponsePhase`, `IncidentNotificationRule`, `IncidentNotificationMatrix` or ' + + '`IncidentResponsePolicy` literal — standalone or nested in an `Incident` — carries ' + + '`targetHours`, `withinMinutes`, `regulatorDeadlineHours`, `escalationTimeoutMinutes`, ' + + '`triageDeadlineHours` or `retentionDays`. 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 the path of the key). Parsed documents no longer carry ' + + 'the three former defaults. ⚠️ 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.training-deadline-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.training-deadline-keys-retired.ts new file mode 100644 index 0000000000..f6af95589b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.training-deadline-keys-retired.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'training-deadline-keys-retired', + surface: + 'training duration and deadline keys: `TrainingCourse.durationMinutes` / `validityDays`, ' + + '`TrainingPlan.recertificationIntervalDays` / `gracePeriodDays` / `reminderDaysBefore`', + replacement: + 'nothing to re-declare — delete the keys. No training-management engine exists on the ' + + 'platform: nothing schedules or times a course, computes a certification expiry, ' + + 're-assigns training on an interval, escalates an expired certification or sends a ' + + 'reminder, so there is no live mechanism to declare a duration or deadline to', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + + 'family). Five minute/day-shaped keys sat on the published authorable surface and in the ' + + 'generated reference docs — an author could write `validityDays: 365` and reasonably ' + + 'expect a certificate to expire — and read by NOTHING: the schemas are exported from ' + + '`@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, ' + + 'absent from the 2026-06 liveness ledgers, and the reader census over every package ' + + 'outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned ' + + 'sha returned zero hits for every key. Three of the five carried defaults (365, 30 and 14 ' + + 'days) that were materialized into every parsed plan without ever being consulted. 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; none of ' + + 'these schemas is either, so a conversion would be a transform with no seam that ever ' + + 'runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent).', + acceptanceCriteria: + 'No `TrainingCourse` or `TrainingPlan` literal — standalone or as a `courses[]` entry — ' + + 'carries `durationMinutes`, `validityDays`, `recertificationIntervalDays`, ' + + '`gracePeriodDays` or `reminderDaysBefore`. 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 the path of the key). Parsed plans no longer carry the ' + + 'three former defaults. ⚠️ 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/registry.ts b/packages/spec/src/migrations/registry.ts index 84751b0d9a..d70701822d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5328,7 +5328,17 @@ const step18: MigrationStep = { 'the three defs — `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` and the ' + 'orphaned `integration/ConnectorErrorCategory` enum — leave via RETIRED_DEFS_BY_MAJOR, and ' + 'the mechanical conversion strips the block from `connectors[]` (pure lossless delete; ' + - 'it never had an effect to lose).', + 'it never had an effect to lose). ' + + 'It also retires the fourteen hour/minute/day-shaped deadline keys of the ' + + 'incident-response, training and change-management families (#14477, ADR-0049 ' + + 'enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response ' + + 'schemas, five on the training schemas and three nested in the change-management ' + + 'schemas, every one on the published surface and read by nothing — the schemas are ' + + 'mounted by no stack key and registered as no metadata type — so a compliance author ' + + 'who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All ' + + 'fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion ' + + 'would be a silent strip) with no D2 conversion, for the additionalTypes reason: ' + + 'none of these schemas is a stack collection member, so the chain has no seam.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5721,6 +5731,43 @@ const step18: MigrationStep = { + 'reference under sharingModel: controlled_by_parent`. Stored metadata keeps loading ' + 'byte-identically (`safeParse` green, `required` unrewritten).', }, + { + id: 'change-management-duration-keys-retired', + surface: + 'change-management duration keys: `ChangeImpact.downtime.durationMinutes`, ' + + '`RollbackPlan.steps[].estimatedMinutes`, ' + + '`ChangeRequest.implementation.steps[].estimatedMinutes`', + replacement: + 'nothing to re-declare — delete the keys. No change-management engine exists on the ' + + 'platform: nothing schedules a maintenance window, executes or times an implementation ' + + 'or rollback step, or compares an estimate with what happened, so there is no live ' + + 'mechanism to declare a duration to', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + + 'family). Three minute-shaped keys, at three nested sites, sat in the exported ' + + 'change-management schemas and in the generated reference docs — an author could write ' + + '`estimatedMinutes: 15` on a rollback step and reasonably expect it to feed a schedule — ' + + 'and read by NOTHING: the schemas are exported from `@objectstack/spec/system`, mounted ' + + 'by no stack key, registered as no metadata type, absent from the 2026-06 liveness ' + + 'ledgers, and the reader census over every package outside `packages/spec` (tests and ' + + 'changelogs excluded) and over objectui at the pinned sha returned zero hits for every ' + + 'key. All three sites are NESTED (`downtime.durationMinutes`, `steps[].estimatedMinutes` ' + + 'twice), so the authorable-surface ratchet — which walks top-level def properties — never ' + + 'listed them; their `RETIRED_KEYS_BY_MAJOR[18]` entries carry the nested spelling for the ' + + 'spec-changes / upgrade-guide projection. 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; none of these schemas is either, so a conversion would be ' + + 'a transform with no seam that ever runs (the ' + + '`kernel/MetadataPluginConfig:additionalTypes` precedent).', + acceptanceCriteria: + 'No `ChangeImpact.downtime` block carries `durationMinutes`, and no implementation or ' + + 'rollback step — in a `RollbackPlan` or inside a `ChangeRequest` — carries ' + + '`estimatedMinutes`. 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 the nested path of the key, e.g. `rollbackPlan.steps.0.estimatedMinutes`). ' + + '⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ' + + 'ever read the keys, so removing them removes no behaviour.', + }, { id: 'cli-command-contribution-retired', surface: @@ -7053,6 +7100,48 @@ const step18: MigrationStep = { + 'read the schema, so removing it removes no behaviour — mint and verify ' + 'work byte-identically before and after.', }, + { + id: 'incident-response-deadline-keys-retired', + surface: + 'incident-response deadline keys: `IncidentResponsePhase.targetHours`, ' + + '`IncidentNotificationRule.withinMinutes` / `regulatorDeadlineHours`, ' + + '`IncidentNotificationMatrix.escalationTimeoutMinutes`, ' + + '`IncidentResponsePolicy.triageDeadlineHours` / `retentionDays`', + replacement: + 'nothing to re-declare — delete the keys. No incident-response engine exists on the ' + + 'platform: nothing tracks a phase against a clock, sends or times an incident ' + + 'notification, notifies a regulator, walks the escalation chain on a timer or sweeps ' + + 'incident records on a schedule, so there is no live mechanism to declare a deadline ' + + 'to. Retention of stored records is the object-level `lifecycle` block (ADR-0057), ' + + 'declared on the object that stores the records and enforced by the LifecycleService — ' + + 'not a number on this policy document', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + + 'family). Six hour/minute/day-shaped keys sat on the published authorable surface and ' + + 'in the generated reference docs — an author could write `triageDeadlineHours: 4` and ' + + 'reasonably expect the platform to escalate after four hours — and read by NOTHING: ' + + 'the schemas are exported from `@objectstack/spec/system`, mounted by no stack key, ' + + 'registered as no metadata type, absent from the 2026-06 liveness ledgers, and the ' + + 'reader census over every package outside `packages/spec` (tests and changelogs ' + + 'excluded) and over objectui at the pinned sha returned zero hits for every key. Three ' + + 'of the six carried defaults (30 minutes, 1 hour, 2555 days) that were materialized ' + + 'into every parsed document without ever being consulted. A compliance-shaped deadline ' + + 'that fails silently is the worst form of the declared-but-unenforced shape ADR-0049 ' + + 'names; tagging it `[EXPERIMENTAL — not enforced]` was the fallback the ruling did not ' + + 'take. 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; none ' + + 'of these schemas is either, so a conversion would be a transform with no seam that ' + + 'ever runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent).', + acceptanceCriteria: + 'No `IncidentResponsePhase`, `IncidentNotificationRule`, `IncidentNotificationMatrix` or ' + + '`IncidentResponsePolicy` literal — standalone or nested in an `Incident` — carries ' + + '`targetHours`, `withinMinutes`, `regulatorDeadlineHours`, `escalationTimeoutMinutes`, ' + + '`triageDeadlineHours` or `retentionDays`. 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 the path of the key). Parsed documents no longer carry ' + + 'the three former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be ' + + 'verified as such: nothing ever read the keys, so removing them removes no behaviour.', + }, { id: 'kernel-context-preview-mode-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a @@ -8174,6 +8263,39 @@ const step18: MigrationStep = { + 'parse-and-refuse accepts and rejects exactly the same sets before and after, ' + 'and no stored metadata or document needs editing.', }, + { + id: 'training-deadline-keys-retired', + surface: + 'training duration and deadline keys: `TrainingCourse.durationMinutes` / `validityDays`, ' + + '`TrainingPlan.recertificationIntervalDays` / `gracePeriodDays` / `reminderDaysBefore`', + replacement: + 'nothing to re-declare — delete the keys. No training-management engine exists on the ' + + 'platform: nothing schedules or times a course, computes a certification expiry, ' + + 're-assigns training on an interval, escalates an expired certification or sends a ' + + 'reminder, so there is no live mechanism to declare a duration or deadline to', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on #14477 (ruled A: retire per ' + + 'family). Five minute/day-shaped keys sat on the published authorable surface and in the ' + + 'generated reference docs — an author could write `validityDays: 365` and reasonably ' + + 'expect a certificate to expire — and read by NOTHING: the schemas are exported from ' + + '`@objectstack/spec/system`, mounted by no stack key, registered as no metadata type, ' + + 'absent from the 2026-06 liveness ledgers, and the reader census over every package ' + + 'outside `packages/spec` (tests and changelogs excluded) and over objectui at the pinned ' + + 'sha returned zero hits for every key. Three of the five carried defaults (365, 30 and 14 ' + + 'days) that were materialized into every parsed plan without ever being consulted. 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; none of ' + + 'these schemas is either, so a conversion would be a transform with no seam that ever ' + + 'runs (the `kernel/MetadataPluginConfig:additionalTypes` precedent).', + acceptanceCriteria: + 'No `TrainingCourse` or `TrainingPlan` literal — standalone or as a `courses[]` entry — ' + + 'carries `durationMinutes`, `validityDays`, `recertificationIntervalDays`, ' + + '`gracePeriodDays` or `reminderDaysBefore`. 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 the path of the key). Parsed plans no longer carry the ' + + 'three former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be ' + + 'verified as such: nothing ever read the keys, so removing them removes no behaviour.', + }, { id: 'ui-cloud-connection-widgets-unknown-keys-refused', surface: 'page `cloud-connection:panel` / `marketplace:installed-list` components — ' @@ -9782,6 +9904,321 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // D2 conversion `permission-allow-restore-purge-removed`, which strips the // key from every object grant in `permissions[].objects`. 'security/ObjectPermission:allowRestore', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription 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, so no `[RETIRED]` row exists for it 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 + // `kernel/Manifest:contributes.actions` and + // `api/BatchEndpointsConfig:operations.upsertMany` are. + // D3 semantic entry: `change-management-duration-keys-retired`. + 'system/ChangeImpact:downtime.durationMinutes', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription 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, so no `[RETIRED]` row exists for it 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 + // `kernel/Manifest:contributes.actions` and + // `api/BatchEndpointsConfig:operations.upsertMany` are. + // D3 semantic entry: `change-management-duration-keys-retired`. + 'system/ChangeRequest:implementation.steps.estimatedMinutes', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `incident-response-deadline-keys-retired`. + 'system/IncidentNotificationMatrix:escalationTimeoutMinutes', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `incident-response-deadline-keys-retired`. + 'system/IncidentNotificationRule:regulatorDeadlineHours', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `incident-response-deadline-keys-retired`. + 'system/IncidentNotificationRule:withinMinutes', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `incident-response-deadline-keys-retired`. + 'system/IncidentResponsePhase:targetHours', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `incident-response-deadline-keys-retired`. + 'system/IncidentResponsePolicy:retentionDays', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `incident-response-deadline-keys-retired`. + 'system/IncidentResponsePolicy:triageDeadlineHours', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription 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, so no `[RETIRED]` row exists for it 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 + // `kernel/Manifest:contributes.actions` and + // `api/BatchEndpointsConfig:operations.upsertMany` are. + // D3 semantic entry: `change-management-duration-keys-retired`. + 'system/RollbackPlan:steps.estimatedMinutes', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `training-deadline-keys-retired`. + 'system/TrainingCourse:durationMinutes', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `training-deadline-keys-retired`. + 'system/TrainingCourse:validityDays', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `training-deadline-keys-retired`. + 'system/TrainingPlan:gracePeriodDays', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `training-deadline-keys-retired`. + 'system/TrainingPlan:recertificationIntervalDays', + // #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 + // published authorable surface, read by NOTHING — the schemas are mounted by + // no stack key and registered as no metadata type, and the reader census over + // every package outside `packages/spec` (and objectui at the pinned sha) + // returned zero hits — so an author who wrote it held a deadline the platform + // never kept. + // + // 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 none of these schemas is a stack collection + // member, so a MetadataConversion would be a transform with no seam that ever + // runs. The prescription reaches authors through the tombstone (`tsc` + the + // parse) and the D3 semantic entry named below. + // D3 semantic entry: `training-deadline-keys-retired`. + 'system/TrainingPlan:reminderDaysBefore', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its // renderers/basic/elements.tsx header deferred the element to "owning plugins" diff --git a/packages/spec/src/system/change-management.test.ts b/packages/spec/src/system/change-management.test.ts index fde346c7ee..638a476868 100644 --- a/packages/spec/src/system/change-management.test.ts +++ b/packages/spec/src/system/change-management.test.ts @@ -72,7 +72,6 @@ describe('ChangeImpactSchema', () => { affectedUsers: 5000, downtime: { required: true, - durationMinutes: 30, }, }; @@ -131,17 +130,14 @@ describe('RollbackPlanSchema', () => { { order: 1, description: 'Stop application servers', - estimatedMinutes: 5, }, { order: 2, description: 'Restore database backup', - estimatedMinutes: 15, }, { order: 3, description: 'Restart application servers', - estimatedMinutes: 5, }, ], testProcedure: 'Verify application login and basic functionality', @@ -157,7 +153,6 @@ describe('RollbackPlanSchema', () => { { order: 1, description: 'Revert changes', - estimatedMinutes: 10, }, ], }; @@ -172,22 +167,18 @@ describe('RollbackPlanSchema', () => { { order: 1, description: 'Step 1', - estimatedMinutes: 5, }, { order: 2, description: 'Step 2', - estimatedMinutes: 10, }, { order: 3, description: 'Step 3', - estimatedMinutes: 15, }, { order: 4, description: 'Step 4', - estimatedMinutes: 20, }, ], }; @@ -213,7 +204,6 @@ describe('ChangeRequestSchema', () => { affectedUsers: 5000, downtime: { required: true, - durationMinutes: 30, }, }, implementation: { @@ -222,12 +212,10 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Backup current database', - estimatedMinutes: 10, }, { order: 2, description: 'Run migration scripts', - estimatedMinutes: 15, }, ], testing: 'Run integration test suite', @@ -238,7 +226,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Restore backup', - estimatedMinutes: 15, }, ], }, @@ -271,7 +258,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Execute change', - estimatedMinutes: 5, }, ], }, @@ -281,7 +267,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Revert', - estimatedMinutes: 5, }, ], }, @@ -310,7 +295,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Execute', - estimatedMinutes: 10, }, ], }, @@ -320,7 +304,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Revert', - estimatedMinutes: 5, }, ], }, @@ -345,7 +328,6 @@ describe('ChangeRequestSchema', () => { affectedUsers: 50000, downtime: { required: true, - durationMinutes: 15, }, }, implementation: { @@ -354,7 +336,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Deploy patch', - estimatedMinutes: 10, }, ], }, @@ -364,7 +345,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Rollback', - estimatedMinutes: 5, }, ], }, @@ -389,7 +369,6 @@ describe('ChangeRequestSchema', () => { affectedUsers: 100000, downtime: { required: true, - durationMinutes: 120, }, }, implementation: { @@ -398,12 +377,10 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Phase 1: Database upgrade', - estimatedMinutes: 30, }, { order: 2, description: 'Phase 2: Application upgrade', - estimatedMinutes: 45, }, ], testing: 'Comprehensive integration testing', @@ -414,7 +391,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Restore infrastructure snapshot', - estimatedMinutes: 60, }, ], }, @@ -456,7 +432,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Update', - estimatedMinutes: 20, }, ], }, @@ -466,7 +441,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Revert', - estimatedMinutes: 10, }, ], }, @@ -501,7 +475,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Deploy', - estimatedMinutes: 15, }, ], }, @@ -511,7 +484,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Revert', - estimatedMinutes: 10, }, ], }, @@ -550,7 +522,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Test', - estimatedMinutes: 5, }, ], }, @@ -560,7 +531,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Test', - estimatedMinutes: 5, }, ], }, @@ -595,7 +565,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Update schema', - estimatedMinutes: 20, }, ], }, @@ -605,7 +574,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Restore', - estimatedMinutes: 15, }, ], }, @@ -634,7 +602,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Deploy', - estimatedMinutes: 15, }, ], }, @@ -644,7 +611,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Rollback', - estimatedMinutes: 10, }, ], testProcedure: 'Verify app functionality', @@ -674,7 +640,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Deploy configuration', - estimatedMinutes: 10, }, ], }, @@ -684,7 +649,6 @@ describe('ChangeRequestSchema', () => { { order: 1, description: 'Restore previous config', - estimatedMinutes: 5, }, ], }, @@ -721,11 +685,11 @@ describe('ChangeRequestSchema', () => { }, implementation: { description: 'Update CSS', - steps: [{ order: 1, description: 'Deploy', estimatedMinutes: 5 }], + steps: [{ order: 1, description: 'Deploy' }], }, rollbackPlan: { description: 'Revert CSS', - steps: [{ order: 1, description: 'Revert', estimatedMinutes: 5 }], + steps: [{ order: 1, description: 'Revert' }], }, securityImpact: { assessed: true, @@ -754,11 +718,11 @@ describe('ChangeRequestSchema', () => { impact: { level: 'low', affectedSystems: ['test'] }, implementation: { description: 'Test', - steps: [{ order: 1, description: 'Test', estimatedMinutes: 5 }], + steps: [{ order: 1, description: 'Test' }], }, rollbackPlan: { description: 'Test', - steps: [{ order: 1, description: 'Test', estimatedMinutes: 5 }], + steps: [{ order: 1, description: 'Test' }], }, securityImpact: { assessed: true, diff --git a/packages/spec/src/system/change-management.zod.ts b/packages/spec/src/system/change-management.zod.ts index fb709abcf2..751fe8218d 100644 --- a/packages/spec/src/system/change-management.zod.ts +++ b/packages/spec/src/system/change-management.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { DataClassificationSchema } from './security-context.zod'; +import { retiredKey } from '../shared/retired-key'; /** * Change Type Enum @@ -17,6 +18,48 @@ export const ChangeTypeSchema = lazySchema(() => z.enum([ 'major', // Requires CAB (Change Advisory Board) approval ])); +// ─── RETIRED duration keys (ADR-0049 enforce-or-remove) ───────────────────── +// +// Three minute-shaped duration keys were declared on the change-management +// schemas and read by NOTHING: no change-management engine scheduled a +// maintenance window, executed or timed an implementation or rollback step, +// or compared an estimate with what happened — the schemas are exported, +// mounted by no stack key and registered as no metadata type, and the reader +// census over every package outside `packages/spec` (and over objectui at the +// pinned sha) returned zero hits for every key. Maintainer ruling 2026-09-02 +// (recorded on #14477): retire the family under enforce-or-remove. +// +// Route: `retiredKey()` tombstones, NOT plain deletion (the schemas are not +// `.strict()`; a bare deletion would be a silent strip, ADR-0104). The three +// sites are nested (`downtime.durationMinutes`, `steps[].estimatedMinutes` +// twice), so the authorable-surface ratchet — which walks top-level def +// properties — never listed them; they are registered by their nested +// spelling in `RETIRED_KEYS_BY_MAJOR[18]` for the spec-changes / upgrade-guide +// projection, the `kernel/Manifest:contributes.actions` precedent. No D2 +// conversion and no `os migrate meta` sentence: none of these schemas is a +// stack collection member (the `kernel/MetadataPluginConfig:additionalTypes` +// precedent); the D3 semantic entry is `change-management-duration-keys-retired`. + +const DOWNTIME_DURATION_MINUTES_RETIRED = + '`ChangeImpact.downtime.durationMinutes` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no engine scheduled a maintenance window or ' + + 'measured an outage against it, so the declared downtime was never enforced, announced or ' + + 'compared with what happened. Delete the key. There is no replacement, because no ' + + 'change-management engine exists.'; + +const ROLLBACK_STEP_ESTIMATED_MINUTES_RETIRED = + '`RollbackPlan.steps[].estimatedMinutes` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no engine executed, timed or summed rollback ' + + 'steps, so the estimate was never compared with anything. Delete the key from every step. ' + + 'There is no replacement, because no change-management engine exists.'; + +const IMPLEMENTATION_STEP_ESTIMATED_MINUTES_RETIRED = + '`ChangeRequest.implementation.steps[].estimatedMinutes` was removed in @objectstack/spec 17 ' + + '(ADR-0049 enforce-or-remove) — nothing ever read it: no engine executed, timed or summed ' + + 'implementation steps, so the estimate never fed a schedule and was never compared with ' + + '`schedule.plannedStart` / `plannedEnd`. Delete the key from every step. There is no ' + + 'replacement, because no change-management engine exists.'; + /** * Change Priority Enum * @@ -60,8 +103,7 @@ export const ChangeStatusSchema = lazySchema(() => z.enum([ * "affectedSystems": ["crm-api", "customer-portal"], * "affectedUsers": 5000, * "downtime": { - * "required": true, - * "durationMinutes": 30 + * "required": true * } * } * ``` @@ -92,9 +134,9 @@ export const ChangeImpactSchema = lazySchema(() => z.object({ required: z.boolean().describe('Downtime required'), /** - * Duration of downtime in minutes + * REMOVED (ADR-0049 enforce-or-remove) — see `DOWNTIME_DURATION_MINUTES_RETIRED` above. */ - durationMinutes: z.number().optional().describe('Downtime duration'), + durationMinutes: retiredKey(DOWNTIME_DURATION_MINUTES_RETIRED), }).optional().describe('Downtime information'), })); @@ -111,13 +153,11 @@ export const ChangeImpactSchema = lazySchema(() => z.object({ * "steps": [ * { * "order": 1, - * "description": "Stop application servers", - * "estimatedMinutes": 5 + * "description": "Stop application servers" * }, * { * "order": 2, - * "description": "Restore database backup", - * "estimatedMinutes": 15 + * "description": "Restore database backup" * } * ], * "testProcedure": "Verify application login and basic functionality" @@ -145,9 +185,9 @@ export const RollbackPlanSchema = lazySchema(() => z.object({ description: z.string().describe('Step description'), /** - * Estimated time to complete this step + * REMOVED (ADR-0049 enforce-or-remove) — see `ROLLBACK_STEP_ESTIMATED_MINUTES_RETIRED` above. */ - estimatedMinutes: z.number().describe('Estimated duration'), + estimatedMinutes: retiredKey(ROLLBACK_STEP_ESTIMATED_MINUTES_RETIRED), })).describe('Rollback steps'), /** @@ -178,8 +218,7 @@ export const RollbackPlanSchema = lazySchema(() => z.object({ * "affectedSystems": ["crm-api", "customer-portal"], * "affectedUsers": 5000, * "downtime": { - * "required": true, - * "durationMinutes": 30 + * "required": true * } * }, * "implementation": { @@ -187,8 +226,7 @@ export const RollbackPlanSchema = lazySchema(() => z.object({ * "steps": [ * { * "order": 1, - * "description": "Backup current database", - * "estimatedMinutes": 10 + * "description": "Backup current database" * } * ], * "testing": "Run integration test suite" @@ -198,8 +236,7 @@ export const RollbackPlanSchema = lazySchema(() => z.object({ * "steps": [ * { * "order": 1, - * "description": "Restore backup", - * "estimatedMinutes": 15 + * "description": "Restore backup" * } * ] * }, @@ -280,9 +317,9 @@ export const ChangeRequestSchema = lazySchema(() => z.object({ description: z.string().describe('Step description'), /** - * Estimated time to complete this step + * REMOVED (ADR-0049 enforce-or-remove) — see `IMPLEMENTATION_STEP_ESTIMATED_MINUTES_RETIRED` above. */ - estimatedMinutes: z.number().describe('Estimated duration'), + estimatedMinutes: retiredKey(IMPLEMENTATION_STEP_ESTIMATED_MINUTES_RETIRED), })).describe('Implementation steps'), /** diff --git a/packages/spec/src/system/deadline-keys-retirement.test.ts b/packages/spec/src/system/deadline-keys-retirement.test.ts new file mode 100644 index 0000000000..970f997046 --- /dev/null +++ b/packages/spec/src/system/deadline-keys-retirement.test.ts @@ -0,0 +1,677 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import type { ZodTypeAny } from 'zod'; + +import { + IncidentNotificationMatrixSchema, + IncidentNotificationRuleSchema, + IncidentResponsePhaseSchema, + IncidentResponsePolicySchema, + IncidentSchema, + type IncidentNotificationRule, + type IncidentResponsePhase, + type IncidentResponsePolicy, +} from './incident-response.zod'; +import { + TrainingCourseSchema, + TrainingPlanSchema, + type TrainingCourse, + type TrainingPlan, +} from './training.zod'; +import { + ChangeImpactSchema, + ChangeRequestSchema, + RollbackPlanSchema, + type ChangeImpact, + type ChangeRequest, + type RollbackPlan, +} from './change-management.zod'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; + +// ─── [#14477] the fourteen inert deadline keys are REMOVED ────────────────── +// +// ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02, ruled A (retire +// per family). Fourteen hour/minute/day-shaped deadline, SLA and duration keys +// on the incident-response, training and change-management schemas sat on the +// published authorable surface and in the generated reference docs, and were +// read by NOTHING: the schemas are exported, mounted by no stack key, +// registered as no metadata type, absent from the 2026-06 liveness ledgers, +// and the reader census over every package outside `packages/spec` (tests and +// changelogs excluded) and over objectui at the pinned sha returned zero hits +// for every key. Six carried defaults that were materialized into every parsed +// document without ever being consulted. +// +// Route: `retiredKey()` tombstones, NOT plain deletion — none of the schemas +// is `.strict()`, so a bare deletion would make zod strip the key in silence, +// replacing an inert declaration with an invisible one (ADR-0104). Audible in +// two channels: `tsc` (the input type is `never`) and the parse (the +// prescription is the message). No D2 conversion: none of the schemas is a +// stack collection member, so the chain has no seam that ever runs (the +// `kernel/MetadataPluginConfig:additionalTypes` precedent) — the registration +// is fourteen `RETIRED_KEYS_BY_MAJOR[18]` entries plus three D3 semantic +// entries, one per family. +// +// On the assertion set (the #8586 / #14676 precedent): a schema refusal raises +// a `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status` — +// that envelope belongs to the API error surface. So these pins assert the +// strongest set this surface really has: refusal, the issue `code`, the `path` +// naming WHICH site refused, and the prescription text (#5240: where the +// wording is the contract, pin the wording). + +// ── Well-formed fixtures: every required key, none of the retired ones ────── + +const PHASE: IncidentResponsePhase = { + phase: 'containment', + description: 'Isolate affected systems', + assignedTo: 'security_team', +}; +const RULE: IncidentNotificationRule = { + severity: 'critical', + channels: ['pagerduty'], + recipients: ['security_team'], +}; +const MATRIX = { rules: [RULE] }; +const POLICY: IncidentResponsePolicy = { + notificationMatrix: MATRIX, + defaultResponseTeam: 'security_team', +}; +const INCIDENT = { + id: 'INC-2024-001', + title: 'Unauthorized API Access Detected', + description: 'Multiple failed authentication attempts from an unknown IP range', + severity: 'high', + category: 'unauthorized_access', + status: 'investigating', + reportedBy: 'monitoring_system', + reportedAt: 1704067200000, + affectedSystems: ['api-gateway'], +}; +const COURSE: TrainingCourse = { + id: 'COURSE-SEC-001', + title: 'Information Security Fundamentals', + description: 'Annual security awareness training for all employees', + category: 'security_awareness', + targetRoles: ['all_employees'], +}; +const PLAN: TrainingPlan = { courses: [COURSE] }; +const STEP = { order: 1, description: 'Restore database backup' }; +const IMPACT: ChangeImpact = { + level: 'high', + affectedSystems: ['crm-api'], + downtime: { required: true }, +}; +const ROLLBACK: RollbackPlan = { description: 'Restore from backup', steps: [STEP] }; +const CHANGE: ChangeRequest = { + id: 'CHG-2024-001', + title: 'Upgrade CRM Database Schema', + description: 'Migrate the customer database to schema version 2.0', + type: 'normal', + priority: 'high', + status: 'approved', + requestedBy: 'user_123', + requestedAt: 1704067200000, + impact: IMPACT, + implementation: { description: 'Execute the migration scripts', steps: [STEP] }, + rollbackPlan: ROLLBACK, +}; + +/** One retired declaration site, as the schema, the registry and the prescription each spell it. */ +interface RetiredSite { + /** The exact `RETIRED_KEYS_BY_MAJOR` spelling. */ + registered: string; + /** How the prescription opens (its backtick-wrapped qualified key). */ + qualified: string; + schema: ZodTypeAny; + /** Parses green as-is. */ + wellFormed: unknown; + /** The same document with the retired key authored. */ + authored: unknown; + /** Where the refusal must surface. */ + issuePath: (string | number)[]; +} + +const SITES: RetiredSite[] = [ + { + registered: 'system/IncidentResponsePhase:targetHours', + qualified: 'IncidentResponsePhase.targetHours', + schema: IncidentResponsePhaseSchema, + wellFormed: PHASE, + authored: { ...PHASE, targetHours: 2 }, + issuePath: ['targetHours'], + }, + { + registered: 'system/IncidentNotificationRule:withinMinutes', + qualified: 'IncidentNotificationRule.withinMinutes', + schema: IncidentNotificationRuleSchema, + wellFormed: RULE, + authored: { ...RULE, withinMinutes: 15 }, + issuePath: ['withinMinutes'], + }, + { + registered: 'system/IncidentNotificationRule:regulatorDeadlineHours', + qualified: 'IncidentNotificationRule.regulatorDeadlineHours', + schema: IncidentNotificationRuleSchema, + wellFormed: RULE, + authored: { ...RULE, notifyRegulators: true, regulatorDeadlineHours: 72 }, + issuePath: ['regulatorDeadlineHours'], + }, + { + registered: 'system/IncidentNotificationMatrix:escalationTimeoutMinutes', + qualified: 'IncidentNotificationMatrix.escalationTimeoutMinutes', + schema: IncidentNotificationMatrixSchema, + wellFormed: MATRIX, + authored: { ...MATRIX, escalationTimeoutMinutes: 30 }, + issuePath: ['escalationTimeoutMinutes'], + }, + { + registered: 'system/IncidentResponsePolicy:triageDeadlineHours', + qualified: 'IncidentResponsePolicy.triageDeadlineHours', + schema: IncidentResponsePolicySchema, + wellFormed: POLICY, + authored: { ...POLICY, triageDeadlineHours: 1 }, + issuePath: ['triageDeadlineHours'], + }, + { + registered: 'system/IncidentResponsePolicy:retentionDays', + qualified: 'IncidentResponsePolicy.retentionDays', + schema: IncidentResponsePolicySchema, + wellFormed: POLICY, + authored: { ...POLICY, retentionDays: 2555 }, + issuePath: ['retentionDays'], + }, + { + registered: 'system/TrainingCourse:durationMinutes', + qualified: 'TrainingCourse.durationMinutes', + schema: TrainingCourseSchema, + wellFormed: COURSE, + authored: { ...COURSE, durationMinutes: 60 }, + issuePath: ['durationMinutes'], + }, + { + registered: 'system/TrainingCourse:validityDays', + qualified: 'TrainingCourse.validityDays', + schema: TrainingCourseSchema, + wellFormed: COURSE, + authored: { ...COURSE, validityDays: 365 }, + issuePath: ['validityDays'], + }, + { + registered: 'system/TrainingPlan:recertificationIntervalDays', + qualified: 'TrainingPlan.recertificationIntervalDays', + schema: TrainingPlanSchema, + wellFormed: PLAN, + authored: { ...PLAN, recertificationIntervalDays: 365 }, + issuePath: ['recertificationIntervalDays'], + }, + { + registered: 'system/TrainingPlan:gracePeriodDays', + qualified: 'TrainingPlan.gracePeriodDays', + schema: TrainingPlanSchema, + wellFormed: PLAN, + authored: { ...PLAN, gracePeriodDays: 30 }, + issuePath: ['gracePeriodDays'], + }, + { + registered: 'system/TrainingPlan:reminderDaysBefore', + qualified: 'TrainingPlan.reminderDaysBefore', + schema: TrainingPlanSchema, + wellFormed: PLAN, + authored: { ...PLAN, reminderDaysBefore: 14 }, + issuePath: ['reminderDaysBefore'], + }, + { + registered: 'system/ChangeImpact:downtime.durationMinutes', + qualified: 'ChangeImpact.downtime.durationMinutes', + schema: ChangeImpactSchema, + wellFormed: IMPACT, + authored: { ...IMPACT, downtime: { required: true, durationMinutes: 30 } }, + issuePath: ['downtime', 'durationMinutes'], + }, + { + registered: 'system/RollbackPlan:steps.estimatedMinutes', + qualified: 'RollbackPlan.steps[].estimatedMinutes', + schema: RollbackPlanSchema, + wellFormed: ROLLBACK, + authored: { ...ROLLBACK, steps: [{ ...STEP, estimatedMinutes: 15 }] }, + issuePath: ['steps', 0, 'estimatedMinutes'], + }, + { + registered: 'system/ChangeRequest:implementation.steps.estimatedMinutes', + qualified: 'ChangeRequest.implementation.steps[].estimatedMinutes', + schema: ChangeRequestSchema, + wellFormed: CHANGE, + authored: { + ...CHANGE, + implementation: { description: 'Execute the migration scripts', steps: [{ ...STEP, estimatedMinutes: 10 }] }, + }, + issuePath: ['implementation', 'steps', 0, 'estimatedMinutes'], + }, +]; + +/** The carriers that nest a retired site: the refusal must travel through them. */ +const CARRIERS: Array> = [ + { + qualified: 'IncidentResponsePhase.targetHours', + schema: IncidentSchema, + wellFormed: { ...INCIDENT, responsePhases: [PHASE] }, + authored: { ...INCIDENT, responsePhases: [{ ...PHASE, targetHours: 2 }] }, + issuePath: ['responsePhases', 0, 'targetHours'], + }, + { + qualified: 'IncidentNotificationRule.withinMinutes', + schema: IncidentResponsePolicySchema, + wellFormed: POLICY, + authored: { ...POLICY, notificationMatrix: { rules: [{ ...RULE, withinMinutes: 15 }] } }, + issuePath: ['notificationMatrix', 'rules', 0, 'withinMinutes'], + }, + { + qualified: 'IncidentNotificationMatrix.escalationTimeoutMinutes', + schema: IncidentResponsePolicySchema, + wellFormed: POLICY, + authored: { ...POLICY, notificationMatrix: { ...MATRIX, escalationTimeoutMinutes: 45 } }, + issuePath: ['notificationMatrix', 'escalationTimeoutMinutes'], + }, + { + qualified: 'TrainingCourse.validityDays', + schema: TrainingPlanSchema, + wellFormed: PLAN, + authored: { courses: [{ ...COURSE, validityDays: 365 }] }, + issuePath: ['courses', 0, 'validityDays'], + }, + { + qualified: 'ChangeImpact.downtime.durationMinutes', + schema: ChangeRequestSchema, + wellFormed: CHANGE, + authored: { ...CHANGE, impact: { ...IMPACT, downtime: { required: true, durationMinutes: 30 } } }, + issuePath: ['impact', 'downtime', 'durationMinutes'], + }, + { + qualified: 'RollbackPlan.steps[].estimatedMinutes', + schema: ChangeRequestSchema, + wellFormed: CHANGE, + authored: { ...CHANGE, rollbackPlan: { ...ROLLBACK, steps: [{ ...STEP, estimatedMinutes: 15 }] } }, + issuePath: ['rollbackPlan', 'steps', 0, 'estimatedMinutes'], + }, +]; + +const SEMANTIC_IDS = [ + 'incident-response-deadline-keys-retired', + 'training-deadline-keys-retired', + 'change-management-duration-keys-retired', +] as const; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function expectTombstoneRefusal(site: Pick) { + const result = site.schema.safeParse(site.authored); + expect(result.success, `${site.qualified} must be refused`).toBe(false); + if (result.success) return; // narrowing; the assertion above already failed + + const wanted = site.issuePath.join('.'); + const issue = result.error.issues.find((i) => i.path.join('.') === wanted); + expect(issue, `the refusal must surface at ${wanted}`).toBeDefined(); + // 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/); + // Customer-facing text carries the ADR, never an issue id — a `#NNNN` + // token resolves to nothing for the reader who meets this refusal. + expect(issue!.message).not.toMatch(/#\d{3,}/); + // Deliberately NO `os migrate meta` sentence: no conversion covers these + // schemas (not stack collection members), so the sentence would promise an + // edit list the tool cannot produce (`retired-key.ts`: the sentence must be + // TRUE of the tool). + expect(issue!.message).not.toMatch(/os migrate meta/); +} + +describe('[#14477] inert deadline keys 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); + // 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); + }); + } + + for (const carrier of CARRIERS) { + it(`REJECTS \`${carrier.qualified}\` through its carrier, at path \`${carrier.issuePath.join('.')}\``, () => { + expectTombstoneRefusal(carrier); + expect(carrier.schema.safeParse(carrier.wellFormed).success).toBe(true); + }); + } + + it('the `retentionDays` prescription names the live retention mechanism, so nobody re-declares a number here as a repair', () => { + const result = IncidentResponsePolicySchema.safeParse({ ...POLICY, retentionDays: 3650 }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.path[0] === 'retentionDays'); + expect(issue!.message).toMatch(/object-level `lifecycle` block \(ADR-0057\)/); + expect(issue!.message).toMatch(/LifecycleService/); + }); + + it('every prescription with a former default names the default it used to materialize', () => { + const expected: Array<[string, RegExp]> = [ + ['IncidentNotificationMatrix.escalationTimeoutMinutes', /default of 30 minutes/], + ['IncidentResponsePolicy.triageDeadlineHours', /default of 1 hour/], + ['IncidentResponsePolicy.retentionDays', /default of 2555 days/], + ['TrainingPlan.recertificationIntervalDays', /default of 365 days/], + ['TrainingPlan.gracePeriodDays', /default of 30 days/], + ['TrainingPlan.reminderDaysBefore', /default of 14 days/], + ]; + for (const [qualified, pattern] of expected) { + const site = SITES.find((s) => s.qualified === qualified)!; + const result = site.schema.safeParse(site.authored); + expect(result.success).toBe(false); + if (result.success) continue; + const issue = result.error.issues.find((i) => i.path.join('.') === site.issuePath.join('.'))!; + expect(issue.message, qualified).toMatch(pattern); + } + }); +}); + +describe('[#14477] no-materialize: parsed documents carry none of the keys and none of the six former defaults', () => { + it('incident-response', () => { + const rule = IncidentNotificationRuleSchema.parse(RULE); + expect(rule.notifyRegulators).toBe(false); // control: a live default still applies + expect(rule).not.toHaveProperty('withinMinutes'); + expect(rule).not.toHaveProperty('regulatorDeadlineHours'); + + const matrix = IncidentNotificationMatrixSchema.parse(MATRIX); + expect(matrix.escalationChain).toEqual([]); // control + // Used to materialize `30` into every parsed matrix. + expect(matrix).not.toHaveProperty('escalationTimeoutMinutes'); + + const policy = IncidentResponsePolicySchema.parse(POLICY); + expect(policy.enabled).toBe(true); // control + expect(policy.requirePostIncidentReview).toBe(true); // control + // Used to materialize `1` and `2555`. + expect(policy).not.toHaveProperty('triageDeadlineHours'); + expect(policy).not.toHaveProperty('retentionDays'); + + const phase = IncidentResponsePhaseSchema.parse(PHASE); + expect(phase.phase).toBe('containment'); + expect(phase).not.toHaveProperty('targetHours'); + }); + + it('training', () => { + const course = TrainingCourseSchema.parse(COURSE); + expect(course.mandatory).toBe(false); // control + expect(course).not.toHaveProperty('durationMinutes'); + expect(course).not.toHaveProperty('validityDays'); + + const plan = TrainingPlanSchema.parse(PLAN); + expect(plan.enabled).toBe(true); // control + expect(plan.sendReminders).toBe(true); // control + // Used to materialize `365`, `30` and `14`. + expect(plan).not.toHaveProperty('recertificationIntervalDays'); + expect(plan).not.toHaveProperty('gracePeriodDays'); + expect(plan).not.toHaveProperty('reminderDaysBefore'); + }); + + it('change-management', () => { + const impact = ChangeImpactSchema.parse(IMPACT); + expect(impact.downtime?.required).toBe(true); // control + expect(impact.downtime).not.toHaveProperty('durationMinutes'); + + const rollback = RollbackPlanSchema.parse(ROLLBACK); + expect(rollback.steps[0]?.order).toBe(1); // control + expect(rollback.steps[0]).not.toHaveProperty('estimatedMinutes'); + + const change = ChangeRequestSchema.parse(CHANGE); + expect(change.implementation.steps[0]?.description).toBe(STEP.description); // control + expect(change.implementation.steps[0]).not.toHaveProperty('estimatedMinutes'); + expect(change.rollbackPlan.steps[0]).not.toHaveProperty('estimatedMinutes'); + expect(change.impact.downtime).not.toHaveProperty('durationMinutes'); + }); +}); + +describe('[#14477] the tsc channel: the input type of every retired key is `never`', () => { + it('fails tsc at every authoring site', () => { + const phase: IncidentResponsePhase = { + ...PHASE, + // @ts-expect-error — `targetHours` is a retiredKey() tombstone: its input type is `never`. + targetHours: 2, + }; + const rule: IncidentNotificationRule = { + ...RULE, + // @ts-expect-error — `withinMinutes` is a retiredKey() tombstone. + withinMinutes: 15, + // @ts-expect-error — `regulatorDeadlineHours` is a retiredKey() tombstone. + regulatorDeadlineHours: 72, + }; + const policy: IncidentResponsePolicy = { + ...POLICY, + notificationMatrix: { + ...MATRIX, + // @ts-expect-error — `escalationTimeoutMinutes` is a retiredKey() tombstone. + escalationTimeoutMinutes: 30, + }, + // @ts-expect-error — `triageDeadlineHours` is a retiredKey() tombstone. + triageDeadlineHours: 1, + // @ts-expect-error — `retentionDays` is a retiredKey() tombstone. + retentionDays: 2555, + }; + const course: TrainingCourse = { + ...COURSE, + // @ts-expect-error — `durationMinutes` is a retiredKey() tombstone. + durationMinutes: 60, + // @ts-expect-error — `validityDays` is a retiredKey() tombstone. + validityDays: 365, + }; + const plan: TrainingPlan = { + ...PLAN, + // @ts-expect-error — `recertificationIntervalDays` is a retiredKey() tombstone. + recertificationIntervalDays: 365, + // @ts-expect-error — `gracePeriodDays` is a retiredKey() tombstone. + gracePeriodDays: 30, + // @ts-expect-error — `reminderDaysBefore` is a retiredKey() tombstone. + reminderDaysBefore: 14, + }; + const impact: ChangeImpact = { + ...IMPACT, + downtime: { + required: true, + // @ts-expect-error — the nested `downtime.durationMinutes` is a retiredKey() tombstone. + durationMinutes: 30, + }, + }; + const rollback: RollbackPlan = { + ...ROLLBACK, + steps: [{ + ...STEP, + // @ts-expect-error — `steps[].estimatedMinutes` is a retiredKey() tombstone. + estimatedMinutes: 15, + }], + }; + const change: ChangeRequest = { + ...CHANGE, + implementation: { + description: 'Execute the migration scripts', + steps: [{ + ...STEP, + // @ts-expect-error — `implementation.steps[].estimatedMinutes` is a retiredKey() tombstone. + estimatedMinutes: 10, + }], + }, + }; + // The parse channel agrees with the type channel on the same literals. + for (const [schema, value] of [ + [IncidentResponsePhaseSchema, phase], + [IncidentNotificationRuleSchema, rule], + [IncidentResponsePolicySchema, policy], + [TrainingCourseSchema, course], + [TrainingPlanSchema, plan], + [ChangeImpactSchema, impact], + [RollbackPlanSchema, rollback], + [ChangeRequestSchema, change], + ] as Array<[ZodTypeAny, unknown]>) { + expect(schema.safeParse(value).success).toBe(false); + } + }); +}); + +describe('[#14477] ADR-0087 registration', () => { + it('declares all fourteen sites under major 18, with the three D3 semantic entries wired and no D2 conversion', () => { + for (const site of SITES) { + expect(RETIRED_KEYS_BY_MAJOR[18], `${site.registered} must be declared`).toContain(site.registered); + } + const step = MIGRATIONS_BY_MAJOR[18]; + expect(step).toBeDefined(); + const ids = step!.semantic.map((s) => s.id); + for (const id of SEMANTIC_IDS) { + expect(ids, `${id} must be wired into the step-18 chain`).toContain(id); + const entry = step!.semantic.find((s) => s.id === id)!; + 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: a transform over a stack that + // never carries these documents would be a seam that never runs. + expect(step!.conversionIds.filter((id) => /incident|training|change-management|deadline/.test(id))).toEqual([]); + }); +}); + +// What this leg guarantees, and what it does not. The walk below reads the +// whole repo tree from REPO_ROOT, but the inputs it reaches outside +// `@objectstack/spec`'s declared cross-package globs in `turbo.json` are not +// hashed by turbo. So a resurrection authored in `examples/**`, `apps/**`, +// hand-written `content/docs/**` or most `packages/*/src/**` does not put this +// suite into `turbo ls --affected`, and the PR that authors it can replay a +// cached green. Read this leg as a FULL-RUN guarantee — the merge queue and a +// plain `pnpm test` — rather than an affected-path one. +// +// `check:cross-package-test-inputs` is green here as designed: every literal +// path this file names is declared, and the gate's declaration file states the +// trade for a walk that descends on a loop variable (the escape verdict +// resolves and the name does not). Widening spec's declared inputs to the +// walk's real radius would put this suite on every docs PR, so nothing is +// widened here; the playbook-vs-gate question is filed as #15528 and is not +// this PR's to answer. +// +// Independently of turbo, every *typed* TypeScript resurrection is caught at +// author time by the `never` channel pinned above, which needs no tree walk. +// The residue this leg covers is the untyped rest: JSON, YAML, MDX and +// unannotated literals. +describe('[#14477] tree-scoped absence: nothing in the repo authors a retired key any more', () => { + const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); + + /** Names unique to the three families — an authored occurrence anywhere is a resurrection. */ + const UNIQUE_KEYS = [ + 'targetHours', + 'withinMinutes', + 'regulatorDeadlineHours', + 'escalationTimeoutMinutes', + 'triageDeadlineHours', + 'validityDays', + 'recertificationIntervalDays', + 'gracePeriodDays', + 'reminderDaysBefore', + ]; + /** + * Names other, LIVE schemas also declare (`AuditPolicy.retentionDays`, + * `TenantBackup.retentionDays`, …): judged only in a file that also names one + * of the three families, so a live key on an unrelated schema is never + * misread as a resurrection. The bound is deliberate and stated: a document + * authored for these families in a file that names none of them is + * invisible to this leg — no such file exists, because none of the families + * is a metadata type with a file-based authoring path. + */ + const SHARED_KEYS = ['retentionDays', 'durationMinutes', 'estimatedMinutes']; + const FAMILY = /\b(IncidentResponsePhase|IncidentNotificationRule|IncidentNotificationMatrix|IncidentResponsePolicy|IncidentSchema|TrainingCourse|TrainingPlan|ChangeImpact|RollbackPlan|ChangeRequest)\b/; + /** An AUTHORING (`key:` / `"key":` / `key?:`), never a prose mention. */ + const authoring = (keys: readonly string[]) => new RegExp(`["']?\\b(${keys.join('|')})\\b["']?\\s*\\??\\s*:`); + + const SCANNED_EXT = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.mts', '.cts', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Build, SCM and cache state — not authored sources. */ + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage']); + /** Structural exclusions, each with its reason. NOT an allowlist file: these are the retirement kit and its projections. */ + const EXCLUDED = [ + // The tombstones declare the key they refuse. + 'packages/spec/src/system/incident-response.zod.ts', + 'packages/spec/src/system/training.zod.ts', + 'packages/spec/src/system/change-management.zod.ts', + // Refusal fixtures author the key to prove the refusal — this pin included. + 'packages/spec/src/system/incident-response.test.ts', + 'packages/spec/src/system/training.test.ts', + 'packages/spec/src/system/change-management.test.ts', + path.relative(REPO_ROOT, fileURLToPath(import.meta.url)), + ]; + const EXCLUDED_PREFIXES = [ + // Registers the retirement by key (entries + the generated registry). + 'packages/spec/src/migrations/', + // Generated projections of the tombstone: the `[RETIRED]` / `[REMOVED]` rows. + 'packages/spec/authorable-surface/', + 'packages/spec/json-schema/', + 'packages/spec/json-schema.manifest/', + 'packages/spec/spec-changes.json', + 'docs/protocol-upgrade-guide.md', + 'content/docs/references/', + // Release prose records the removal (release-owned; never edited by a code PR). + 'content/docs/releases/', + '.changeset/', + ]; + + it('the matcher recognises the authored shape (anti-vacuity, on a file the scan deliberately excludes)', () => { + const tombstoned = fs.readFileSync(path.join(THIS_DIR, 'incident-response.zod.ts'), 'utf-8'); + expect(authoring(UNIQUE_KEYS).test(tombstoned)).toBe(true); + expect(FAMILY.test(tombstoned) && authoring(SHARED_KEYS).test(tombstoned)).toBe(true); + // And a bare prose mention is NOT an authoring. + expect(authoring(UNIQUE_KEYS).test('the `targetHours` key was removed')).toBe(false); + expect(authoring(UNIQUE_KEYS).test('`IncidentResponsePhase.targetHours` was removed')).toBe(false); + }); + + it('no authored occurrence survives outside the retirement kit and its generated projections', () => { + const offenders: string[] = []; + let visited = 0; + const unique = authoring(UNIQUE_KEYS); + const shared = authoring(SHARED_KEYS); + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name)) continue; + // Other dot-directories are generated caches (`.source`, `.next`, …); + // the two hand-authored ones are scanned. + if (entry.name.startsWith('.') && entry.name !== '.claude' && entry.name !== '.github') continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + if (!SCANNED_EXT.has(path.extname(entry.name))) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.includes(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + visited += 1; + const text = fs.readFileSync(full, 'utf-8'); + const u = unique.exec(text); + if (u) offenders.push(`${rel} authors \`${u[1]}\``); + if (FAMILY.test(text)) { + const s = shared.exec(text); + if (s) offenders.push(`${rel} names a family and authors \`${s[1]}\``); + } + } + }; + walk(REPO_ROOT); + // Anti-vacuity: the walk really covered the tree. + expect(visited).toBeGreaterThan(1000); + expect(offenders, 'an authored retired key means the retirement is being undone — re-read #14477').toEqual([]); + }); +}); diff --git a/packages/spec/src/system/incident-response.test.ts b/packages/spec/src/system/incident-response.test.ts index 93367737c0..fa03608740 100644 --- a/packages/spec/src/system/incident-response.test.ts +++ b/packages/spec/src/system/incident-response.test.ts @@ -68,7 +68,6 @@ describe('IncidentResponsePhaseSchema', () => { phase: 'containment', description: 'Isolate affected systems', assignedTo: 'security_team', - targetHours: 4, }; expect(() => IncidentResponsePhaseSchema.parse(phase)).not.toThrow(); @@ -82,7 +81,6 @@ describe('IncidentResponsePhaseSchema', () => { phase, description: `${phase} phase`, assignedTo: 'team', - targetHours: 2, })).not.toThrow(); }); }); @@ -92,7 +90,6 @@ describe('IncidentResponsePhaseSchema', () => { phase: 'recovery', description: 'Restore services', assignedTo: 'ops_team', - targetHours: 8, completedAt: 1704067200000, notes: 'All systems restored successfully', }); @@ -101,13 +98,16 @@ describe('IncidentResponsePhaseSchema', () => { expect(phase.notes).toBe('All systems restored successfully'); }); - it('should reject negative target hours', () => { + it('REFUSES an authored `targetHours` — a retiredKey() tombstone since #14477 (ADR-0049)', () => { + // The full refusal envelope (path, code, prescription) is pinned in + // `deadline-keys-retirement.test.ts`; this keeps the family suite honest + // about the shape it parses: any value, not only a negative one, is refused. expect(() => IncidentResponsePhaseSchema.parse({ phase: 'identification', description: 'Identify', assignedTo: 'team', - targetHours: -1, - })).toThrow(); + targetHours: 2, + })).toThrow(/`IncidentResponsePhase\.targetHours` was removed/s); }); }); @@ -117,9 +117,7 @@ describe('IncidentNotificationRuleSchema', () => { severity: 'critical', channels: ['email', 'pagerduty'], recipients: ['ciso', 'security_team'], - withinMinutes: 15, notifyRegulators: true, - regulatorDeadlineHours: 72, }; expect(() => IncidentNotificationRuleSchema.parse(rule)).not.toThrow(); @@ -130,7 +128,6 @@ describe('IncidentNotificationRuleSchema', () => { severity: 'low', channels: ['email'], recipients: ['security_team'], - withinMinutes: 60, }); expect(rule.notifyRegulators).toBe(false); @@ -143,7 +140,6 @@ describe('IncidentNotificationRuleSchema', () => { severity: 'high', channels, recipients: ['all'], - withinMinutes: 30, })).not.toThrow(); }); @@ -152,7 +148,6 @@ describe('IncidentNotificationRuleSchema', () => { severity: 'high', channels: ['carrier_pigeon'], recipients: ['team'], - withinMinutes: 30, })).toThrow(); }); }); @@ -165,12 +160,11 @@ describe('IncidentNotificationMatrixSchema', () => { severity: 'critical', channels: ['pagerduty', 'sms'], recipients: ['ciso', 'security_team'], - withinMinutes: 15, }, ], }); - expect(matrix.escalationTimeoutMinutes).toBe(30); + expect(matrix).not.toHaveProperty('escalationTimeoutMinutes'); expect(matrix.escalationChain).toEqual([]); expect(matrix.rules).toHaveLength(1); }); @@ -182,29 +176,24 @@ describe('IncidentNotificationMatrixSchema', () => { severity: 'critical', channels: ['pagerduty', 'sms', 'email'], recipients: ['ciso', 'executive_team'], - withinMinutes: 15, notifyRegulators: true, - regulatorDeadlineHours: 72, }, { severity: 'high', channels: ['slack', 'email'], recipients: ['security_team'], - withinMinutes: 30, }, { severity: 'low', channels: ['email'], recipients: ['security_team'], - withinMinutes: 120, }, ], - escalationTimeoutMinutes: 60, escalationChain: ['security_lead', 'ciso', 'ceo'], }); expect(matrix.rules).toHaveLength(3); - expect(matrix.escalationTimeoutMinutes).toBe(60); + expect(matrix).not.toHaveProperty('escalationTimeoutMinutes'); expect(matrix.escalationChain).toHaveLength(3); }); }); @@ -228,13 +217,11 @@ describe('IncidentSchema', () => { phase: 'identification', description: 'Identify scope of unauthorized access', assignedTo: 'security_team', - targetHours: 2, }, { phase: 'containment', description: 'Block suspicious IP range', assignedTo: 'network_team', - targetHours: 1, }, ], rootCause: 'Compromised API key', @@ -281,7 +268,6 @@ describe('IncidentSchema', () => { phase: 'identification', description: 'Identify malware type', assignedTo: 'security_team', - targetHours: 1, completedAt: 1704070800000, notes: 'Identified as known ransomware variant', }, @@ -289,28 +275,24 @@ describe('IncidentSchema', () => { phase: 'containment', description: 'Isolate affected workstation', assignedTo: 'it_support', - targetHours: 0.5, completedAt: 1704072600000, }, { phase: 'eradication', description: 'Remove malware and reimage', assignedTo: 'it_support', - targetHours: 4, completedAt: 1704086400000, }, { phase: 'recovery', description: 'Restore from backup', assignedTo: 'it_support', - targetHours: 8, completedAt: 1704115200000, }, { phase: 'lessons_learned', description: 'Post-incident review', assignedTo: 'security_team', - targetHours: 24, completedAt: 1704153600000, }, ], @@ -360,7 +342,6 @@ describe('IncidentResponsePolicySchema', () => { severity: 'critical', channels: ['pagerduty'], recipients: ['security_team'], - withinMinutes: 15, }, ], }, @@ -368,10 +349,10 @@ describe('IncidentResponsePolicySchema', () => { }); expect(policy.enabled).toBe(true); - expect(policy.triageDeadlineHours).toBe(1); + expect(policy).not.toHaveProperty('triageDeadlineHours'); expect(policy.requirePostIncidentReview).toBe(true); expect(policy.regulatoryNotificationThreshold).toBe('high'); - expect(policy.retentionDays).toBe(2555); + expect(policy).not.toHaveProperty('retentionDays'); }); it('should accept full policy configuration', () => { @@ -383,30 +364,24 @@ describe('IncidentResponsePolicySchema', () => { severity: 'critical', channels: ['pagerduty', 'sms', 'email'], recipients: ['ciso', 'executive_team'], - withinMinutes: 15, notifyRegulators: true, - regulatorDeadlineHours: 72, }, { severity: 'high', channels: ['slack', 'email'], recipients: ['security_team'], - withinMinutes: 30, }, ], - escalationTimeoutMinutes: 45, escalationChain: ['security_lead', 'ciso'], }, defaultResponseTeam: 'incident_response_team', - triageDeadlineHours: 2, requirePostIncidentReview: true, regulatoryNotificationThreshold: 'critical', - retentionDays: 3650, }); - expect(policy.triageDeadlineHours).toBe(2); + expect(policy).not.toHaveProperty('triageDeadlineHours'); expect(policy.regulatoryNotificationThreshold).toBe('critical'); - expect(policy.retentionDays).toBe(3650); + expect(policy).not.toHaveProperty('retentionDays'); }); it('should reject missing required fields', () => { diff --git a/packages/spec/src/system/incident-response.zod.ts b/packages/spec/src/system/incident-response.zod.ts index 38898bd3c1..40a929a2af 100644 --- a/packages/spec/src/system/incident-response.zod.ts +++ b/packages/spec/src/system/incident-response.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { DataClassificationSchema } from './security-context.zod'; +import { retiredKey } from '../shared/retired-key'; /** * Incident Response Protocol — ISO 27001:2022 (A.5.24–A.5.28) @@ -28,6 +29,75 @@ export const IncidentSeveritySchema = lazySchema(() => z.enum([ 'low', // Minor impact with standard response procedures ])); +// ─── RETIRED deadline keys (ADR-0049 enforce-or-remove) ───────────────────── +// +// Six hour/minute/day-shaped deadline and SLA keys were declared on the +// incident-response schemas and read by NOTHING: no scheduler, escalation +// engine, regulator notifier, SLA clock or retention sweeper exists on the +// platform for this family — the schemas are exported, mounted by no stack +// key and registered as no metadata type, and the reader census over every +// package outside `packages/spec` (and over objectui at the pinned sha) +// returned zero hits for every key. An author could write +// `triageDeadlineHours: 4` and the platform would never act on it; the +// generated reference docs advertised a deadline nothing kept. Maintainer +// ruling 2026-09-02 (recorded on #14477): retire the family under +// enforce-or-remove. +// +// Route: `retiredKey()` tombstones, NOT plain deletion — none of these +// schemas is `.strict()`, so a bare deletion would make zod strip the key in +// silence, replacing an inert declaration with an invisible one (ADR-0104). +// The tombstone is audible in both channels: `tsc` (the input type is +// `never`) and the parse (the prescription is the message). No D2 conversion +// and no `os migrate meta` sentence: the conversion chain walks a normalized +// STACK and none of these schemas is a stack collection member, so a +// conversion would be a transform with no seam that ever runs (the +// `kernel/MetadataPluginConfig:additionalTypes` precedent). The retirement is +// registered as `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3 semantic entry +// `incident-response-deadline-keys-retired`. + +const TARGET_HOURS_RETIRED = + '`IncidentResponsePhase.targetHours` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no engine tracked a response phase against a ' + + 'clock, so the target was never checked, never escalated and never reported. Delete the ' + + 'key. There is no replacement, because no incident-response engine exists to keep a phase ' + + 'deadline.'; + +const WITHIN_MINUTES_RETIRED = + '`IncidentNotificationRule.withinMinutes` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no dispatcher sent an incident notification, ' + + 'so no deadline for one was ever measured. Delete the key. There is no replacement, ' + + 'because no incident-notification engine exists to keep the deadline.'; + +const REGULATOR_DEADLINE_HOURS_RETIRED = + '`IncidentNotificationRule.regulatorDeadlineHours` was removed in @objectstack/spec 17 ' + + '(ADR-0049 enforce-or-remove) — nothing ever read it: no engine notified a regulator, so a ' + + 'regulatory deadline declared here (a GDPR 72-hour window, for example) was never tracked, ' + + 'and a compliance author who wrote it held a promise the platform did not keep. Delete the ' + + 'key. There is no replacement, because no regulatory-notification engine exists.'; + +const ESCALATION_TIMEOUT_MINUTES_RETIRED = + '`IncidentNotificationMatrix.escalationTimeoutMinutes` was removed in @objectstack/spec 17 ' + + '(ADR-0049 enforce-or-remove) — nothing ever read it: no engine walked `escalationChain` ' + + 'on a timer, so the timeout never fired, and its default of 30 minutes was materialized ' + + 'into every parsed matrix without ever being consulted. Delete the key. There is no ' + + 'replacement, because no escalation engine exists.'; + +const TRIAGE_DEADLINE_HOURS_RETIRED = + '`IncidentResponsePolicy.triageDeadlineHours` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no engine timed the interval between detection ' + + 'and triage, so the deadline was never kept, and its default of 1 hour was materialized ' + + 'into every parsed policy without ever being consulted. Delete the key. There is no ' + + 'replacement, because no incident-response engine exists to keep a triage window.'; + +const RETENTION_DAYS_RETIRED = + '`IncidentResponsePolicy.retentionDays` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no sweeper deleted incident records on a ' + + 'schedule, so the retention period was never applied, and its default of 2555 days was ' + + 'materialized into every parsed policy without ever being consulted. Delete the key. ' + + 'Retention on this platform is the object-level `lifecycle` block (ADR-0057), enforced by ' + + 'the LifecycleService over the records of an object — declare it on the object that stores ' + + 'incident records, not on this policy document.'; + /** * Incident Category Schema * @@ -92,9 +162,9 @@ export const IncidentResponsePhaseSchema = lazySchema(() => z.object({ assignedTo: z.string().describe('Responsible team or role'), /** - * Target completion time in hours from incident start + * REMOVED (ADR-0049 enforce-or-remove) — see `TARGET_HOURS_RETIRED` above. */ - targetHours: z.number().min(0).describe('Target completion time in hours'), + targetHours: retiredKey(TARGET_HOURS_RETIRED), /** * Actual completion timestamp (Unix milliseconds) @@ -137,9 +207,9 @@ export const IncidentNotificationRuleSchema = lazySchema(() => z.object({ recipients: z.array(z.string()).describe('Roles or teams to notify'), /** - * Maximum time in minutes to send notification after incident detection + * REMOVED (ADR-0049 enforce-or-remove) — see `WITHIN_MINUTES_RETIRED` above. */ - withinMinutes: z.number().min(1).describe('Notification deadline in minutes from detection'), + withinMinutes: retiredKey(WITHIN_MINUTES_RETIRED), /** * Whether to notify external regulators (for data breaches) @@ -148,10 +218,9 @@ export const IncidentNotificationRuleSchema = lazySchema(() => z.object({ .describe('Whether to notify regulatory authorities'), /** - * Regulatory notification deadline in hours (e.g., GDPR 72h) + * REMOVED (ADR-0049 enforce-or-remove) — see `REGULATOR_DEADLINE_HOURS_RETIRED` above. */ - regulatorDeadlineHours: z.number().optional() - .describe('Regulatory notification deadline in hours'), + regulatorDeadlineHours: retiredKey(REGULATOR_DEADLINE_HOURS_RETIRED), }).describe('Incident notification rule per severity level')); export type IncidentNotificationRule = z.input; @@ -171,10 +240,9 @@ export const IncidentNotificationMatrixSchema = lazySchema(() => z.object({ .describe('Notification rules by severity level'), /** - * Default escalation timeout in minutes before auto-escalation + * REMOVED (ADR-0049 enforce-or-remove) — see `ESCALATION_TIMEOUT_MINUTES_RETIRED` above. */ - escalationTimeoutMinutes: z.number().default(30) - .describe('Auto-escalation timeout in minutes'), + escalationTimeoutMinutes: retiredKey(ESCALATION_TIMEOUT_MINUTES_RETIRED), /** * Escalation chain: ordered list of roles to escalate to @@ -210,8 +278,7 @@ export type IncidentNotificationMatrixParsed = z.infer z.object({ .describe('Default incident response team or role'), /** - * Maximum time in hours to begin initial triage + * REMOVED (ADR-0049 enforce-or-remove) — see `TRIAGE_DEADLINE_HOURS_RETIRED` above. */ - triageDeadlineHours: z.number().default(1) - .describe('Maximum hours to begin triage after detection'), + triageDeadlineHours: retiredKey(TRIAGE_DEADLINE_HOURS_RETIRED), /** * Whether to require post-incident review for all incidents @@ -358,10 +424,9 @@ export const IncidentResponsePolicySchema = lazySchema(() => z.object({ .describe('Minimum severity requiring regulatory notification'), /** - * Retention period for incident records in days + * REMOVED (ADR-0049 enforce-or-remove) — see `RETENTION_DAYS_RETIRED` above. */ - retentionDays: z.number().default(2555) - .describe('Incident record retention period in days (default ~7 years)'), + retentionDays: retiredKey(RETENTION_DAYS_RETIRED), }).describe('Organization-level incident response policy per ISO 27001:2022')); // Type exports diff --git a/packages/spec/src/system/training.test.ts b/packages/spec/src/system/training.test.ts index e55e0f1224..7730728e36 100644 --- a/packages/spec/src/system/training.test.ts +++ b/packages/spec/src/system/training.test.ts @@ -48,7 +48,6 @@ describe('TrainingCourseSchema', () => { title: 'Information Security Fundamentals', description: 'Annual security awareness training for all employees', category: 'security_awareness', - durationMinutes: 60, targetRoles: ['all_employees'], }); @@ -63,10 +62,8 @@ describe('TrainingCourseSchema', () => { title: 'Phishing Awareness Training', description: 'Recognize and report phishing attempts', category: 'phishing_awareness', - durationMinutes: 30, mandatory: true, targetRoles: ['all_employees', 'contractors'], - validityDays: 365, passingScore: 80, version: '2.0', }; @@ -87,21 +84,23 @@ describe('TrainingCourseSchema', () => { title: `${category} Training`, description: `Training for ${category}`, category, - durationMinutes: 30, targetRoles: ['all'], })).not.toThrow(); }); }); - it('should reject invalid duration', () => { + it('REFUSES an authored `durationMinutes` — a retiredKey() tombstone since #14477 (ADR-0049)', () => { + // The full refusal envelope (path, code, prescription) is pinned in + // `deadline-keys-retirement.test.ts`; this keeps the family suite honest + // about the shape it parses: any value, not only `0`, is refused. expect(() => TrainingCourseSchema.parse({ id: 'COURSE-001', title: 'Test', description: 'Test', category: 'other', - durationMinutes: 0, + durationMinutes: 30, targetRoles: ['all'], - })).toThrow(); + })).toThrow(/`TrainingCourse\.durationMinutes` was removed/s); }); it('should reject passing score out of range', () => { @@ -110,7 +109,6 @@ describe('TrainingCourseSchema', () => { title: 'Test', description: 'Test', category: 'other', - durationMinutes: 30, targetRoles: ['all'], passingScore: 101, })).toThrow(); @@ -120,7 +118,6 @@ describe('TrainingCourseSchema', () => { title: 'Test', description: 'Test', category: 'other', - durationMinutes: 30, targetRoles: ['all'], passingScore: -1, })).toThrow(); @@ -197,18 +194,17 @@ describe('TrainingPlanSchema', () => { title: 'Security Awareness', description: 'Annual security training', category: 'security_awareness', - durationMinutes: 60, targetRoles: ['all_employees'], }, ], }); expect(plan.enabled).toBe(true); - expect(plan.recertificationIntervalDays).toBe(365); + expect(plan).not.toHaveProperty('recertificationIntervalDays'); expect(plan.trackCompletion).toBe(true); - expect(plan.gracePeriodDays).toBe(30); + expect(plan).not.toHaveProperty('gracePeriodDays'); expect(plan.sendReminders).toBe(true); - expect(plan.reminderDaysBefore).toBe(14); + expect(plan).not.toHaveProperty('reminderDaysBefore'); }); it('should accept full plan configuration', () => { @@ -220,10 +216,8 @@ describe('TrainingPlanSchema', () => { title: 'Security Fundamentals', description: 'Core security training', category: 'security_awareness', - durationMinutes: 60, mandatory: true, targetRoles: ['all_employees'], - validityDays: 365, passingScore: 80, }, { @@ -231,24 +225,19 @@ describe('TrainingPlanSchema', () => { title: 'Secure Development', description: 'Secure coding practices', category: 'secure_development', - durationMinutes: 120, mandatory: true, targetRoles: ['developers', 'devops'], - validityDays: 365, passingScore: 85, }, ], - recertificationIntervalDays: 180, trackCompletion: true, - gracePeriodDays: 14, sendReminders: true, - reminderDaysBefore: 30, }); expect(plan.courses).toHaveLength(2); - expect(plan.recertificationIntervalDays).toBe(180); - expect(plan.gracePeriodDays).toBe(14); - expect(plan.reminderDaysBefore).toBe(30); + expect(plan).not.toHaveProperty('recertificationIntervalDays'); + expect(plan).not.toHaveProperty('gracePeriodDays'); + expect(plan).not.toHaveProperty('reminderDaysBefore'); }); it('should accept plan with empty courses', () => { diff --git a/packages/spec/src/system/training.zod.ts b/packages/spec/src/system/training.zod.ts index 6831a2ddb4..27d58383be 100644 --- a/packages/spec/src/system/training.zod.ts +++ b/packages/spec/src/system/training.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +import { retiredKey } from '../shared/retired-key'; /** * Information Security Training Protocol — ISO 27001:2022 (A.6.3) @@ -31,6 +32,61 @@ export const TrainingCategorySchema = lazySchema(() => z.enum([ 'other', // Other training categories ])); +// ─── RETIRED deadline keys (ADR-0049 enforce-or-remove) ───────────────────── +// +// Five minute/day-shaped duration and deadline keys were declared on the +// training schemas and read by NOTHING: no training engine scheduled a +// course, computed a certification expiry, re-assigned training on an +// interval, escalated an expired certification or sent a reminder — the +// schemas are exported, mounted by no stack key and registered as no metadata +// type, and the reader census over every package outside `packages/spec` (and +// over objectui at the pinned sha) returned zero hits for every key. Maintainer +// ruling 2026-09-02 (recorded on #14477): retire the family under +// enforce-or-remove. +// +// Route: `retiredKey()` tombstones, NOT plain deletion (the schemas are not +// `.strict()`; a bare deletion would be a silent strip, ADR-0104). No D2 +// conversion and no `os migrate meta` sentence: none of these schemas is a +// stack collection member, so a conversion would have no seam that ever runs +// (the `kernel/MetadataPluginConfig:additionalTypes` precedent). Registered +// as `RETIRED_KEYS_BY_MAJOR[18]` entries plus the D3 semantic entry +// `training-deadline-keys-retired`. + +const DURATION_MINUTES_RETIRED = + '`TrainingCourse.durationMinutes` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no training engine scheduled, timed or ' + + 'reported a course, so the duration was a number the platform displayed nowhere and acted ' + + 'on never. Delete the key. There is no replacement, because no training-management engine ' + + 'exists.'; + +const VALIDITY_DAYS_RETIRED = + '`TrainingCourse.validityDays` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no engine computed a certification expiry from ' + + 'it, so a certificate declared valid for 365 days never expired on the platform and never ' + + 'triggered recertification. Delete the key. There is no replacement, because no ' + + 'training-management engine exists to keep a validity window.'; + +const RECERTIFICATION_INTERVAL_DAYS_RETIRED = + '`TrainingPlan.recertificationIntervalDays` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no engine re-assigned training on an interval, ' + + 'so the interval never elapsed into anything, and its default of 365 days was materialized ' + + 'into every parsed plan without ever being consulted. Delete the key. There is no ' + + 'replacement, because no training-management engine exists.'; + +const GRACE_PERIOD_DAYS_RETIRED = + '`TrainingPlan.gracePeriodDays` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no engine escalated an expired certification, ' + + 'so a grace period before that escalation had nothing to delay, and its default of 30 days ' + + 'was materialized into every parsed plan without ever being consulted. Delete the key. ' + + 'There is no replacement, because no training-management engine exists.'; + +const REMINDER_DAYS_BEFORE_RETIRED = + '`TrainingPlan.reminderDaysBefore` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no engine sent a training reminder, so the lead ' + + 'time was never counted down, and its default of 14 days was materialized into every parsed ' + + 'plan without ever being consulted. Delete the key. There is no replacement, because no ' + + 'training-reminder engine exists.'; + /** * Training Completion Status Schema */ @@ -54,10 +110,8 @@ export const TrainingCompletionStatusSchema = lazySchema(() => z.enum([ * "title": "Information Security Fundamentals", * "description": "Annual security awareness training for all employees", * "category": "security_awareness", - * "durationMinutes": 60, * "mandatory": true, * "targetRoles": ["all_employees"], - * "validityDays": 365, * "passingScore": 80 * } * ``` @@ -84,9 +138,9 @@ export const TrainingCourseSchema = lazySchema(() => z.object({ category: TrainingCategorySchema.describe('Training category'), /** - * Estimated duration in minutes + * REMOVED (ADR-0049 enforce-or-remove) — see `DURATION_MINUTES_RETIRED` above. */ - durationMinutes: z.number().min(1).describe('Estimated course duration in minutes'), + durationMinutes: retiredKey(DURATION_MINUTES_RETIRED), /** * Whether this training is mandatory @@ -99,9 +153,9 @@ export const TrainingCourseSchema = lazySchema(() => z.object({ targetRoles: z.array(z.string()).describe('Target roles or groups'), /** - * Validity period in days before recertification is needed + * REMOVED (ADR-0049 enforce-or-remove) — see `VALIDITY_DAYS_RETIRED` above. */ - validityDays: z.number().optional().describe('Certification validity period in days'), + validityDays: retiredKey(VALIDITY_DAYS_RETIRED), /** * Minimum passing score (percentage) for assessment @@ -179,10 +233,9 @@ export const TrainingPlanSchema = lazySchema(() => z.object({ courses: z.array(TrainingCourseSchema).describe('Training courses'), /** - * Default recertification interval in days + * REMOVED (ADR-0049 enforce-or-remove) — see `RECERTIFICATION_INTERVAL_DAYS_RETIRED` above. */ - recertificationIntervalDays: z.number().default(365) - .describe('Default recertification interval in days'), + recertificationIntervalDays: retiredKey(RECERTIFICATION_INTERVAL_DAYS_RETIRED), /** * Whether to track training completion for compliance reporting @@ -191,10 +244,9 @@ export const TrainingPlanSchema = lazySchema(() => z.object({ .describe('Track training completion for compliance'), /** - * Grace period in days after expiry before non-compliance escalation + * REMOVED (ADR-0049 enforce-or-remove) — see `GRACE_PERIOD_DAYS_RETIRED` above. */ - gracePeriodDays: z.number().default(30) - .describe('Grace period in days after certification expiry'), + gracePeriodDays: retiredKey(GRACE_PERIOD_DAYS_RETIRED), /** * Whether to send reminders for upcoming training deadlines @@ -203,10 +255,9 @@ export const TrainingPlanSchema = lazySchema(() => z.object({ .describe('Send reminders for upcoming training deadlines'), /** - * Days before deadline to send first reminder + * REMOVED (ADR-0049 enforce-or-remove) — see `REMINDER_DAYS_BEFORE_RETIRED` above. */ - reminderDaysBefore: z.number().default(14) - .describe('Days before deadline to send first reminder'), + reminderDaysBefore: retiredKey(REMINDER_DAYS_BEFORE_RETIRED), }).describe('Organizational training plan per ISO 27001:2022 A.6.3')); // Type exports