Skip to content

Commit f415bcf

Browse files
fix(spec): one D3 entry per major-18 retirement family — the census and the 25 missing entries (#20201) (#20255)
Fixes #20201 Clause-②: no ## What Ruling B on #17152 (director `5615360777`, restated `5634031140`, on the maintainer's #15954 authority `5559778263`): every retirement family carries ONE ADR-0087 D3 (`semantic`) entry, even when a lossless D2 conversion repairs its data; D2 carries the mechanical repair only. This PR takes the major-18 family census the card asks for, adds the 25 D3 entries it found missing, corrects the prose that justified their absence, and pins the census in the registry's own test. - **25 new D3 entries** under `packages/spec/src/migrations/entries/semantic/18.*.ts`, one per D2-backed family that had none. Each names its family and its D2 conversion id, says what D2 already repairs, and says what judgment the consumer still owes (the `reason`), with an `acceptanceCriteria` the consumer can check. None is a placeholder: every one states a residue specific to its family (a unit only the author knows, a belief the platform never honoured, a shape the conversion deliberately leaves alone, code the chain cannot reach). - **Prose corrected** at the sites of `5854110917` and `5854456343`, plus step 18's rationale and step 17's docblock fact (list below). - **Census pin** in the existing `packages/spec/src/migrations/migrations.test.ts` (registry integrity). No new check script. - `MIGRATIONS_BY_MAJOR[18].semantic` 186 → 211 entries at the census base; after merging `main` (four sibling D3 entries landed meanwhile, none with a D2 conversion) the generated region holds 215. ## The census (the card's main deliverable) **Tree.** `objectstack-ai/objectstack` at `3cb84d084` (this branch's fork point; it already contains #20227's view-item retirement). Major-18 population there: **185** `retired-keys`, **146** `retired-defs`, **186** `semantic` entry files, and **36** D2 conversions graduated into step 18. (The card measured 181 / 130 / 175 at `d7c024133e`.) **Grouping rule (ruling B's unit, held constant).** Where a D2 conversion exists, the conversion is the family: every retired key or def it repairs belongs to it, and a D3 entry may cover more than one conversion only where one judgment covers them (the pre-existing `element-filter-and-form-node-refused` covers `element-filter-removed` and `element-form-removed`). Every new entry here covers exactly one conversion. Where no conversion exists, the records are grouped by the D3 entry that names them. **Method.** 1. Mechanical pass (scratch scripts, not committed): for each of the 36 conversions, the major-18 `semantic/` entry files that name its id as a whole id (comment or field); for each retired key, the conversion its own comment names, else the major-18 entries naming it as `cat/Def:key`, `Def.path` / `Def:path`, or the def name plus the leaf key; for each retired def, the entries naming the def. 2. Reading pass, where a string match cannot decide: (a) ownership: an entry that names a conversion only in passing is not that family's entry (this is how `metric-filters-removed` was classed missing although `analytics-authorable-unknown-keys-refused` names it); (b) 14 records matched by more than one entry, each placed by its own comment (for example `kernel/PluginStartupResult:plugin`, which goes to `startup-orchestrator-retired`); (c) 9 records whose comment names no conversion but which belong to a D2 family (`integration/DeclarativeConnectorEntry:connectionTimeoutMs` and `:errorMapping`, the three error-mapping defs, the four responsive-shape defs), plus `integration/Connector:connectionTimeoutMs`, whose comment names two conversion ids and belongs to `connector-connection-timeout-ms-removed` (it names the permission conversion only as a comparison; round 1's Table 1 placed it wrongly, corrected in patch round 1); (d) 5 theme sub-block defs, named by the theme family's entry as its sub-blocks. **Control.** The pairing sees a family that has its entry: 11 of the 36 conversions pair with a pre-existing entry, among them `cube-join-sql-and-relationship-removed` with `cube-join-sql-and-relationship-retired` (which the pin's own control test also asserts), and the whole-id matcher refuses a prefix (`record-chatter-position-vocabulary` is a prefix of its entry's own id and matches only the entry's real citation). Evaluated at the base with the pin's logic: **24** conversions named by no major-18 entry; the reading pass adds `metric-filters-removed` for **25**. Evaluated at this head: **0**. **Result.** 331 records (185 keys + 146 defs) plus 36 conversions: - **36 D2-backed families** covering 58 records: 11 had their D3 entry, **25 had none**. Table 1. - **273 D2-less records** in 68 groups: every one is named by an existing D3 entry. Table 2. None missing, as expected: before ruling B, a retirement with no conversion needed a D3 entry anyway. The card's grep for 「lossless」 found the `tenancy.organizationField` site and step 17. The census finds 25 major-18 families, most of them with no 「lossless」 wording at all. ### Table 1 — D2-backed families (step 18 `conversionIds`, in order) | # | D2 conversion (the family) | registered records | D3 entry at base | D3 entry after | |---|---|---|---|---| | 1 | `field-malformed-scale-precision-removed` | none (value or strict-key retirement, not in the two tables) | `field-scale-precision-integer-refused` | unchanged | | 2 | `record-chatter-position-vocabulary` | none (value or strict-key retirement, not in the two tables) | `record-chatter-position-vocabulary-converged` | unchanged | | 3 | `element-input-target-variable-removed` | `ui/ElementRecordPickerProps:targetVariable`, `ui/ElementTextInputProps:targetVariable` | MISSING | `element-input-target-variable-retired` (new) | | 4 | `element-filter-removed` | `ui/ElementFilterProps:aria`, `ui/ElementFilterProps:fields`, `ui/ElementFilterProps:layout`, `ui/ElementFilterProps:object`, `ui/ElementFilterProps:showSearch`, `ui/ElementFilterProps:targetVariable` | `element-filter-and-form-node-refused` | unchanged | | 5 | `element-form-removed` | `ui/ElementFormProps:aria`, `ui/ElementFormProps:fields`, `ui/ElementFormProps:mode`, `ui/ElementFormProps:object`, `ui/ElementFormProps:onSubmit`, `ui/ElementFormProps:submitLabel` | `element-filter-and-form-node-refused` | unchanged | | 6 | `field-column-lists-canonicalized` | none (value or strict-key retirement, not in the two tables) | MISSING | `field-inline-and-related-list-columns-closed` (new) | | 7 | `metric-filters-removed` | `data/Metric:filters` | MISSING (named only in passing by `analytics-authorable-unknown-keys-refused`) | `cube-metric-filters-retired` (new) | | 8 | `cube-sub-day-granularities-removed` | none (value or strict-key retirement, not in the two tables) | `time-update-interval-sub-day-retired` | unchanged | | 9 | `cube-join-sql-and-relationship-removed` | `data/CubeJoin:relationship`, `data/CubeJoin:sql` | `cube-join-sql-and-relationship-retired` | unchanged | | 10 | `record-highlights-field-icon-removed` | `ui/RecordHighlightsField:icon` | MISSING | `record-highlights-field-icon-retired` (new) | | 11 | `mapping-lookup-params-removed` | none (value or strict-key retirement, not in the two tables) | MISSING | `mapping-lookup-params-retired` (new) | | 12 | `translation-component-submit-label-removed` | none (value or strict-key retirement, not in the two tables) | MISSING | `translation-component-submit-label-retired` (new) | | 13 | `page-component-responsive-removed` | `ui/PageComponent:responsive`, `ui/BreakpointColumnMap`, `ui/BreakpointName`, `ui/BreakpointOrderMap`, `ui/ResponsiveConfig` | MISSING | `page-component-responsive-retired` (new) | | 14 | `object-grid-default-sort-removed` | `ui/ObjectGridProps:defaultSort` | MISSING | `object-grid-default-sort-retired` (new) | | 15 | `object-kanban-quick-add-removed` | `ui/ObjectKanbanProps:quickAdd` | MISSING | `object-kanban-quick-add-retired` (new) | | 16 | `permission-allow-restore-purge-removed` | `security/EffectiveObjectPermission:allowPurge`, `security/EffectiveObjectPermission:allowRestore`, `security/ObjectPermission:allowPurge`, `security/ObjectPermission:allowRestore` | MISSING | `permission-restore-purge-bits-retired` (new) | | 17 | `form-view-option-default-removed` | none (value or strict-key retirement, not in the two tables) | MISSING | `form-view-option-default-retired` (new) | | 18 | `field-reference-to-alias` | none (value or strict-key retirement, not in the two tables) | MISSING | `field-reference-to-spelling-retired` (new) | | 19 | `connector-error-mapping-removed` | `integration/Connector:errorMapping`, `integration/DeclarativeConnectorEntry:errorMapping`, `integration/ConnectorErrorCategory`, `integration/ErrorMappingConfig`, `integration/ErrorMappingRule` | MISSING | `connector-error-mapping-retired` (new) | | 20 | `connector-connection-timeout-ms-removed` | `integration/Connector:connectionTimeoutMs`, `integration/DeclarativeConnectorEntry:connectionTimeoutMs` | `connector-provider-context-connection-timeout-ms-retired` | unchanged | | 21 | `hook-timeout-to-timeout-ms` | none (value or strict-key retirement, not in the two tables) | MISSING | `hook-timeout-unit-in-key` (new) | | 22 | `job-timeout-to-timeout-ms` | `system/Job:timeout` | MISSING | `job-timeout-unit-in-key` (new) | | 23 | `api-endpoint-cache-ttl-to-cache-ttl-seconds` | `api/ApiEndpoint:cacheTtl` | MISSING | `api-endpoint-cache-ttl-unit-in-key` (new) | | 24 | `dashboard-refresh-interval-to-refresh-interval-seconds` | `ui/Dashboard:refreshInterval` | MISSING | `dashboard-refresh-interval-unit-in-key` (new) | | 25 | `connector-health-and-trigger-durations-unit-in-key` | `integration/CircuitBreakerConfig:monitoringWindow`, `integration/ConnectorTrigger:interval` | MISSING | `connector-resilience-durations-unit-in-key` (new) | | 26 | `memory-persistence-auto-save-interval-to-ms` | `data/AutoPersistenceConfig:autoSaveInterval`, `data/FilePersistenceConfig:autoSaveInterval` | MISSING | `memory-persistence-auto-save-interval-unit-in-key` (new) | | 27 | `turso-config-timeout-to-timeout-ms` | `data/TursoConfig:timeout` | MISSING | `turso-config-timeout-unit-in-key` (new) | | 28 | `view-page-mount-removed` | `ui/ListView:pageName`, `ui/ObjectListView:pageName` | MISSING | `list-view-page-mount-retired` (new) | | 29 | `list-view-sort-string-clause-to-array` | none (value or strict-key retirement, not in the two tables) | MISSING | `list-view-sort-string-clause-retired` (new) | | 30 | `page-assigned-profiles-removed` | `ui/Page:assignedProfiles` | `page-assigned-profiles-audience-to-permission-set` | unchanged | | 31 | `chart-config-aria-removed` | `ui/ChartConfig:aria`, `ui/ReportChart:aria` | MISSING | `chart-config-aria-retired` (new) | | 32 | `dashboard-widget-chart-config-structure-removed` | `ui/DashboardWidgetChartConfig:series`, `ui/DashboardWidgetChartConfig:type`, `ui/DashboardWidgetChartConfig:xAxis`, `ui/DashboardWidgetChartConfig:yAxis` | `dashboard-widget-chart-config-structure-refused` | unchanged | | 33 | `translation-per-app-settings-removed` | none (value or strict-key retirement, not in the two tables) | `translation-per-app-settings-platform-only` | unchanged | | 34 | `object-tenancy-organization-field-removed` | `data/TenancyConfig:organizationField` | MISSING | `object-tenancy-organization-field-retired` (new) | | 35 | `page-component-filter-record-to-rule-array` | none (value or strict-key retirement, not in the two tables) | `element-data-source-and-object-block-filter-rule-array`, `object-grid-default-filters-rule-array` | unchanged | | 36 | `view-item-owner-hidden-removed` | `ui/ViewItemWire:hidden`, `ui/ViewItemWire:owner`, `ui/ViewItem:hidden`, `ui/ViewItem:owner` | MISSING | `view-item-owner-hidden-retired` (new) | ### Table 2 — D2-less records, grouped by the existing D3 entry that names them | D3 entry (existing) | records it names | |---|---| | `advanced-plugin-lifecycle-config-retired` | `kernel/AdvancedPluginLifecycleConfig`, `kernel/GracefulDegradation`, `kernel/PluginUpdateStrategy` | | `ai-conversation-analytics-duration-unit-in-key` | `ai/ConversationAnalytics:duration` | | `api-error-retry-after-unit-in-key` | `api/EnhancedApiError:retryAfter` | | `api-runtime-config-durations-unit-in-key` | `api/DataLoaderConfig:cacheTtl`, `api/RouteDefinition:timeout` | | `automation-flow-list-route-retired` | `api/FlowSummary`, `api/ListFlowsRequest`, `api/ListFlowsResponse` | | `automation-runs-cursor-retired` | `api/ListRunsRequest:cursor` | | `branded-identifier-schemas-retired` | `shared/AppName`, `shared/FieldName`, `shared/FlowName`, `shared/ObjectName`, `shared/RoleName`, `shared/ViewName` | | `change-management-duration-keys-retired` | `system/ChangeImpact:downtime.durationMinutes`, `system/ChangeRequest:implementation.steps.estimatedMinutes`, `system/RollbackPlan:steps.estimatedMinutes` | | `change-management-family-retired` | `system/ChangeImpact`, `system/ChangePriority`, `system/ChangeRequest`, `system/ChangeStatus`, `system/ChangeType`, `system/RollbackPlan` | | `cli-command-contribution-retired` | `kernel/CLICommandContribution` | | `cloud-subpath-retired` | 62 records, all `cloud/` defs | | `data-file-value-duration-unit-in-key` | `data/FileValue:duration` | | `data-nosql-query-options-timeout-unit-in-key` | `data/NoSQLQueryOptions:timeout` | | `device-request-response-interval-unit-in-key` | `api/DeviceRequestResponse:interval` | | `driver-options-timeout-to-timeout-ms` | `data/DriverOptions:timeout` | | `epoch-instant-keys-renamed` | `api/SimplePresenceState:lastSeen`, `api/WebSocketEvent:timestamp`, `kernel/HealthStatus:timestamp`, `kernel/KernelContext:startTime`, `kernel/TenantRuntimeContext:startTime` | | `esignature-config-deadline-keys-retired` | `data/ESignatureConfig:expirationDays`, `data/ESignatureConfig:reminderDays` | | `event-name-schema-retired` | `shared/EventName` | | `export-job-family-retired` | 13 records, all `api/`, `automation/` defs | | `hot-reload-inert-state-strategies-retired` | `kernel/DistributedStateConfig` | | `hot-reload-watch-placeholder-retired` | `kernel/HotReloadConfig:watchPatterns` | | `identity-api-key-schema-retired` | `identity/ApiKey` | | `incident-response-deadline-keys-retired` | `system/IncidentNotificationMatrix:escalationTimeoutMinutes`, `system/IncidentNotificationRule:regulatorDeadlineHours`, `system/IncidentNotificationRule:withinMinutes`, `system/IncidentResponsePhase:targetHours`, `system/IncidentResponsePolicy:retentionDays`, `system/IncidentResponsePolicy:triageDeadlineHours` | | `incident-response-family-retired` | `system/Incident`, `system/IncidentCategory`, `system/IncidentNotificationMatrix`, `system/IncidentNotificationRule`, `system/IncidentResponsePhase`, `system/IncidentResponsePolicy`, `system/IncidentSeverity`, `system/IncidentStatus` | | `kernel-compatibility-matrix-estimated-migration-time-unit-in-key` | `kernel/CompatibilityMatrixEntry:estimatedMigrationTime` | | `kernel-context-preview-mode-retired` | `kernel/KernelContext:previewMode`, `kernel/PreviewModeConfig`, `kernel/TenantRuntimeContext:previewMode` | | `kernel-event-bus-retention-unit-in-key` | `kernel/EventPersistence:retention`, `kernel/EventSourcingConfig:retention` | | `kernel-health-check-and-hot-reload-durations-unit-in-key` | `kernel/HotReloadConfig:debounceDelay`, `kernel/PluginHealthCheck:interval`, `kernel/PluginHealthCheck:timeout` | | `kernel-package-lifecycle-durations-unit-in-key` | `kernel/MultiVersionSupport:rollout.duration`, `kernel/PackageDependencyResolutionResult:resolvedIn`, `kernel/UpgradePlan:estimatedDuration` | | `kernel-plugin-health-report-durations-unit-in-key` | `kernel/PluginHealthReport:metrics.responseTime`, `kernel/PluginHealthReport:metrics.uptime` | | `kernel-plugin-security-durations-unit-in-key` | `kernel/KernelSecurityPolicy:auditLog.retention`, `kernel/KernelSecurityPolicy:authentication.tokenExpiration`, `kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime` | | `kernel-runtime-config-timeout-unit-in-key` | `kernel/RuntimeConfig:resourceLimits.timeout`, `kernel/SandboxConfig:process.timeout` | | `kernel-startup-orchestrator-durations-unit-in-key` | `kernel/PluginStartupResult:duration`, `kernel/StartupOptions:timeout`, `kernel/StartupOrchestrationResult:totalDuration` | | `list-view-navigation-view-retired` | `ui/NavigationConfig:view` | | `logging-durations-unit-in-key` | `system/HttpDestinationConfig:batch.flushInterval`, `system/HttpDestinationConfig:retry.initialDelay`, `system/HttpDestinationConfig:timeout`, `system/LoggingConfig:buffer.flushInterval` | | `metadata-changed-event-payload-retired` | `kernel/MetadataChangeOperation`, `kernel/MetadataChangedEventPayload` | | `metadata-customization-protocol-retired` | 13 records, all `api/`, `kernel/` defs | | `metadata-manager-config-cache-ttl-unit-in-key` | `kernel/MetadataManagerConfig:cache.ttl` | | `metadata-manager-config-inert-cache-keys-retired` | `kernel/MetadataManagerConfig:cache.enabled`, `kernel/MetadataManagerConfig:cache.maxSize`, `kernel/MetadataManagerConfig:cache.ttlSeconds` | | `metadata-plugin-additional-types-retired` | `kernel/MetadataPluginConfig:additionalTypes` | | `package-rollback-response-retired` | `api/PackageRollbackResponse` | | `packages-list-pagination-retired` | `api/ListInstalledPackagesRequest:cursor`, `api/ListInstalledPackagesRequest:limit` | | `plugin-auto-restart-never-reinitialised` | `kernel/PluginHealthCheck:autoRestart`, `kernel/PluginHealthCheck:maxRestartAttempts`, `kernel/PluginHealthCheck:restartBackoff` | | `plugin-manifest-contributes-dead-members-retired` | `kernel/Manifest:contributes.actions`, `kernel/Manifest:contributes.commands`, `kernel/Manifest:contributes.drivers`, `kernel/Manifest:contributes.events`, `kernel/Manifest:contributes.fieldTypes`, `kernel/Manifest:contributes.functions`, `kernel/Manifest:contributes.menus`, `kernel/Manifest:contributes.themes`, `kernel/Manifest:contributes.translations` | | `plugin-manifest-contributes-routes-retired` | `kernel/Manifest:contributes.routes` | | `plugin-manifest-dead-containers-retired` | `kernel/Manifest:capabilities`, `kernel/Manifest:configuration`, `kernel/Manifest:extensions` | | `plugin-manifest-kind-globs-retired` | `kernel/Manifest:contributes.kinds.globs` | | `plugin-security-scan-result-surface-retired` | `kernel/KernelSecurityScanResult`, `kernel/KernelSecurityVulnerability`, `kernel/PluginQualityMetrics:securityScan`, `kernel/PluginSecurityManifest:scanResults`, `kernel/PluginSecurityManifest:vulnerabilities` | | `rest-api-endpoint-handler-status-retired` | `api/HandlerStatus`, `api/RestApiEndpoint:handlerStatus`, `api/RouteCoverageEntry`, `api/RouteCoverageReport` | | `rest-api-plugin-durations-unit-in-key` | `api/RestApiEndpoint:cacheTtl`, `api/RestApiEndpoint:timeout`, `api/RestApiPluginConfig:performance.defaultCacheTtl` | | `rest-server-config-dead-keys-retired` | 11 records, all `api/` defs | | `session-user-language-retired` | `api/SessionUser:language` | | `stack-themes-carrier-retired` | `ui/BorderRadius`, `ui/ColorPalette`, `ui/Shadow`, `ui/Theme`, `ui/ThemeMode`, `ui/Typography` | | `startup-orchestrator-retired` | `kernel/HealthStatus`, `kernel/PluginStartupResult:health`, `kernel/PluginStartupResult:plugin`, `kernel/PluginStartupResult:startTime`, `kernel/StartupOptions`, `kernel/StartupOrchestrationResult` | | `system-cache-durations-unit-in-key` | `system/CacheAvalanchePrevention:circuitBreaker.resetTimeout`, `system/CacheTier:ttl` | | `system-collaboration-durations-unit-in-key` | `system/CollaborationSessionConfig:idleTimeout`, `system/CollaborationSessionConfig:snapshot.interval` | | `system-failover-health-check-interval-unit-in-key` | `system/FailoverConfig:healthCheckInterval` | | `system-metrics-jsdoc-durations-unit-in-key` | `system/MetricDefinition:summary.maxAge`, `system/MetricExportConfig:interval`, `system/MetricsConfig:collectionInterval`, `system/MetricsConfig:retention.period`, `system/ServiceLevelObjective:errorBudget.burnRateWindows.window` | | `system-metrics-window-durations-unit-in-key` | `system/MetricAggregationConfig:window.size`, `system/ServiceLevelIndicator:window.size`, `system/ServiceLevelObjective:period.duration` | | `system-object-storage-durations-unit-in-key` | `system/AccessControlConfig:maxAge`, `system/StorageConnection:timeout` | | `system-registry-config-durations-unit-in-key` | `system/RegistryConfig:cache.ttl`, `system/RegistryUpstream:syncInterval`, `system/RegistryUpstream:timeout` | | `system-tracing-otel-exporter-durations-unit-in-key` | `system/OpenTelemetryCompatibility:exporter.batch.exportTimeout`, `system/OpenTelemetryCompatibility:exporter.batch.scheduledDelay`, `system/OpenTelemetryCompatibility:exporter.timeout`, `system/TracingConfig:performance.exportInterval` | | `system-tracing-span-duration-unit-in-key` | `system/Span:duration` | | `system-worker-queue-rate-limit-duration-unit-in-key` | `system/QueueConfig:rateLimit.duration` | | `tenant-schema-cache-ttl-unit-in-key` | `system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL` | | `training-deadline-keys-retired` | `system/TrainingCourse:durationMinutes`, `system/TrainingCourse:validityDays`, `system/TrainingPlan:gracePeriodDays`, `system/TrainingPlan:recertificationIntervalDays`, `system/TrainingPlan:reminderDaysBefore` | | `training-family-retired` | `system/TrainingCategory`, `system/TrainingCompletionStatus`, `system/TrainingCourse`, `system/TrainingPlan`, `system/TrainingRecord` | | `websocket-durations-unit-in-key` | `api/WebSocketConfig:pingInterval`, `api/WebSocketConfig:reconnectInterval`, `api/WebSocketConfig:timeout`, `api/WebSocketServerConfig:heartbeatInterval` | ## Prose corrected (the single-entry sites of `5854110917` / `5854456343`, and the rationale sentences) | site (at this head) | was | now | |---|---|---| | `packages/spec/src/migrations/registry.ts:78–86` (step 17 docblock, fact correction only) | 「Mechanical, and mechanical only … there is no semantic residue and the `semantic` list is deliberately empty」 | the three renames replay losslessly as D2; they carry no D3 entry because step 17 shipped before the rule and was not back-filled; the `semantic` list is NOT empty. ⛔ No step-17 entry added. | | `packages/spec/src/migrations/registry.ts:5256` (step 18 rationale, `tenancy.organizationField`) | 「The conversion is a lossless delete and there is no semantic residue」 | a lossless delete still leaves the author a judgment, carried by `object-tenancy-organization-field-retired` | | `packages/spec/src/conversions/registry.ts:3460` (`datasource-driver-mongo-to-mongodb`, protocol 17) | 「Why D2 and not D3」 | 「Why the data repair is D2」, plus: losslessness does not decide whether a family owes D3; this one is protocol 17 and has none | | `packages/spec/src/conversions/registry.ts:9312` (`api-endpoint-cache-ttl-to-cache-ttl-seconds`) | 「gets a conversion rather than a semantic entry」 | 「also gets a conversion」, and names its D3 entry | | `packages/spec/src/conversions/registry.ts:9804` (`list-view-sort-string-clause-to-array`) | 「which is why this is a D2 conversion rather than a semantic TODO」 | the data repair is D2; the family's D3 entry carries the clauses the rewrite leaves alone | | `packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts:11–19` | 「a D2 CONVERSION rather than a semantic entry」 | also a D2 conversion, and names the D3 entry | | `packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts:11–13` | 「exactly the residue D2 cannot express, which is why this is a semantic entry」 | that residue is why there is no D2 at all; the D3 entry is owed either way | | `packages/spec/scripts/build-migration-registry.ts:276` | 「a major whose semantic residue is genuinely nil (protocol 14)」 | an empty region is a real state (a freshly opened step, or protocol 14's, which predated the rule) | ⛔ Not touched: the governed texts (ADR-0087, `.claude/skills/spec-property-retirement/SKILL.md` §3), which are #20188's. ## The census pin **Where:** `packages/spec/src/migrations/migrations.test.ts` › `registry integrity`: `from protocol 18 on, every graduated D2 conversion is named by a D3 entry of its own step (ruling B)`, plus a control test. It reads the major's `entries/semantic/` files (comment and literal) inside its own package; `check:migration-registry` already proves those files and the generated region are one set. **What it asserts:** for every step whose `toMajor` is 18 or later, every id in `conversionIds` appears as a whole id in at least one `semantic/` entry of that major. A new major-18 (or later) retirement that lands a D2 conversion with no D3 entry naming it goes red, naming the conversion. **What it cannot see**, stated so a green run is not over-read: (1) whether the naming entry is that family's OWN (a passing mention satisfies it; the census judged ownership by reading); (2) a family retired with no conversion at all (no machine-readable link joins a retired key or def to its D3 entry; the census paired those by reading, and found none missing). Protocol 17 is outside the pin by design: measured with the same logic, 53 of its 57 graduated conversions are named by no step-17 entry, and step-17 backfill is out of scope (triage `5854164872`). **Reverse verification (one-shot, no permanent test file).** At `c8656ad35`, with the entries committed: deleted `18.object-tenancy-organization-field-retired.ts` (absence confirmed on disk before the run), ran the pin: `× from protocol 18 on …` with `+ "protocol 18: object-tenancy-organization-field-removed"`, `Tests 1 failed | 140 skipped`. Restored with `git checkout HEAD -- PATH` (that path) inside a `trap … EXIT INT TERM`: blob `2cf3007d9fff` equals HEAD's, `git diff HEAD` empty. Direction observed: red, the expected one. No build involved: the test imports `src/` and reads the entry files directly. ## Patch round 1 (contract review `5857834457`: FAIL at `3197fce29`) **Blocking: fixed.** `Lint & Repo Gates` step 189 (`check-issue-citations.mjs`, judging pass) was red. Four bare citations this PR added answer 404 on the board: #10329, #10926, #12868 and #14676. Each was in an entry's leading comment, and again in the regenerated region. `--probe-cause` classes all four as **deleted** (the web endpoint also answers 404, so none was transferred), so none of them is a reference to another repository to qualify. Each comment now anchors to the commit in this repository's history that retired the family, and says in words what that commit decided. That is the precedent of commit `66e266c93` (ruling C+D on #19123). Every sha is an ancestor of `origin/main`: | entry | was | now anchored to | |---|---|---| | `mapping-lookup-params-retired` | #10329 | commit `15d58dbf1` (the import path never read the four lookup steering params) | | `translation-component-submit-label-retired` | #10926 | commit `d173125fb` (the copy key left with its only declarer, `element:form`) | | `form-view-option-default-retired` | #12868 | commit `c459da6bc` (the ruled narrowing: the form-view face drops per-option `default`, the object-field face keeps it enforced) | | `connector-error-mapping-retired` | #14676 | commit `13c48c2a5` (eleven inert keys, one spelled like the live `userMessage` channel) | Only the comments changed; no string an author is shown moves. The region was regenerated with `gen:migration-registry`. The round-1 report's `pnpm check:issue-citations :: exit 0` was the package script, which runs only the `--self-test`. The judging pass CI runs was exit 2 at `3197fce29` (8 findings = 4 numbers × 2 sites) and is exit 0 now (below). **Pin message.** The census pin's assertion now names the unnamed `protocol N: conversion-id` pairs and the remedy: add a D3 `semantic` entry of that step whose text names the conversion id as a whole word. Its logic and scope are unchanged. Shown firing at `21418c4d2` with one entry removed (trap-guarded restore, blob equal to HEAD's, `git diff HEAD` empty): `AssertionError: graduated D2 conversion(s) named by no D3 entry of their own step: protocol 18: object-tenancy-organization-field-removed. Remedy: add a D3 semantic entry of that step …`, `Tests 1 failed | 140 passed`. **Body.** Table 1: `integration/Connector:connectionTimeoutMs` moved from row 16 to row 20. Its own comment names `connector-connection-timeout-ms-removed`; the permission id appears there only as a comparison. The code was already right. ## Sibling PRs - **PR #20238** (#20161) has since LANDED as `6a6a17b62`, with the D2 conversion `report-joined-chart-removed` and `18.ui-report-joined-chart-retired.ts`, which names that id. The union of this head with `main` at `6a6a17b62` is clean and passes the pin (37 pairs, 0 unnamed; delta review `5859315908`). - `main` was merged three times with `os-regen-merge.sh`, and never by hand in a generated region: at `a70cd62e5` (#20223 and #20245), at `21418c4d2` (`cel-predicate-one-value-comparand-refused` and `filter-query-face-comparands-refused-at-save`) and at `a930cacea` (step-17 rationale prose from #20268). Each is D3-only or prose, with no new step-18 conversion. Regeneration produced a commit only after the first merge (`3197fce29`) and changed nothing after the other two. Every sibling entry id was verified present. ## Verification (head `a930cacea`) - `pnpm check:issue-citations && node scripts/check-issue-citations.mjs`, exactly as CI runs it (base `origin/main`): **exit 0**, 112 citations across 29 files: 106 resolve, 6 cross-repo unjudged, 0 findings. At `3197fce29` the same command exited 2. - `pnpm --filter @objectstack/spec build` under the verify lock: ok. `check:generated`: all 15 generated artifacts up to date. `check:migration-registry`: current (292 semantic, 214 retired-key, 199 retired-def). `spec-changes.json` and `docs/protocol-upgrade-guide.md` do not move: they project up to the current protocol major, and step 18 is beyond it. - `pnpm --filter @objectstack/spec exec vitest run --project local`: **552 files, 16257 passed, 1 todo**. The `src/migrations/` directory alone: 3 files, 151 passed. - `pnpm --filter @objectstack/spec typecheck` (tsc, scripts, test layer) at `21418c4d2`, the head before the last merge, which brought only another PR's prose into this diff's files: exit 0, test-typecheck debt unchanged (53 files / 255 errors / 142 signatures). - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `a930cacea`, every command run and its exit recorded, reconciled with `--ran`: **89 derived, 87 run (all exit 0), 2 NOT MEASURED**. `check:dual-build-cjs-loads` and `check:type-check-debt` exited 3 (PREREQUISITE NOT MET: the full 86-package workspace build does not fit the foreground cap on this shared box). CI runs both. `check:pm-dispatch-gates` finished this time: exit 0, in 907.6 s. - ESLint, narrowed and proven: all 31 changed `.ts` files, `--no-inline-config --format json`: 0 errors, 0 warnings, none reported ignored. `eslint.config.mjs` enables no type-aware linting (its own statement at `eslint.config.mjs:326–328`), so this diff cannot move any untouched file's verdict. - Changeset: `@objectstack/spec` `patch`. The published registry text changes; no accept set moves. ## Acceptance notes (observed, not filed) - `registry.ts:108` (released step-17 text) still says the sharing-rule `full` conversion 「leaves no semantic residue」: triage scoped step-17 backfill out, so it is left as is. - New entries keep tracker numbers in their `//` comments only, never in the strings an author is shown (AGENTS.md runtime-strings rule). Several older entries do cite numbers in `reason`; not touched. - `dashboard-refresh-interval-unit-in-key` states the console renderer's release lag as a verification step, not as a present fact: this container has no objectui checkout at the pin to measure it. - `main` moved after the last merge (`a930cacea`). #20285 (`2aa25efb4`, prose in five semantic entries) and #20238 (`6a6a17b62`, a new step-18 conversion with its entry) landed under `migrations/` and `conversions/`. The delta review merged this head onto `6a6a17b62`: clean, with all six regions still mirrored. The queue verifies the merged generation. (Corrected by the seat at 2026-09-27T19:57Z; the earlier wording said nothing under those paths had moved.) --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 28ad7e4 commit f415bcf

32 files changed

Lines changed: 1756 additions & 36 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): every protocol-18 retirement family now carries its D3 entry, including the 25 whose data repair is a lossless D2 conversion (#20201)
6+
7+
Clause-②: no
8+
9+
ADR-0087 D3 requires one semantic (D3) entry per retirement family, even when a
10+
lossless D2 conversion already repairs the data: D2 carries the mechanical repair
11+
only, and the D3 entry says what the consumer still has to decide. Twenty-five
12+
protocol-18 families shipped a D2 conversion and no D3 entry, some of them
13+
justified by "lossless, so no semantic residue". `MIGRATIONS_BY_MAJOR[18].semantic`
14+
gains one entry per family, so `os migrate meta` lists each as a TODO on the
15+
17 → 18 hop, with its reason and acceptance criteria. Among them:
16+
17+
- the seven duration renames (`hook.timeout`, `job.timeout`, `apis[].cacheTtl`,
18+
`dashboard.refreshInterval`, the connector health / trigger durations, the memory
19+
driver's `autoSaveInterval` and the turso `timeout`). The rename keeps the value,
20+
so only the author can say whether it was ever written in the unit the new key
21+
names.
22+
- `object.tenancy.organizationField`, `view.owner` / `view.hidden`,
23+
`permission.objects.*.allowRestore` / `allowPurge` and the list-view `page` mount.
24+
Each delete is lossless, and each leaves a belief the author held that the
25+
platform never honoured.
26+
27+
No accept set moves and no conversion changes. The registry's own test now fails
28+
when a protocol-18-or-later step graduates a conversion that no D3 entry of that
29+
step names. The prose that justified the missing entries is corrected, and the
30+
protocol-17 docblock no longer calls that step's `semantic` list empty.

‎packages/spec/scripts/build-migration-registry.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,9 @@ const closeMarker = (kind: Kind, major: number) => ` // </os-generated ${kind
273273
* self-test drives it without touching the tree.
274274
*
275275
* A region present in the file with no entries on disk is emitted EMPTY rather
276-
* than removed — a major whose semantic residue is genuinely nil (protocol 14)
277-
* is a real state, and the marker is where its first entry will land.
276+
* than removed — an empty region is a real state (a freshly opened step, or
277+
* protocol 14's, whose step carried no D3 entry before every retirement family
278+
* was required to carry one), and the marker is where its first entry will land.
278279
*/
279280
export function renderRegistry(source: string, byKind: Record<Kind, Entry[]>): { text: string; errors: string[] } {
280281
const errors: string[] = [];

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3459,7 +3459,7 @@ const datasourceConfigDriverKeyAliases: MetadataConversion = {
34593459
* rows. So the stored value converges here rather than each reader learning to
34603460
* accept both.
34613461
*
3462-
* ## Why D2 and not D3
3462+
* ## Why the data repair is D2
34633463
*
34643464
* There is a concrete stored value with a lossless, behaviour-preserving
34653465
* rewrite, which is the D2 test exactly. `mongo` and `mongodb` resolve to the
@@ -3468,6 +3468,12 @@ const datasourceConfigDriverKeyAliases: MetadataConversion = {
34683468
* {@link datasourceConfigDriverKeyAliases}, whose scope guard exists because
34693469
* rewriting a sqlite `path:` WOULD have moved a database.
34703470
*
3471+
* Losslessness decides only that the data repair is D2. It does not decide
3472+
* whether the family ALSO owes a D3 entry — every retirement family does
3473+
* (`SemanticMigration`, `migrations/types.ts`). This one converts into
3474+
* protocol 17, whose step shipped before that rule and was not back-filled,
3475+
* so it has none.
3476+
*
34713477
* ## Why it stays on the LIVE load path
34723478
*
34733479
* Unlike the key-alias conversion above, `mongo` is not a spelling the authoring
@@ -9305,10 +9311,13 @@ const jobTimeoutToTimeoutMs: MetadataConversion = {
93059311
* `apis[].cacheTtl` → `apis[].cacheTtlSeconds` (protocol 18, #15677 for #14478)
93069312
* — the `api` half of the same rename `hookTimeoutToTimeoutMs` and
93079313
* `jobTimeoutToTimeoutMs` document, and the ONE key of that card's twelve that
9308-
* gets a conversion rather than a semantic entry: `apis:` is a stack collection
9314+
* also gets a conversion: `apis:` is a stack collection
93099315
* (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered metadata kind
93109316
* stored as a row, so the chain has a seam that sees it. The other eleven are
9311-
* wire payloads and construction arguments the chain never touches.
9317+
* wire payloads and construction arguments the chain never touches, so their
9318+
* D3 entries are their only channel. This key's family carries a D3 entry too,
9319+
* `api-endpoint-cache-ttl-unit-in-key`: the rename keeps the value, and only
9320+
* the author can say whether the value was ever in seconds.
93129321
*
93139322
* Same posture as its two siblings: retired from the load path, tombstoned at
93149323
* the schema, replayable here. The fixture keeps `rateLimit` out of the
@@ -9794,12 +9803,14 @@ const viewPageMountRemoved: MetadataConversion = {
97949803
* upstream and failed downstream, and the author was told off by the wrong
97959804
* layer.
97969805
*
9797-
* The rewrite is lossless and wholly mechanical, which is why this is a D2
9798-
* conversion rather than a semantic TODO: `'created_at desc'` carries exactly
9799-
* the tuple `{ field: 'created_at', order: 'desc' }`; a bare field name meant
9800-
* ASCENDING, so it is written out as `order: 'asc'` rather than omitted
9801-
* (`order` is required on the entry); and the comma-separated multi-key form
9802-
* the wire normalizer splits on becomes one entry per key, in the same order.
9806+
* The rewrite is lossless and wholly mechanical, which is why the data repair
9807+
* is a D2 conversion (the family's D3 entry,
9808+
* `list-view-sort-string-clause-retired`, carries the clauses this rewrite
9809+
* leaves alone): `'created_at desc'` carries exactly the tuple
9810+
* `{ field: 'created_at', order: 'desc' }`; a bare field name meant ASCENDING,
9811+
* so it is written out as `order: 'asc'` rather than omitted (`order` is
9812+
* required on the entry); and the comma-separated multi-key form the wire
9813+
* normalizer splits on becomes one entry per key, in the same order.
98039814
*
98049815
* ⚠️ A string that does NOT parse as that grammar is left ALONE and emits
98059816
* nothing — the `'-field'` OData-ish dialect above all. That dialect belongs to

‎packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
// `cacheTtlSeconds`; the value is unchanged and the key stays GET-only.
99
// Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare
1010
// deletion would strip the old key in silence, and the unknown-key error could
11-
// not carry the rename. This is the ONE key of this card's twelve that gets a
12-
// D2 CONVERSION rather than a semantic entry: `apis:` is a stack collection
13-
// (`stack.zod.ts` — `apis: z.array(ApiEndpointSchema)`) and an `api` is a
14-
// registered metadata kind stored as a row, so the conversion chain has a seam
15-
// that sees it. `api-endpoint-cache-ttl-to-cache-ttl-seconds` rewrites it,
16-
// retired from the load path (no alias window). Registered under 18 for the
17-
// launch-window reason its neighbours state.
11+
// not carry the rename. This is the ONE key of this card's twelve that also
12+
// gets a D2 CONVERSION: `apis:` is a stack collection (`stack.zod.ts` —
13+
// `apis: z.array(ApiEndpointSchema)`) and an `api` is a registered metadata
14+
// kind stored as a row, so the conversion chain has a seam that sees it.
15+
// `api-endpoint-cache-ttl-to-cache-ttl-seconds` rewrites it, retired from the
16+
// load path (no alias window); the family's D3 entry is
17+
// `api-endpoint-cache-ttl-unit-in-key`, because a rename that keeps the value
18+
// cannot say whether the value was ever in seconds. Registered under 18 for
19+
// the launch-window reason its neighbours state.
1820
export const entry = 'api/ApiEndpoint:cacheTtl';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #15677 (stack card 2/6 of #14478, maintainer ruling B: a duration key
6+
// carries its unit in its NAME) — the D3 entry of the
7+
// `api-endpoint-cache-ttl-to-cache-ttl-seconds` family (ruling B on #17152:
8+
// one D3 entry per retirement family, even when D2 is lossless). The card's
9+
// other eleven keys have no conversion and carry their own D3 entries; this
10+
// one has both.
11+
export const entry: SemanticMigration = {
12+
id: 'api-endpoint-cache-ttl-unit-in-key',
13+
surface: 'apis[].cacheTtl — the response-cache lifetime of a declared API endpoint',
14+
replacement: '`cacheTtlSeconds` — the same lifetime, in seconds, with the unit in the key name. It '
15+
+ 'still applies to GET endpoints only.',
16+
reason: 'The D2 conversion `api-endpoint-cache-ttl-to-cache-ttl-seconds` renames `cacheTtl` to '
17+
+ '`cacheTtlSeconds` in the `apis` collection and on stored endpoint rows, keeping the value, '
18+
+ 'and the rename is lossless: the key always meant seconds. The judgment is whether the '
19+
+ 'author knew that. The unit lived only in the description, on the same endpoint surface '
20+
+ 'where `rateLimit.windowMs` spells its unit in milliseconds, so a value written in '
21+
+ 'milliseconds — `cacheTtl: 60000` meant as one minute — cached responses for almost '
22+
+ 'seventeen hours, and the rename carries 60000 over unchanged. A cache that lives a '
23+
+ 'thousand times longer than intended serves stale data long after the underlying records '
24+
+ 'change, with no error anywhere. Only the author can say which unit each value was written '
25+
+ 'in.',
26+
acceptanceCriteria: 'No endpoint carries `cacheTtl`; the parse refuses it with the rename. Every '
27+
+ '`cacheTtlSeconds` value is the cache lifetime the author intends in seconds — an endpoint '
28+
+ 'meant to cache for one minute reads `cacheTtlSeconds: 60`. A GET to the endpoint repeated '
29+
+ 'inside that window is answered from the cache, and one repeated after it reflects a record '
30+
+ 'changed in between.',
31+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// Maintainer decision batch #118 item 2 (ADR-0049 enforce-or-remove) — the D3
6+
// entry of the `chart-config-aria-removed` family (ruling B on #17152: one D3
7+
// entry per retirement family, even when D2 is lossless). Registered keys:
8+
// `ui/ChartConfig:aria` and `ui/ReportChart:aria`, over three authored sites.
9+
// The strip changes nothing a screen reader hears; the accessible name the
10+
// author wrote was never announced, and moving it is the author's edit.
11+
export const entry: SemanticMigration = {
12+
id: 'chart-config-aria-retired',
13+
surface: 'dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria — '
14+
+ 'the ARIA block on a chart config',
15+
replacement: 'The sibling `description`, which the chart renderer lowers onto the chart graphic '
16+
+ 'as its accessible name (`role="img"` with an aria-label). One accessibility vocabulary per '
17+
+ 'chart node.',
18+
reason: 'The D2 conversion `chart-config-aria-removed` deletes `aria` from every dashboard widget '
19+
+ 'chart config, report chart and report block chart, and the delete is lossless: no chart '
20+
+ 'renderer on either face ever applied the block, so the ARIA attributes it declared never '
21+
+ 'reached the DOM. The residue is accessibility work the author did that no user benefited '
22+
+ 'from. An author who wrote `aria.label` for a chart believed screen-reader users heard that '
23+
+ 'name; they heard the `description` if one was set, and nothing specific if not. The strip '
24+
+ 'deletes the label text along with the key, and only the author can say whether that text '
25+
+ 'should become the chart\'s `description` — a field that other readers of the chart may also '
26+
+ 'show — or whether the existing description already says it.',
27+
acceptanceCriteria: 'No chart config on a dashboard widget, a report or a report block carries '
28+
+ '`aria`; the parse refuses it. Every chart that had carried an `aria.label` has a '
29+
+ '`description` conveying what that label was meant to announce, or the author has confirmed '
30+
+ 'the existing description does. With a screen reader, focusing the chart graphic announces '
31+
+ 'the description as its name.',
32+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// ADR-0049 enforce-or-remove — the D3 entry of the
6+
// `connector-error-mapping-removed` family, which landed in commit 13c48c2a5:
7+
// eleven inert authorable keys, one of them spelled like the live
8+
// `userMessage` channel. One D3 entry per retirement family, even when D2 is
9+
// lossless (ruling B on #17152). The family is the key on both carriers
10+
// (`integration/Connector:errorMapping`,
11+
// `integration/DeclarativeConnectorEntry:errorMapping`) and the shape that
12+
// leaves with it — `integration/ErrorMappingConfig`,
13+
// `integration/ErrorMappingRule` and `integration/ConnectorErrorCategory` in
14+
// RETIRED_DEFS_BY_MAJOR[18].
15+
export const entry: SemanticMigration = {
16+
id: 'connector-error-mapping-retired',
17+
surface: 'connector.errorMapping — the rules / defaultCategory / unmappedBehavior / logUnmapped '
18+
+ 'block and its per-rule keys, on a connector and on a stack connectors[] entry',
19+
replacement: '(removed — no connector engine maps an external error through authored rules.) '
20+
+ 'Retry behaviour is `retryConfig`, which the outbound fetch applies. No connector-level '
21+
+ 'channel shows an end user a message: an error users must read is surfaced by whatever '
22+
+ 'handles the connector call\'s failure.',
23+
reason: 'The D2 conversion `connector-error-mapping-removed` deletes the whole block from every '
24+
+ 'connector, stack entry and stored connector row, with one notice per connector, and the '
25+
+ 'delete is lossless: no provider, dispatcher or materializer ever mapped an external error '
26+
+ 'through the rules, so the eleven nested keys configured nothing. The judgment is about what '
27+
+ 'the rules were written to achieve. A rule marking an upstream code `retryable` never '
28+
+ 'changed a retry — if that retry matters, it belongs in `retryConfig`. A rule with a '
29+
+ '`userMessage` never showed that message to anyone, although the spelling matches the live '
30+
+ 'API-error channel and read as a user-facing refusal; if users need that text, whatever '
31+
+ 'handles the failed call has to surface it. `unmappedBehavior` and `logUnmapped` suppressed '
32+
+ 'or logged nothing. Which of these intents still matters is known only to the connector\'s '
33+
+ 'author.',
34+
acceptanceCriteria: 'No connector and no stack connector entry carries `errorMapping`; the parse '
35+
+ 'refuses it, and no code imports ErrorMappingConfig, ErrorMappingRule or '
36+
+ 'ConnectorErrorCategory. Calls through each connector fail and retry exactly as they did '
37+
+ 'before the upgrade. For every rule whose intent still matters: a retry the author wanted is '
38+
+ 'expressed in `retryConfig` and observed on a failing upstream, and a message the author '
39+
+ 'wanted users to read is shown to them, by the caller that handles the failure, when the '
40+
+ 'upstream fails.',
41+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #15680 (stack card of #14478, maintainer ruling B: a duration key carries its
6+
// unit in its NAME) — the D3 entry of the
7+
// `connector-health-and-trigger-durations-unit-in-key` family (ruling B on
8+
// #17152: one D3 entry per retirement family, even when D2 is lossless). The
9+
// two keys share one authored document and one conversion, so they share one
10+
// entry. Both renamed keys are still unread (the liveness ledger records each
11+
// as dead, `liveness/connector.json`): the rename is an honesty fix to the
12+
// declaration, and the entry says so rather than implying a live engine.
13+
export const entry: SemanticMigration = {
14+
id: 'connector-resilience-durations-unit-in-key',
15+
surface: 'connector.health.circuitBreaker.monitoringWindow and connector.triggers[].interval — '
16+
+ 'the two connector durations whose name carried no unit',
17+
replacement: '`monitoringWindowMs` (milliseconds) and `intervalSeconds` (seconds) — rename each '
18+
+ 'key; both values are unchanged.',
19+
reason: 'The D2 conversion `connector-health-and-trigger-durations-unit-in-key` renames both keys '
20+
+ 'in `connectors[]` and on stored connector rows, keeping each value, with a separate notice '
21+
+ 'per key so an operator sees which of its own keys moved; the rename is lossless because '
22+
+ 'each key always meant the unit its new name states. Two judgments remain. First, the units '
23+
+ 'were easy to get wrong in opposite directions: `monitoringWindow` (milliseconds) sat one '
24+
+ 'key below `resetTimeoutMs`, and the bare token `interval` means MILLISECONDS elsewhere in '
25+
+ 'this same spec while a trigger interval meant SECONDS — so a trigger written '
26+
+ '`interval: 60000` for one minute asked for once every sixteen hours or so, and the rename '
27+
+ 'keeps 60000. Second, neither key drives an engine today: no polling loop reads a trigger '
28+
+ 'interval, and no circuit breaker exists for connectors, so nothing reads the monitoring '
29+
+ 'window. An author who relied '
30+
+ 'on either for behaviour has not been getting it, before or after this rename.',
31+
acceptanceCriteria: 'No connector carries `health.circuitBreaker.monitoringWindow` or '
32+
+ '`triggers[].interval`; the parse refuses both with the rename. Every `monitoringWindowMs` '
33+
+ 'value is the window the author intends in milliseconds and every `intervalSeconds` value '
34+
+ 'the cadence the author intends in seconds — a trigger meant to poll every minute reads '
35+
+ '`intervalSeconds: 60`. No part of the deployment\'s design depends on a connector polling '
36+
+ 'on that interval or tripping on that window: where it did, the author has moved that need '
37+
+ 'to a mechanism that runs.',
38+
};

0 commit comments

Comments
 (0)