From d7345c40dcae001c9f4a89292379889cc79d4572 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:19:06 +0000 Subject: [PATCH 01/22] =?UTF-8?q?spec:=20retire=20=E2=89=A416.x=20retireme?= =?UTF-8?q?nt=20kits;=20strict=20unknown-key=20refusal=20names=20the=20upg?= =?UTF-8?q?rade=20guide=20(#25767)=20[wip]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work in progress, paused on maintainer instruction (token budget); not yet gate-green. Done so far: - conversions/registry.ts: the 50 retiredAfter-≤16.x conversions and their retired-key / retired-def tables are removed; chain floor rises 16 → 17 (MIGRATION_SUPPORT_FLOOR), MigrationFloorError names the upgrade guide. - 55 ≤16.x tombstones on strict containers deleted; retiredKey() tombstones on non-strict containers kept (a delete would strip silently). - shared/protocol-upgrade-guide.ts: RETIRED_KEY_UPGRADE_GUIDE_SENTENCE, emitted by the strict unknown-key refusal after its fix channels and ahead of the history sentence (#5955 order). - build-schemas.ts tombstone-age gate (registry row or previous-major tag proof); migrations registry.ts.template permanence docblock. - ADR-0087 amendment, upgrade guide page, changeset; cli migrate meta support floor. - Test pins rewritten for ai, automation, conversions, data, kernel, migrations, shared, security and most of ui. Left: ui/view*, ui/dashboard*, scripts liveness pins; full spec test run; cli floor-17 pins; dispatch gates; PR body and report. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- .../25767-retire-16x-retirement-kits.md | 24 + .../skills/spec-property-retirement/SKILL.md | 18 +- content/docs/references/ai/agent.mdx | 2 - content/docs/references/ai/skill.mdx | 1 - content/docs/references/api/analytics.mdx | 2 - .../docs/references/api/automation-api.mdx | 13 - content/docs/references/api/metadata.mdx | 8 - .../references/api/package-api-assembled.mdx | 3 - content/docs/references/api/package-api.mdx | 6 - content/docs/references/api/protocol.mdx | 15 - content/docs/references/api/rest-server.mdx | 4 +- .../references/automation/control-flow.mdx | 4 - content/docs/references/automation/flow.mdx | 10 - .../automation/schemaless-node-config.mdx | 5 - content/docs/references/data/field.mdx | 1 - content/docs/references/data/object.mdx | 16 - .../docs/references/integration/connector.mdx | 4 +- content/docs/references/kernel/manifest.mdx | 1 - .../references/kernel/metadata-plugin.mdx | 3 - .../references/kernel/package-registry.mdx | 2 - .../docs/references/security/permission.mdx | 1 - content/docs/references/security/rls.mdx | 1 - content/docs/references/shared/mapping.mdx | 2 +- content/docs/references/system/book.mdx | 4 +- content/docs/references/system/job.mdx | 2 - content/docs/references/system/migration.mdx | 2 - content/docs/references/ui/action.mdx | 3 - content/docs/references/ui/app.mdx | 8 - content/docs/references/ui/component.mdx | 8 - content/docs/references/ui/dashboard.mdx | 12 - content/docs/references/ui/view.mdx | 40 - content/docs/upgrading.mdx | 35 +- ...0087-metadata-protocol-upgrade-contract.md | 104 +- docs/protocol-upgrade-guide.md | 3 +- packages/cli/src/commands/migrate/meta.ts | 3 +- .../component-field-specs-liveness.test.ts | 19 +- .../lint/src/lint-liveness-properties.test.ts | 88 +- .../src/page-walk-conversion-parity.test.ts | 27 +- packages/lint/src/validate-expressions.ts | 3 +- .../lint/src/validate-object-field-refs.ts | 8 +- .../lint/src/validate-security-posture.ts | 5 +- ...ct-forward-conversion-package-body.test.ts | 5 +- .../src/artifact-forward-conversion.test.ts | 31 +- .../src/artifact-forward-conversion.ts | 10 +- .../src/object-schema-fls-references.ts | 1 - packages/spec/api-surface/shared.json | 2 + packages/spec/authorable-surface/ai.json | 3 - packages/spec/authorable-surface/api.json | 5 - .../spec/authorable-surface/automation.json | 9 - packages/spec/authorable-surface/data.json | 3 - packages/spec/authorable-surface/kernel.json | 1 - .../spec/authorable-surface/security.json | 1 - packages/spec/authorable-surface/system.json | 1 - packages/spec/authorable-surface/ui.json | 38 - packages/spec/export-origins/shared.json | 2 + packages/spec/liveness/action.json | 15 - packages/spec/liveness/agent.json | 10 - packages/spec/liveness/app.json | 41 - packages/spec/liveness/dashboard.json | 35 - packages/spec/liveness/field.json | 5 - packages/spec/liveness/flow.json | 25 - packages/spec/liveness/manifest.json | 6 - packages/spec/liveness/permission.json | 6 - packages/spec/liveness/skill.json | 5 - packages/spec/liveness/state-counts/action.md | 2 +- packages/spec/liveness/state-counts/agent.md | 2 +- packages/spec/liveness/state-counts/app.md | 2 +- .../spec/liveness/state-counts/dashboard.md | 2 +- packages/spec/liveness/state-counts/field.md | 2 +- packages/spec/liveness/state-counts/flow.md | 2 +- .../spec/liveness/state-counts/manifest.md | 2 +- .../spec/liveness/state-counts/permission.md | 2 +- packages/spec/liveness/state-counts/skill.md | 2 +- packages/spec/liveness/state-counts/view.md | 2 +- packages/spec/liveness/view.json | 54 +- ...chemas-check-mode.deleted-baseline.test.ts | 2 +- ...schemas-check-mode.deletion-proofs.test.ts | 130 +- packages/spec/scripts/build-schemas.ts | 183 +- packages/spec/scripts/lib/renamed-defs.ts | 3 +- packages/spec/src/ai/agent.test.ts | 22 +- packages/spec/src/ai/agent.zod.ts | 51 - packages/spec/src/ai/skill.test.ts | 9 +- packages/spec/src/ai/skill.zod.ts | 19 - ...onfirmation-prescription-tense.pin.test.ts | 27 +- packages/spec/src/ai/tool.zod.ts | 7 +- packages/spec/src/api/analytics.zod.ts | 12 - packages/spec/src/api/rest-server.zod.ts | 2 +- .../src/automation/builtin-node-config.zod.ts | 4 +- .../spec/src/automation/control-flow.test.ts | 15 +- .../flow-builtin-node-config-keys.test.ts | 32 +- .../flow-builtin-node-config-values.test.ts | 10 +- .../flow-sibling-block-loose-keys.ts | 4 +- packages/spec/src/automation/flow.test.ts | 65 +- packages/spec/src/automation/flow.zod.ts | 86 - .../spec/src/automation/io-node-config.zod.ts | 10 +- .../automation/schemaless-node-config.test.ts | 55 +- .../automation/schemaless-node-config.zod.ts | 65 +- .../spec/src/conversions/conversions.test.ts | 646 +- .../conversions/page-component-walk.test.ts | 33 +- packages/spec/src/conversions/registry.ts | 5897 +++-------------- packages/spec/src/conversions/stored.test.ts | 135 +- .../conversions/view-spelling-walk.test.ts | 194 +- packages/spec/src/conversions/walk.ts | 14 +- .../data/analytics-strictness-batchd.test.ts | 14 +- packages/spec/src/data/datasource.zod.ts | 12 +- packages/spec/src/data/driver.zod.ts | 5 +- .../src/data/driver/config-registry.zod.ts | 6 +- packages/spec/src/data/field.test.ts | 38 +- packages/spec/src/data/field.zod.ts | 16 - packages/spec/src/data/hook-body.zod.ts | 2 +- packages/spec/src/data/mapping.zod.ts | 6 +- .../data/object-strictness-batch20.test.ts | 32 +- packages/spec/src/data/object.test.ts | 37 +- packages/spec/src/data/object.zod.ts | 40 +- packages/spec/src/data/query.zod.ts | 2 +- .../spec/src/integration/connector.zod.ts | 2 +- .../src/kernel/manifest-unknown-keys.test.ts | 12 +- packages/spec/src/kernel/manifest.zod.ts | 39 - .../kernel/plugin-loading-retirement.test.ts | 87 +- .../plugin-runtime-tier-truthful-text.test.ts | 109 +- packages/spec/src/migrations/chain.ts | 8 +- .../spec/src/migrations/entries/README.md | 9 +- .../retired-defs/17.api__CreateViewRequest.ts | 4 - .../17.api__CreateViewResponse.ts | 3 - .../retired-defs/17.api__DeleteViewRequest.ts | 3 - .../17.api__DeleteViewResponse.ts | 3 - .../retired-defs/17.api__GetViewRequest.ts | 3 - .../retired-defs/17.api__GetViewResponse.ts | 3 - .../retired-defs/17.api__ListViewsRequest.ts | 3 - .../retired-defs/17.api__ListViewsResponse.ts | 3 - .../retired-defs/17.api__UpdateViewRequest.ts | 3 - .../17.api__UpdateViewResponse.ts | 3 - .../17.automation__ETLDestination.ts | 4 - .../17.automation__ETLEndpointType.ts | 3 - .../17.automation__ETLPipeline.ts | 3 - .../17.automation__ETLPipelineRun.ts | 3 - .../17.automation__ETLRunStatus.ts | 3 - .../retired-defs/17.automation__ETLSource.ts | 3 - .../17.automation__ETLSyncMode.ts | 3 - .../17.automation__ETLTransformation.ts | 3 - .../17.automation__ETLTransformationType.ts | 3 - .../17.data__ExternalDataSource.ts | 14 - .../17.data__ExternalFieldMapping.ts | 12 - .../retired-defs/17.data__ExternalLookup.ts | 11 - .../retired-defs/17.kernel__PluginCaching.ts | 16 - .../17.kernel__PluginCodeSplitting.ts | 3 - .../17.kernel__PluginDependencyResolution.ts | 3 - .../17.kernel__PluginDynamicImport.ts | 3 - .../17.kernel__PluginHotReload.ts | 3 - .../17.kernel__PluginInitialization.ts | 3 - .../17.kernel__PluginLoadingConfig.ts | 3 - .../17.kernel__PluginLoadingStrategy.ts | 3 - .../17.kernel__PluginPerformanceMonitoring.ts | 3 - .../17.kernel__PluginPreloadConfig.ts | 3 - .../17.kernel__PluginSandboxing.ts | 3 - .../17.shared__FieldMappingTransform.ts | 3 - .../retired-defs/17.system__ConsumerConfig.ts | 7 - .../17.system__DeadLetterQueue.ts | 9 - .../17.system__MessageQueueConfig.ts | 14 - .../17.system__MessageQueueProvider.ts | 9 - .../17.system__ServerCapabilities.ts | 4 - .../retired-defs/17.system__ServerEvent.ts | 3 - .../17.system__ServerEventType.ts | 3 - .../retired-defs/17.system__ServerStatus.ts | 3 - .../retired-defs/17.system__TopicConfig.ts | 8 - .../entries/retired-defs/17.ui__DateFormat.ts | 3 - .../entries/retired-defs/17.ui__I18nObject.ts | 3 - .../retired-defs/17.ui__LocaleConfig.ts | 3 - .../retired-defs/17.ui__NumberFormat.ts | 3 - .../entries/retired-defs/17.ui__PluralRule.ts | 3 - .../retired-defs/17.ui__WidgetEvent.ts | 3 - .../retired-defs/17.ui__WidgetLifecycle.ts | 3 - .../retired-defs/17.ui__WidgetManifest.ts | 3 - .../retired-defs/17.ui__WidgetProperty.ts | 3 - .../retired-defs/17.ui__WidgetSource.ts | 3 - .../17.api__AuthFeaturesConfig__magicLink.ts | 15 - .../17.api__AuthFeaturesConfig__passkeys.ts | 20 - ...7.api__ListNotificationsRequest__cursor.ts | 18 - ....api__ListNotificationsResponse__cursor.ts | 3 - ...7.automation__ActionDescriptor__isAsync.ts | 11 - .../17.data__AggregationNode__distinct.ts | 24 - ...7.data__DataEngineUpdateOptions__upsert.ts | 9 - .../17.data__EngineUpdateOptions__upsert.ts | 18 - .../17.data__MongoConfig__password.ts | 8 - .../17.data__MysqlConfig__password.ts | 8 - .../17.data__PostgresConfig__password.ts | 12 - .../17.data__TursoConfig__authToken.ts | 9 - ...ation__ConnectorFieldMapping__transform.ts | 3 - .../17.kernel__Manifest__loading.ts | 22 - .../17.shared__FieldMapping__transform.ts | 3 - ..._ElementRecordPickerProps__displayField.ts | 7 - ....ui__ElementRecordPickerProps__multiple.ts | 3 - ..._ElementRecordPickerProps__searchFields.ts | 3 - .../retired-keys/17.ui__ListView__bordered.ts | 9 - .../retired-keys/17.ui__ListView__striped.ts | 8 - .../17.ui__ListView__virtualScroll.ts | 9 - .../17.ui__ObjectListView__bordered.ts | 8 - .../17.ui__ObjectListView__striped.ts | 8 - .../17.ui__ObjectListView__virtualScroll.ts | 8 - .../17.ui__PageCardProps__actions.ts | 14 - .../17.ui__PageCardProps__body.ts | 3 - .../17.ui__PageHeaderProps__icon.ts | 3 - .../17.ui__PageTabsProps__type.ts | 8 - .../17.ui__RecordDetailsProps__layout.ts | 8 - .../17.action-descriptor-is-async-retired.ts | 45 - ...escriptor-resume-authority-default-flip.ts | 69 - .../17.action-session-roles-to-positions.ts | 59 - .../17.actor-user-roles-to-positions.ts | 79 - .../17.aggregation-node-distinct-retired.ts | 59 - ...nalytics-query-request-envelope-retired.ts | 19 - ....analytics-query-request-format-retired.ts | 16 - .../17.api-runtime-create-withdrawn.ts | 60 - .../semantic/17.apimethod-enum-shrink.ts | 51 - ...pproval-escalation-enabled-default-flip.ts | 50 - .../17.audit-log-action-enum-retired.ts | 77 - .../17.audit-log-action-restore-retired.ts | 82 - ...h-config-unadvertised-reserved-features.ts | 32 - ...7.authoring-schemas-strict-unknown-keys.ts | 105 - .../17.batch-options-validate-only-retired.ts | 17 - .../17.batch-row-result-schema-shape.ts | 41 - .../17.client-delete-result-success.ts | 50 - ...r-inline-authentication-publish-refused.ts | 27 - .../17.dashboard-widget-compareto-offset.ts | 31 - .../17.data-driver-find-stream-retired.ts | 40 - .../17.data-driver-query-omit-object.ts | 51 - .../semantic/17.data-engine-batch-retired.ts | 47 - .../17.data-field-changed-event-retired.ts | 42 - ...source-config-inline-credential-refused.ts | 26 - ...7.datasource-config-placeholder-refused.ts | 39 - ....datasource-config-url-userinfo-refused.ts | 36 - .../17.declarative-apis-endpoints-live.ts | 64 - ...elete-by-id-before-hook-repoint-retired.ts | 66 - ...ggregate-undeclared-key-aliases-removed.ts | 57 - ....driver-capabilities-inert-bits-removed.ts | 71 - ...7.driver-sql-distinct-bare-filter-typed.ts | 62 - .../17.engine-dotted-projection-refused.ts | 69 - .../17.engine-find-formula-filter-refused.ts | 79 - ...17.engine-find-formula-order-by-refused.ts | 67 - .../17.engine-update-upsert-retired.ts | 25 - ...enhanced-api-error-field-errors-renamed.ts | 22 - .../semantic/17.etl-pipeline-layer-retired.ts | 77 - .../entries/semantic/17.export-axis-opt-in.ts | 57 - ...7.export-field-meta-constraints-retired.ts | 64 - ...l-lookup-message-queue-families-retired.ts | 84 - .../17.field-runtime-create-withdrawn.ts | 71 - .../17.filter-regex-options-retired.ts | 68 - .../17.flow-retry-max-retries-required.ts | 34 - .../17.hook-context-session-roles-retired.ts | 43 - ...ok-register-empty-object-target-refused.ts | 54 - .../17.http-request-errors-total-retired.ts | 58 - ....http-server-runtime-vocabulary-retired.ts | 58 - ...-automations-declared-default-corrected.ts | 71 - ....job-retry-policy-constraints-tightened.ts | 25 - .../17.notification-list-cursor-retired.ts | 84 - ....package-uninstall-explicit-all-tenants.ts | 36 - .../17.plugin-activation-events-retired.ts | 56 - .../17.plugin-manifest-loading-retired.ts | 66 - .../17.plugin-runtime-family-retired.ts | 56 - .../17.position-permissions-column-retired.ts | 71 - .../17.query-array-string-agg-retired.ts | 35 - .../semantic/17.query-cursor-retired.ts | 26 - .../semantic/17.query-distinct-retired.ts | 26 - ...17.query-field-node-object-form-retired.ts | 35 - .../semantic/17.query-joins-retired.ts | 34 - .../17.query-window-functions-retired.ts | 26 - .../17.record-details-sections-object-form.ts | 47 - .../17.rest-server-openapi31-block-removed.ts | 37 - .../17.runtime-httpserver-wrapper-retired.ts | 47 - .../17.sharing-execution-context-retired.ts | 59 - .../17.sharing-rule-recipient-reconcile.ts | 54 - .../17.sort-node-direction-rejected.ts | 51 - ...17.spec-type-alias-input-suffix-retired.ts | 62 - .../17.storage-service-list-retired.ts | 74 - .../17.tool-requires-confirmation-retired.ts | 69 - ...17.ui-interaction-config-family-retired.ts | 84 - ...otification-action-embed-config-retired.ts | 75 - .../17.ui-widget-i18n-family-retired.ts | 100 - .../17.ups-delegated-from-column-retired.ts | 66 - ...ew-filter-rule-value-shaped-by-operator.ts | 66 - .../17.view-management-protocol-retired.ts | 58 - .../17.workflow-service-slot-retired.ts | 35 - .../spec/src/migrations/migrations.test.ts | 270 +- .../spec/src/migrations/registry.ts.template | 1294 +--- .../src/migrations/semantic-relevance.test.ts | 11 +- packages/spec/src/security/rls.test.ts | 22 +- packages/spec/src/security/rls.zod.ts | 27 +- packages/spec/src/security/sharing.zod.ts | 2 +- packages/spec/src/shared/index.ts | 1 + packages/spec/src/shared/mapping.test.ts | 8 +- packages/spec/src/shared/mapping.zod.ts | 2 +- .../spec/src/shared/protocol-upgrade-guide.ts | 31 + .../retired-key-migrate-sentence.test.ts | 16 +- .../shared/retired-key-tsc-diagnostic.test.ts | 33 +- packages/spec/src/shared/retired-key.ts | 14 +- packages/spec/src/shared/retry-policy.test.ts | 64 +- packages/spec/src/shared/retry-policy.zod.ts | 23 - .../spec/src/shared/strict-object.test.ts | 12 +- packages/spec/src/shared/suggestions.zod.ts | 12 +- packages/spec/src/stack.zod.ts | 3 +- packages/spec/src/system/book.zod.ts | 2 +- packages/spec/src/system/job.test.ts | 6 +- packages/spec/src/system/job.zod.ts | 5 +- .../metadata-form-zod-reconciliation.test.ts | 2 +- packages/spec/src/system/translation.zod.ts | 3 +- packages/spec/src/ui/action-params.test.ts | 20 - packages/spec/src/ui/action.test.ts | 54 +- packages/spec/src/ui/action.zod.ts | 43 +- packages/spec/src/ui/app.test.ts | 147 +- packages/spec/src/ui/app.zod.ts | 122 +- .../src/ui/aria-carrier-tombstones.test.ts | 84 +- packages/spec/src/ui/component.test.ts | 81 +- packages/spec/src/ui/component.zod.ts | 166 +- packages/spec/src/ui/dashboard.test.ts | 80 +- packages/spec/src/ui/dashboard.zod.ts | 112 +- .../spec/src/ui/list-view-export-options.ts | 2 +- packages/spec/src/ui/view.zod.ts | 62 - 316 files changed, 2324 insertions(+), 14222 deletions(-) create mode 100644 .changeset/25767-retire-16x-retirement-kits.md delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.data__ExternalDataSource.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.data__ExternalFieldMapping.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.data__ExternalLookup.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ConsumerConfig.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__DeadLetterQueue.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueConfig.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueProvider.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__TopicConfig.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts delete mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__DataEngineUpdateOptions__upsert.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__EngineUpdateOptions__upsert.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__bordered.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__virtualScroll.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__bordered.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__striped.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__virtualScroll.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts delete mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.engine-dotted-projection-refused.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.engine-find-formula-filter-refused.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.engine-update-upsert-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts create mode 100644 packages/spec/src/shared/protocol-upgrade-guide.ts diff --git a/.changeset/25767-retire-16x-retirement-kits.md b/.changeset/25767-retire-16x-retirement-kits.md new file mode 100644 index 00000000000..cec32a6eb07 --- /dev/null +++ b/.changeset/25767-retire-16x-retirement-kits.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'@objectstack/lint': patch +'@objectstack/metadata-core': patch +--- + +feat(spec,cli)!: retirement kits retired at or below protocol 16.x leave the tree; the strict unknown-key refusal names the protocol upgrade guide, and the migration floor rises to 17 + +Clause-②: no (narrowing: a key retired at or below protocol 16.x is refused as a strict unknown key without its own prescription in the error, and `os migrate meta --from 16` refuses at the support floor instead of replaying step 17) +Metadata-protocol: narrowed + +**BREAKING** for a source still carrying a key retired at or below protocol 16.x, and for an operator who runs `os migrate meta --from 16`. + +- **The rule (ADR-0087, amended 2026-10-26, #25767 ruling D2 = A).** A retirement kit — the `retiredKey()` tombstone, its `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` rows, its semantic entry and its D2 conversion — lives through the protocol major after the one it retired in and is removed when the next major is cut. At 18 that keeps the 17.x and 18.x kits and removes everything retired at or below 16.x; 19 repeats the move one major on. "Never deleted", "permanent" and "forever" in ADR-0087 are amended in place. +- **Before.** A key retired at or below 16.x met a `retiredKey()` tombstone: the parse refused it with the key's own prescription in the error, `os migrate meta --from 16` listed and applied the mechanical edit, and a stored row in that shape was rewritten at read by the ≤16.x conversions. +- **Now.** The key is gone from the shape, so a `strictObject` refuses it as an unknown key. The refusal carries one more sentence, after its fix channels and ahead of its history sentence (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, exported with `PROTOCOL_UPGRADE_GUIDE_URL` from `@objectstack/spec`): it says that keys retired before the previous major are no longer prescribed in the error and names the protocol upgrade guide, `https://objectstack.ai/docs/protocol-upgrade`, where the prescription lives; the CHANGELOG of the release that retired the key carries it too. The 17.x kits still carry their prescription in the error, and they leave when 19 is cut. +- **The floor.** `MIGRATION_SUPPORT_FLOOR` is 17. `os migrate meta --from 16` and below answer `MigrationFloorError`, whose message now names the floor and the guide link, and rewrite nothing. A source authored against protocol 16 reaches 17 on a release that still carries the step (any `@objectstack/cli` up to 18.1), then runs the current release. The house sentence a 17.x retirement's error ends with is respelled accordingly: it says the edit is applied by hand or on a release that still carries the step, and links the guide. +- **Stored rows.** A `sys_metadata` row still in a ≤16.x shape is no longer rewritten at read; canonicalize it once with `os migrate meta --stored --apply` on 18.1 or earlier. `app-hidden-to-unpublished` is among the removed conversions, so a pre-split `hidden: true` app row is no longer rewritten to `_unpublished` by any seam, and the artifact door's refusal of it by id is removed as moot (`@objectstack/metadata-core`). +- **What left.** 50 D2 conversions with `retiredAfter` at or below 16.x (5 at 15.0.0, 45 at 16.1.0) and their fixtures; 55 tombstones already `[RETIRED]` at `@objectstack/spec@17.0.0` whose container is strict; the 159 major-17 entry files (29 retired-key rows, 53 retired-def rows, 77 semantic entries) and the chain's step 17; 61 `[RETIRED]` lines of the authorable surface and their 36 liveness-ledger rows. 58 same-age tombstones whose container strips unknown keys stay, because deleting one would turn a refusal into a silent strip; the four inline-credential refusals are not retirements and are untouched. +- **The deletion gate.** `check:authorable-surface` proof 1 ages a tombstone one major (was two). A deleted `[RETIRED]` line is dated by an exact `RETIRED_KEYS_BY_MAJOR` row or, new, by the previous major's release tag: a line already `[RETIRED]` on the surface at `@objectstack/spec@.0.0` was retired before that cut. The tag is immutable, so neither a row a PR writes nor one it deletes moves the clock; where the tag cannot be read the gate dates by the registry alone and says so. No gate was added. +- **Who moves.** Measured over this repository at `963e504cf5`: no `examples/**`, `ui/` or `hotcrm` source writes a removed key (the `ui/` hits for the removed names are the console's own same-named props and type mirrors, not readers of the retired spec keys, per the liveness ledger's measurements). A consumer source still carrying one meets the strict refusal and the guide link at `os validate`. + + diff --git a/.claude/skills/spec-property-retirement/SKILL.md b/.claude/skills/spec-property-retirement/SKILL.md index c3a51b3745b..72fecbdeeb1 100644 --- a/.claude/skills/spec-property-retirement/SKILL.md +++ b/.claude/skills/spec-property-retirement/SKILL.md @@ -191,11 +191,15 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都 注册中了 `ui/FormView:aria`,protocol 11 的 `flow.node.type` 注册中了任意 `.type`(#4658)。对每个常见叶名,保证早已失效。 *条目同时启动老化时钟(#5898):* 门 (c) 读同一张表来决定这个墓碑的 - `authorable-surface/` 行何时可删(~两个 major),所以你把它写在哪个 major 下,时钟就从那个 - release 数起。比这张表更老的退役是**未申报的、因此不可删的** —— - 没有东西能诚实地给它们定日期(叶匹配曾把 `data/Index:type` 的日期从一个不相干 - 的 `flow.node.type` 定到 major 11,而 baseline 文件自己的 git 历史始于 - 17.0.0-rc.0)。要删那类行,先考证它真实的 major、补条目、在 PR 里写明。 + `authorable-surface/` 行何时可删(一个 major:退役套件活过退役所在 major 的下一个 + major,再下一个 major 切出时离开 —— ADR-0087 2026-10-26 修订,#25767;此前是 ~两个 + major),所以你把它写在哪个 major 下,时钟就从那个 release 数起。比这张表更老的退役 + 由第二个来源定日期:上一个 major 的 release tag(`@objectstack/spec@<当前 major - 1>.0.0`) + 的 authorable surface 上已经带 `[RETIRED]` 的行,退役早于那次切分,套件已活满一个 + major,可删;tag 不可读(离线)时只按表判,只会更严。两个来源都不认的墓碑**不可删** + ——叶匹配曾把 `data/Index:type` 的日期从一个不相干的 `flow.node.type` 定到 major 11, + 而 baseline 文件自己的 git 历史始于 17.0.0-rc.0。要删那类行,先考证它真实的 major、 + 补条目、在 PR 里写明。 ⚠ 永不加一行你定不了日期的:这里的估计会被之后每个门当成事实读。 - [ ] **`surface` 保持散文 —— 它不再被匹配。** 按作者写元数据的方式写 (`flow.nodes[].outputSchema`),那也是 upgrade guide 打印的。多键 conversion @@ -208,8 +212,8 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都 条目,它**一处也拦不住**:`applyConversionsToStoredItem`(钉死)、automation engine 的 flow rehydration、`applyArtifactForwardConversions`。对*改名*它意味着 「没有 alias 窗口,故意的」;对**默认值翻转**,只有确知输入早于翻转的 seam 才可重 - 放,其余按 id 退订 `excludeConversionIds` —— `app-hidden-to-unpublished` 在 artifact - 门即如此。 + 放,其余按 id 退订 `excludeConversionIds` —— `flow-decision-mode-inclusive-explicit` 在 + artifact 门即如此(`app-hidden-to-unpublished` 曾是另一例,随 ≤16.x 套件在 #25767 退役)。 - [ ] **一步 D3 链**,在 `packages/spec/src/migrations/registry.ts.template`(`registry.ts` 由 `gen:migration-registry` 从它与 `entries/` 整文件生成并被 git 忽略,在 `registry.ts` 里手改不会 进提交);`conversion.toMajor` **必须等于**该步的 major。**18 步**:conversion 只进 diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx index bea57ece8b3..7ad8a8a1fde 100644 --- a/content/docs/references/ai/agent.mdx +++ b/content/docs/references/ai/agent.mdx @@ -52,8 +52,6 @@ const result = AIModelConfigSchema.parse(data); | **lifecycle** | `never` | optional | [REMOVED] `agent.lifecycle` was removed in @objectstack/spec 17.7.0 (ADR-0049 enforce-or-remove) — no runtime ever read it: no agent moved through a declared state and no transition was ever refused. Delete the key. A phase of a conversation is a skill with its own `instructions` and `tools`, selected by its `triggerConditions` (ADR-0064); multi-step process orchestration is a Flow (ADR-0019); a record's status transitions are a `state_machine` validation rule on the object (ADR-0020). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **surface** | `Enum<'ask' \| 'build'>` | optional (default: `"ask"`) | Product surface this agent binds ('ask' \| 'build') — ADR-0063 §1 | | **skills** | `string[]` | optional | Skill names to attach (Agent→Skill→Tool architecture) | -| **tools** | `never` | optional | [REMOVED] `agent.tools` was removed in @objectstack/spec 17 — use `skills`. An agent reaches exactly the tools its surface-compatible skills declare (ADR-0064), so move each reference into a skill: a platform tool by its registered name, or `action_` for one of your own AI-exposed Actions. This is NOT a rename — there is no key the value moves to: the migration DELETES the key and emits a notice naming each tool that was listed, and you re-declare each one in a skill by hand. ADR-0064 itself still reads `Proposed` and is cloud-owned — that scopes its RUNTIME half (tool resolution, which lives in cloud `service-ai`), not this rejection: the authoring invariant binds you here, and ADR-0109 (Accepted — implemented) is the in-repo record that carries it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **knowledge** | `never` | optional | [REMOVED] `agent.knowledge` was removed in @objectstack/spec 17.0.0 (audit close-out) — declaring knowledge sources/indexes on an agent never scoped retrieval: the `search_knowledge` tool takes `sourceIds` from the LLM's tool-call arguments, not from the agent record. Delete the block. Restrict retrieval at the knowledge-service / source level (per-source permissions), and describe intended grounding in `instructions` so the model asks for the right sources. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **active** | `boolean` | optional (default: `true`) | | | **access** | `string[]` | optional | Who can chat with this agent | | **permissions** | `string[]` | optional | Required permission-set capabilities | diff --git a/content/docs/references/ai/skill.mdx b/content/docs/references/ai/skill.mdx index 978815485c1..4da4f3b7afe 100644 --- a/content/docs/references/ai/skill.mdx +++ b/content/docs/references/ai/skill.mdx @@ -39,7 +39,6 @@ const result = SkillSchema.parse(data); | **surface** | `Enum<'ask' \| 'build' \| 'both'>` | optional (default: `"ask"`) | Agent surface this skill binds to ('ask' \| 'build' \| 'both') — ADR-0063 §3; read by the cloud agent runtime only | | **instructions** | `string` | optional | LLM instructions when skill is active — also served as an MCP prompt | | **tools** | `string[]` | ✅ | Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) — bound by the cloud agent runtime only | -| **triggerPhrases** | `never` | optional | [REMOVED] `skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (audit close-out) — phrases were never matched against the user's message; skill activation is `triggerConditions` (AND of context field/operator/value) intersected with the agent's `skills[]`, plus explicit /skill-name pinning. Delete the key. Put routing intent in `triggerConditions`; describe intent in `description`/`instructions` for the LLM. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **triggerConditions** | `{ field: string; operator: Enum<'eq' \| 'neq' \| 'in' \| 'not_in' \| 'contains'>; value: string \| string[] }[]` | optional | Programmatic activation conditions — evaluated by the cloud agent runtime only | | **active** | `boolean` | optional (default: `true`) | Whether the skill is enabled | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this skill. | diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 950d20d4103..8a2643a9943 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -100,8 +100,6 @@ const result = AnalyticsEndpoint.parse(data); | **limit** | `integer` | optional | Maximum number of rows to return, applied after `order` — a non-negative integer (`0` returns no rows) | | **offset** | `integer` | optional | Number of rows to skip before the first row returned, applied after `order` — a non-negative integer; an `offset` with no `limit` returns every row after it | | **timezone** | `string` | optional | | -| **query** | `never` | optional | [REMOVED] `query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. The `{ cube, query: {...} }` envelope was the dialect of the retired degraded analytics shim — the real engine never understood it. Move the query.* fields to the body top level: `{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }`. | -| **format** | `never` | optional | [REMOVED] `format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. It was never implemented — every response is the JSON envelope. Delete the key; for CSV/XLSX use the export surface instead. | ### Nested Shape: `AnalyticsQueryRequest.timeDimensions[number]` diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 2c9a40096cc..67fadc78899 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -114,12 +114,10 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -154,7 +152,6 @@ const result = AutomationApiErrorCode.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -180,8 +177,6 @@ const result = AutomationApiErrorCode.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **fallbackNodeId** | `never` | optional | [REMOVED] `flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (audit close-out) — the engine routes unrecoverable node errors via per-node fault edges (an edge with type: 'fault'), and never read this key: a fallback configured here silently did not exist. Delete the key and draw a fault edge from the failing node to the handler node instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `CreateFlowRequest.protection` @@ -239,12 +234,10 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -769,12 +762,10 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -1080,12 +1071,10 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -1146,12 +1135,10 @@ const result = AutomationApiErrorCode.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 425b799a95d..b85daac6f89 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -85,7 +85,6 @@ const result = AppDefinitionResponseSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | App unique machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | App display label | -| **version** | `never` | optional | [REMOVED] `App.version` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no consumer in framework or objectui). An app is versioned by its owning package: use `manifest.version`. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | App description | | **icon** | `string` | optional | App icon used in the App Launcher | | **branding** | `{ primaryColor?: string; accentColor?: string; logo?: string; favicon?: string }` | optional | App-specific branding | @@ -96,15 +95,8 @@ const result = AppDefinitionResponseSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | -| **objects** | `never` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **apis** | `never` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17. Before the executor landed it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps//` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. An endpoint whose work needs CODE still lands here: compute in a flow and register the code as a stack function (`defineStack({ functions })`) or a connector — an app does not mount routes on `http.server`, and the manifest `contributes.routes` key was removed in @objectstack/spec 17 (nothing ever read it; authoring it is now rejected with its own prescription). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sharing** | `never` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **embed** | `never` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **mobileNavigation** | `never` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | -| **aria** | `never` | optional | [REMOVED] `App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no renderer read app-level ARIA attributes). Declare `aria` on the page component that renders the DOM node instead (`page.components[].aria`; `page.aria` and the list view `aria` are live too). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index cfa11b1628c..b9d95f2ec31 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -103,7 +103,6 @@ Installed package row whose manifest is the assembled package body | **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -285,7 +284,6 @@ This schema accepts one of the following structures: | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -349,7 +347,6 @@ This schema accepts one of the following structures: | **capabilities** | `{ name: string; label?: string; description?: string; scope?: Enum<'platform' \| 'org'>; … }[]` | optional | [ADR-0066 D1] Authorization capabilities this package defines (seeded with package provenance) | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 903d29313c1..ce66fae5096 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -159,7 +159,6 @@ Install package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -206,7 +205,6 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -314,7 +312,6 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -431,7 +428,6 @@ Install package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -565,7 +561,6 @@ Upgrade package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -660,7 +655,6 @@ Resolve dependencies request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index bccedcea049..fccccead6d5 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1871,11 +1871,6 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `GetUiViewResponse.form` @@ -1901,12 +1896,10 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `GetUiViewResponse.listViews[string]` @@ -1957,11 +1950,6 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `GetUiViewResponse.formViews[string]` @@ -1987,12 +1975,10 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `GetUiViewResponse.protection` @@ -2104,7 +2090,6 @@ Install package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index 1fd84cef370..9d4b7e53413 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -249,7 +249,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (server-wide search opt-out; embedder-only, not settable from `os serve`) | | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | -| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | | **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version | | **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. | @@ -297,7 +297,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (server-wide search opt-out; embedder-only, not settable from `os serve`) | | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | -| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | | **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version | | **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. | diff --git a/content/docs/references/automation/control-flow.mdx b/content/docs/references/automation/control-flow.mdx index 77a6f5bae10..27f65897c0e 100644 --- a/content/docs/references/automation/control-flow.mdx +++ b/content/docs/references/automation/control-flow.mdx @@ -119,7 +119,6 @@ const result = FlowRegionSchema.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -182,7 +181,6 @@ const result = FlowRegionSchema.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -231,7 +229,6 @@ const result = FlowRegionSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -270,7 +267,6 @@ const result = FlowRegionSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 13ddfba3c41..208077b5660 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -35,12 +35,10 @@ const result = FlowSchema.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | @@ -75,7 +73,6 @@ const result = FlowSchema.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -101,8 +98,6 @@ const result = FlowSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **fallbackNodeId** | `never` | optional | [REMOVED] `flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (audit close-out) — the engine routes unrecoverable node errors via per-node fault edges (an edge with type: 'fault'), and never read this key: a fallback configured here silently did not exist. Delete the key and draw a fault edge from the failing node to the handler node instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `Flow.protection` @@ -146,7 +141,6 @@ const result = FlowSchema.parse(data); | **position** | `{ x: number; y: number }` | optional | | | **timeoutMs** | `integer` | optional | Maximum execution time for this node in milliseconds | | **inputSchema** | `Record; required?: boolean; description?: string }>` | optional | Input parameter schema for this node | -| **outputSchema** | `never` | optional | [REMOVED] `flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit close-out) — it was never validated: the engine does not check node outputs against it, so it documented a contract nothing enforced. Delete the key. Downstream nodes read prior outputs via expressions (`{{nodeId.field}}`) regardless of any declaration. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **waitEventConfig** | `{ eventType: Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>; timerDuration?: string; signalName?: string }` | optional | Configuration for wait node event resumption. REQUIRED on a `type: 'wait'` node — the block is the whole contract, and a wait node without one parses into a run that parks forever reporting success. Under `eventType: 'timer'` a `timerDuration` is required too. | | **boundaryConfig** | `{ attachedToNodeId: string; eventType: Enum<'error' \| 'timer' \| 'signal' \| 'cancel'>; interrupting?: boolean; errorCode?: string; … }` | optional | Configuration for boundary events attached to host nodes. REQUIRED on a `type: 'boundary_event'` node — without it the node names neither the activity it watches nor what fires it. | @@ -173,8 +167,6 @@ const result = FlowSchema.parse(data); | **eventType** | `Enum<'timer' \| 'signal' \| 'webhook' \| 'manual' \| 'condition'>` | ✅ | What kind of event resumes the execution | | **timerDuration** | `string` | optional | ISO 8601 duration (e.g., "PT1H") or wait time for timer events | | **signalName** | `string` | optional | Named signal or webhook event to wait for | -| **timeoutMs** | `never` | optional | [REMOVED] `waitEventConfig.timeoutMs` was removed in @objectstack/spec 17. It documented a timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its only reader treated it as the timer DURATION when `timerDuration` was absent, so use `timerDuration` — but QUOTE the number: the key is a string, and a bare numeric string is read as milliseconds, making `timeoutMs: 60000` and `timerDuration: '60000'` the same wait (`timerDuration: 'PT1M'` is the ISO 8601 spelling of that same 60s). Stored flows are converted automatically — the conversion does the quoting for you. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **onTimeout** | `never` | optional | [REMOVED] `waitEventConfig.onTimeout` was removed in @objectstack/spec 17. It had no readers at all — no code path ever inspected it, so neither `fail` nor `continue` ever happened. Delete the key. There is no replacement: `wait` has no timeout, and a wait node resumes only when its timer elapses or its signal arrives. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `FlowNode.boundaryConfig` @@ -257,12 +249,10 @@ const result = FlowSchema.parse(data); | **errorMessage** | `string` | optional | Message carried on AutomationResult for every terminal run (not only screen flows); the screen-flow UI shows it as a toast instead of the raw error. | | **version** | `integer` | optional (default: `1`) | Version number | | **status** | `Enum<'draft' \| 'active' \| 'obsolete' \| 'invalid'>` | optional (default: `"draft"`) | Deployment status | -| **template** | `never` | optional | [REMOVED] `flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — no designer or engine path ever read it, so flagging a flow as a template/subflow did nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the flow by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **type** | `Enum<'autolaunched' \| 'record_change' \| 'schedule' \| 'screen' \| 'api'>` | ✅ | Flow type | | **variables** | `{ name: string; type: string; isInput?: boolean; isOutput?: boolean; … }[]` | optional | Flow variables | | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | -| **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS) — system flows keep system privileges; after an approval decision their attribution user is the decider; user = the human whose act is running this segment: the trigger user until a human decision, the decider after it (RLS-respecting; the nodes after an approval decision run with the decider's identity and permissions, so a node the decider may not perform is refused). Elevation does not reach identity assignment: a system run's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the run acts for), and change invitations and memberships through the organization endpoints. A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to START this flow, the flow-level twin of an action's requiredPermissions. Checked at every door that starts a flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow): a caller that is not the system principal must hold every named capability, or the door answers 403 PERMISSION_DENIED and nothing runs. An action that starts this flow inherits the list even when it declares none; one that declares its own needs both. A parent flow's subflow node is not a door and is not checked again. Use this, not current_user.positions inside the flow, to say who may start an elevated (runAs: system) screen or api flow. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx index b45f61f391d..65dd8a5fba2 100644 --- a/content/docs/references/automation/schemaless-node-config.mdx +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -167,11 +167,6 @@ const result = DecisionConditionSchema.parse(data); | **function** | `string` | ✅ | Registered function to call (defineStack(`{ functions }`)). Contractually pure — it returns a value a later declarative node persists | | **inputs** | `Record` | optional | Inputs passed to the function: each value a CEL value envelope `{ dialect: 'cel', source }` evaluated against the live flow variables, or a literal written as it is — a `{…}` template token is refused | | **outputVariable** | `string` | optional | Flow variable the function's return value is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | -| **actionType** | `never` | optional | [REMOVED] `script.config.actionType` was removed in @objectstack/spec 17 — none of its values did what it said. The two built-ins were logger-backed stubs that recorded the intent and delivered nothing under any configuration, and every other value was a second spelling of `config.function`. Replace it per branch: for `email` use a `notify` node (it delivers through the messaging service — the in-app inbox by default, real email once `@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with the Slack connector, or an `http` node posting to a webhook; for anything else, move the name into `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for the shorthand case into `config.function`; `--write` applies the ones it can prove, and the stub and marker values are removed. | -| **template** | `never` | optional | [REMOVED] `script.config.template` was removed in @objectstack/spec 17 — it fed only the logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template id was ever resolved. Delete the key. For templated content use a `notify` node and name an email template (`email_template`) in its own `template` key, with `templateData` filling its placeholders: the email and in-app inbox channels render that template in each recipient's locale. Otherwise the node carries inline `title`/`message`. The messaging service's `sys_notification_template` rows are read only by the `email` and `sms` channels, `email` only when the node names no template. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **recipients** | `never` | optional | [REMOVED] `script.config.recipients` was removed in @objectstack/spec 17 — the addresses were logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a `notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging service for real. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **variables** | `never` | optional | [REMOVED] `script.config.variables` was removed in @objectstack/spec 17 — it injected values into a template no side effect ever rendered. Delete the key. A `notify` node carries structured data in `payload`; a registered function takes it in `config.inputs`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **script** | `never` | optional | [REMOVED] `script.config.script` was removed in @objectstack/spec 17 — the built-in runtime has no server-side JS sandbox, so an inline body was recognized and never executed: the node warned and completed as a no-op. Move the logic into a registered function (`defineStack({ functions })`) and name it in `config.function`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 0597c0bee21..2f1bffa18dd 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -108,7 +108,6 @@ const result = CurrencyConfigSchema.parse(data); | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 7302d4f9277..d1511e6ca90 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -67,8 +67,6 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **unique** | `false \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -275,7 +273,6 @@ const result = ApiMethod.parse(data); | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | @@ -307,8 +304,6 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **unique** | `false \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `Object.fieldGroups[number]` @@ -415,11 +410,6 @@ const result = ApiMethod.parse(data); | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `Object.enable` @@ -488,7 +478,6 @@ const result = ApiMethod.parse(data); | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | @@ -504,8 +493,6 @@ const result = ApiMethod.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | -| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | @@ -641,7 +628,6 @@ const result = ApiMethod.parse(data); | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | @@ -673,8 +659,6 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **unique** | `false \| 'global' \| 'organization'` | optional (default: `false`) | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' | -| **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 8566a20c5e7..596b15c56c6 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -194,7 +194,7 @@ const result = ConnectorSchema.parse(data); | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **fieldMappings** | `never` | optional | [REMOVED] `connector.fieldMappings` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever moved a value through a connector field mapping: nothing read `source`, `target`, `defaultValue`, `dataType`, `required` or `syncMode`. Delete the key; the `ConnectorFieldMapping` shape leaves with it. Map fields on the sync's TARGET instead: a `mapping`'s `fieldMapping` (`source` → `target`, with a `transform` the import path executes), which its `connectorSource` pulls through. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | @@ -491,7 +491,7 @@ Connector type | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **fieldMappings** | `never` | optional | [REMOVED] `connector.fieldMappings` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever moved a value through a connector field mapping: nothing read `source`, `target`, `defaultValue`, `dataType`, `required` or `syncMode`. Delete the key; the `ConnectorFieldMapping` shape leaves with it. Map fields on the sync's TARGET instead: a `mapping`'s `fieldMapping` (`source` → `target`, with a `transform` the import path executes), which its `connectorSource` pulls through. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index c0fd420abf2..b06ef84f934 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -59,7 +59,6 @@ Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (cop | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index a1885168117..3929a1d320a 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -328,7 +328,6 @@ const result = MetadataBulkResultSchema.parse(data); | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | @@ -344,8 +343,6 @@ const result = MetadataBulkResultSchema.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | -| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index da0ea3229a6..f25f1f83e3e 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -214,7 +214,6 @@ Install package request | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | @@ -313,7 +312,6 @@ Installed package with runtime lifecycle state | **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | | **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | | **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +9 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | -| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 0b8bcff05d9..8865e8e54bd 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -172,7 +172,6 @@ const result = AdminScopeSchema.parse(data); | **check** | `string` | optional | Validation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. Refused on a policy whose `operation` is `select` or `delete`, which write no new row to check: limit the rows such a policy admits with `using`, and declare the `check` on an `insert`, `update` or `all` policy. | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | -| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **tags** | `never` | optional | [REMOVED] `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in `positions`. A policy is identified by its `name` and its `object`; say why it exists in `description`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `PermissionSet.adminScope` diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index cbd396b2c8c..99ab6568045 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -254,7 +254,6 @@ The membership key name: policies read the set as `current_user.KEY` | **check** | `string` | optional | Validation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. The default to `using` is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares `check`, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares `check` does each applicable policy's `using` stand in as its check (OR-combined). Applicable = not `enabled: false`, `object` matches or is '*', `operation` matches or is 'all', and the caller holds one of its `positions` when it lists any. Refused on a policy whose `operation` is `select` or `delete`, which write no new row to check: limit the rows such a policy admits with `using`, and declare the `check` on an `insert`, `update` or `all` policy. | | **positions** | `string[]` | optional | Positions this policy applies to (omit for all) | | **enabled** | `boolean` | optional (default: `true`) | Whether this policy is active | -| **priority** | `never` | optional | [REMOVED] `rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. It never had an effect. Delete the key — policy outcomes are unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **tags** | `never` | optional | [REMOVED] `rowLevelSecurity[].tags` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: the RLS compiler never consulted a policy's tags and nothing else acted on them, so a tag scoped, restricted and reported nothing. Delete the key. A tag never limited whom a policy applies to; to do that, list the positions in `positions`. A policy is identified by its `name` and its `object`; say why it exists in `description`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | diff --git a/content/docs/references/shared/mapping.mdx b/content/docs/references/shared/mapping.mdx index ca355d829f9..620b2408952 100644 --- a/content/docs/references/shared/mapping.mdx +++ b/content/docs/references/shared/mapping.mdx @@ -76,7 +76,7 @@ const result = FieldMappingSchema.parse(data); | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field name | | **target** | `string` | ✅ | Target field name | -| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | | **defaultValue** | `any` | optional | Default if source is null/undefined | diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx index fd613241dd3..033d30194c2 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -68,7 +68,7 @@ const result = BookSchema.parse(data); | :--- | :--- | :--- | :--- | | **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) | | **label** | `string` | ✅ | Section title — first-class, i18n-homed | -| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | @@ -116,7 +116,7 @@ Type: `'public'` | :--- | :--- | :--- | :--- | | **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) | | **label** | `string` | ✅ | Section title — first-class, i18n-homed | -| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | diff --git a/content/docs/references/system/job.mdx b/content/docs/references/system/job.mdx index 884b58f83d9..0368ea63d91 100644 --- a/content/docs/references/system/job.mdx +++ b/content/docs/references/system/job.mdx @@ -120,7 +120,6 @@ const result = CronScheduleSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -177,7 +176,6 @@ const result = CronScheduleSchema.parse(data); | **backoffMultiplier** | `number` | optional (default: `1`) | Exponential backoff multiplier; 1 (the default) keeps the delay flat | | **maxRetryDelayMs** | `integer` | optional (default: `30000`) | Ceiling for a single backoff delay (ms) | | **jitter** | `boolean` | optional (default: `false`) | Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries | -| **retryDelayMs** | `never` | optional | [REMOVED] `retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and `flow.errorHandling`. Rename the key to `backoffMs`; the value (milliseconds before the first retry) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index a4d4316610a..1b110e0c74a 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -109,7 +109,6 @@ Add a new field to an existing object | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | @@ -539,7 +538,6 @@ Add a new field to an existing object | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. P`record.type == 'invoice'` | | **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. | | **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17. | -| **conditionalRequired** | `never` | optional | [REMOVED] `conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. Rename the key; the value (a CEL predicate) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dueLike** | `boolean` | optional | Deadline semantic (`date` / `datetime` only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with `settledWhen` to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. | | **settledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. P`record.status == 'done'`. Evaluated per record like `visibleWhen`, reading the record's own columns as `record`. Requires `dueLike: true` on a `date` / `datetime` field; refused otherwise. Display only: nothing on the write path reads it. | | **conditionalFormatting** | `{ condition: string \| object; style: Record }[]` | optional | Cell formatting rules for this field — `[{ condition, style }]`, the same rule a list view's `conditionalFormatting` declares. In order, the first rule whose CEL `condition` holds applies its CSS `style` map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads `value` (this field's value on the record) and `record` (the row) and no other root, e.g. P`value < 0` with `style: { color: '#b91c1c' }`; `objectstack validate` refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with `dueLike` / `settledWhen` instead. Display only: nothing on the write path reads it. | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index c47e8f8e0d8..4533de19e9c 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -42,7 +42,6 @@ const result = ActionSchema.parse(data); | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | -| **execute** | `never` | optional | [REMOVED] `execute` was removed in @objectstack/spec 17 — use `target`. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | @@ -58,8 +57,6 @@ const result = ActionSchema.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | -| **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bulkEnabled** | `never` | optional | [REMOVED] `action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (audit close-out) — the multi-select toolbar is driven by the LIST VIEW's `bulkActions` / `bulkActionDefs`, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's `bulkActions` instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | | **recordIdField** | `string` | optional | Row field whose value seeds recordIdParam. Defaults to "id". | diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index e110e64b62a..f5f23868d3c 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -68,7 +68,6 @@ const result = ActionNavItemSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | App unique machine name (lowercase snake_case) | | **label** | `string \| Record` | ✅ | App display label | -| **version** | `never` | optional | [REMOVED] `App.version` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no consumer in framework or objectui). An app is versioned by its owning package: use `manifest.version`. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | App description | | **icon** | `string` | optional | App icon used in the App Launcher | | **branding** | `{ primaryColor?: string; accentColor?: string; logo?: string; favicon?: string }` | optional | App-specific branding | @@ -79,15 +78,8 @@ const result = ActionNavItemSchema.parse(data); | **navigation** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | -| **objects** | `never` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **apis** | `never` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17. Before the executor landed it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps//` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. An endpoint whose work needs CODE still lands here: compute in a flow and register the code as a stack function (`defineStack({ functions })`) or a connector — an app does not mount routes on `http.server`, and the manifest `contributes.routes` key was removed in @objectstack/spec 17 (nothing ever read it; authoring it is now rejected with its own prescription). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sharing** | `never` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **embed** | `never` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **mobileNavigation** | `never` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 | -| **aria** | `never` | optional | [REMOVED] `App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no renderer read app-level ARIA attributes). Declare `aria` on the page component that renders the DOM node instead (`page.components[].aria`; `page.aria` and the list view `aria` are live too). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 965cec08d24..ad10aed228b 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -391,9 +391,6 @@ const result = ActionButtonPropsSchema.parse(data); | **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **placeholder** | `string \| Record` | optional | Placeholder text | | **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") | -| **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use the component-level `dataSource.filter`, which the query path does apply. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `ElementRecordPickerProps.aria` @@ -1467,9 +1464,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **title** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **bordered** | `boolean` | optional (default: `true`) | | -| **actions** | `never` | optional | [REMOVED] `page:card` property `actions` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it: objectui's card renderer builds its `` from `title`, `bordered`, `children` and `footer` only, has no actions area, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key and author the buttons as components in the card's `children` or `footer` (`element:button`, `record:quick_actions`), which is what actually renders. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **children** | `any[]` | optional | Card content components, in order (the card body slot) | -| **body** | `never` | optional | [REMOVED] `page:card` property `body` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — it was a second spelling of the composition slot every other container calls `children`, and the renderer reads both. Rename the key to `children`; the value (an array of child components) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **footer** | `any[]` | optional | Card footer components (slot) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -1503,7 +1498,6 @@ View filter rule | :--- | :--- | :--- | :--- | | **title** | `string \| Record` | optional | Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from. | | **subtitle** | `string \| Record` | optional | Page subtitle | -| **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **breadcrumb** | `never` | optional | [REMOVED] `page:header` property `breadcrumb` was removed in @objectstack/spec 17 (ADR-0087 D2) — no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the navigation trail is drawn once, by the app shell's header. Delete the key, whether it was `true` or `false`; the shell's trail is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **actions** | `string[]` | optional | Action IDs the header draws, resolved against the actions declared on the page's object. An authored list is the whole set: an action that declares `record_header` (or `record_more`) in its `locations` but is not named here is not drawn, and omitting the key draws none of the object's actions. A named action that declares neither location renders nowhere. The host's own Edit / Share / Delete chrome is separate from this list. | | **recordChrome** | `boolean` | optional (default: `true`) | Render the record chrome — the title as a record chip with its copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. | @@ -1531,7 +1525,6 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **tabStyle** | `Enum<'line' \| 'card' \| 'pill'>` | optional (default: `"line"`) | Tab-strip visual style: 'line' underlines the active tab, 'card' frames each tab, 'pill' renders rounded pills | -| **type** | `never` | optional | [REMOVED] `page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — a props key named `type` collides with the page component's own dispatch key, so it is unauthorable in the flat and JSX carriers and was never validated in them. Rename the key to `tabStyle`; the value (`line` \| `card` \| `pill`) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **position** | `Enum<'top' \| 'left'>` | optional (default: `"top"`) | | | **alwaysShowStrip** | `boolean` | optional | Render the tab strip even when only one tab is visible (renderer default: a one-tab strip is hidden). | | **items** | `{ label: string \| Record; icon?: string; visibleWhen?: string \| object; value?: string; … }[]` | ✅ | | @@ -1676,7 +1669,6 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) | -| **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sections** | `{ name?: string; label?: string \| Record; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor?, count? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). `count: { field }` shows how many values a multi-value field holds in the heading, through its `{{ count }}` hole. | | **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) | diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 12ac9c76bca..cccd1cf17d4 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -40,8 +40,6 @@ const result = DashboardSchema.parse(data); | **dateRange** | `{ field?: string; defaultRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange?: boolean }` | optional | Global dashboard date range filter configuration | | **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | -| **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `dashboard.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dashboard. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -71,9 +69,6 @@ const result = DashboardSchema.parse(data); | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Widget color variant for theming | | **requiresObject** | `string` | optional | Hide the widget unless the named object is registered | | **requiresService** | `string` | optional | Hide the widget unless the named kernel service is registered | -| **actionUrl** | `never` | optional | [REMOVED] `dashboard.widgets[].actionUrl` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **actionType** | `never` | optional | [REMOVED] `dashboard.widgets[].actionType` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **actionIcon** | `never` | optional | [REMOVED] `dashboard.widgets[].actionIcon` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **filter** | `any` | optional | Presentation-scope filter (runtimeFilter) | | **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) | | **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) | @@ -83,8 +78,6 @@ const result = DashboardSchema.parse(data); | **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record` | optional | Widget specific configuration | | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | | **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget | -| **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. This message used to point at `page.components[].responsive` as the live home of the shared `ResponsiveConfig` shape; that key was measured equally unread and removed with the shape with it. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0. Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `Dashboard.dateRange` @@ -176,9 +169,6 @@ Dashboard header action | **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Widget color variant for theming | | **requiresObject** | `string` | optional | Hide the widget unless the named object is registered | | **requiresService** | `string` | optional | Hide the widget unless the named kernel service is registered | -| **actionUrl** | `never` | optional | [REMOVED] `dashboard.widgets[].actionUrl` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **actionType** | `never` | optional | [REMOVED] `dashboard.widgets[].actionType` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **actionIcon** | `never` | optional | [REMOVED] `dashboard.widgets[].actionIcon` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from `header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; delete all three. Put the affordance on the dashboard header instead — `header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched (`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of `actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` bound to a dataset: its rows are clickable and drill through the semantic layer. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **filter** | `any` | optional | Presentation-scope filter (runtimeFilter) | | **compareTo** | `{ kind: Enum<'previousPeriod' \| 'previousYear'>; dimension?: string }` | optional | Period-over-period comparison window (`{ kind, dimension? }`) | | **dataset** | `string` | ✅ | Dataset name to bind (ADR-0021) | @@ -188,8 +178,6 @@ Dashboard header action | **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record` | optional | Widget specific configuration | | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | | **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget | -| **responsive** | `never` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. This message used to point at `page.components[].responsive` as the live home of the shared `ResponsiveConfig` shape; that key was measured equally unread and removed with the shape with it. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0. Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, `page.components[].aria` and the list view `aria`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Allowed Values: `DashboardWidget.type` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 3f28ff734f5..ac3fe141787 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -406,12 +406,10 @@ Form-view select option — the object-field option shape minus the per-option ` | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `FormView.data[provider='object']` @@ -813,11 +811,6 @@ Map view configuration | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ListView.data[provider='object']` @@ -1199,11 +1192,6 @@ View filter rule | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `ObjectListView.data[provider='object']` @@ -1776,11 +1764,6 @@ Tab configuration for multi-tab view interface | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `View.form` @@ -1806,12 +1789,10 @@ Tab configuration for multi-tab view interface | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `View.listViews[string]` @@ -1862,11 +1843,6 @@ Tab configuration for multi-tab view interface | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'toggle'>; fields?: object[] }` | optional | | ### Nested Shape: `View.formViews[string]` @@ -1892,12 +1868,10 @@ Tab configuration for multi-tab view interface | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `View.protection` @@ -2072,11 +2046,6 @@ This schema accepts one of the following structures: | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ViewItem[viewKind='list'].protection` @@ -2136,12 +2105,10 @@ This schema accepts one of the following structures: | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ViewItem[viewKind='form'].protection` @@ -2251,11 +2218,6 @@ This schema accepts one of the following structures: | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes for the list view | -| **responsive** | `never` | optional | [REMOVED] `view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **performance** | `never` | optional | [REMOVED] `view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **striped** | `never` | optional | [REMOVED] `view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ViewItemWire[viewKind='list'].protection` @@ -2327,12 +2289,10 @@ This schema accepts one of the following structures: | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | | **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | -| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped. | | **buttons** | `{ submit?: object; cancel?: object; reset?: object }` | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. | | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). | -| **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ViewItemWire[viewKind='form'].protection` diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index ba3a61a145e..32aee922ac9 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -11,7 +11,7 @@ There are **two upgrades** on this platform, and they run on separate clocks: | Ships as | a Docker image (or the `os` CLI on a host) | a compiled artifact, `dist/objectstack.json` | | Versioned by | our release train (`X.Y.Z`) | your own catalog | | Whose cadence | ours | yours | -| The upgrade action | move the image tag, or the dependency pins, then restart | `os migrate meta --from 16`, then rebuild and ship | +| The upgrade action | move the image tag, or the dependency pins, then restart | `os migrate meta --from 17`, then rebuild and ship | | Touches your metadata? | it never *rewrites* your metadata — but it can start *refusing* it ([measured](#a-runtime-move-can-still-force-metadata-edits)) | yes — you edit your source, guided by the tool's change list | Most upgrade questions are really the question *which of these am I doing*. You @@ -218,7 +218,7 @@ followed by your normal build. ### One command covers every major you skipped ```bash -os migrate meta --from 16 # the major your metadata was authored against +os migrate meta --from 17 # the major your metadata was authored against ``` `--from` is the major **you** wrote against, not the one you are going to. The @@ -229,20 +229,27 @@ them by hand: | You ran | Steps it replays | | :--- | :--- | -| `os migrate meta --from 16` | 17, then every later major this build carries a step for | +| `os migrate meta --from 17` | 18, then every later major this build carries a step for | -The chain reaches back to **protocol 16** — that floor is a release-policy -decision, not an accident of what still exists. Below it the command refuses -with a message naming the floor rather than half-migrating you. +The chain reaches back to **protocol 17** — one major below the current one, by +rule (ADR-0087, amended 2026-10-26): a retirement kit lives through the major +after the one it retired in and leaves when the next major is cut, and the floor +rises with it at every major. Below the floor the command refuses with a message +naming the floor and the [protocol upgrade guide](/docs/protocol-upgrade) +rather than half-migrating you. - **The floor was raised from 10 to 16.** Earlier releases replayed the chain - from protocol 10, so `--from 10` through `--from 15` used to work and now - answer `Cannot migrate from protocol N: the chain's support floor is 16`. - Nothing half-migrates: the command refuses before it rewrites anything. If - your metadata is still authored against protocol 10–15, reach protocol 16 by - another path first — an older `@objectstack/cli` still carries those steps — - and then run the command above. + **The floor is 17, and it rises one major at every major.** `--from 16` and + below answer `Cannot migrate from protocol N: the chain's support floor is 17` + with the guide link; nothing half-migrates, because the command refuses before + it rewrites anything. Metadata still authored against protocol 16 reaches 17 on + a release that still carries the step — any `@objectstack/cli` up to 18.1 — and + then runs the command above. Keys retired at or below 16.x are no longer + prescribed in the schema's own error: the strict parse refuses them as unknown + keys and points at the guide, which carries the prescription from then on. + Stored `sys_metadata` rows in a ≤16.x shape are no longer rewritten at read + either; canonicalize them once with `os migrate meta --stored --apply` on 18.1 + or earlier before moving past it. Useful flags: @@ -325,7 +332,7 @@ has already done. ### The loop ```bash -os migrate meta --from 16 # 1. read the mechanical change list and the to-dos +os migrate meta --from 17 # 1. read the mechanical change list and the to-dos # 2. apply the edits (--write writes the ones it can # trace and lists the rest); resolve the to-dos # and the checklist items below diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index cc22b0a19c3..25f02014b17 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -1,6 +1,6 @@ # ADR-0087: Metadata protocol upgrades for AI consumers — conversion over notification, executable migrations, machine-verifiable upgrades -**Status**: Accepted (2026-07-04, #2582) · trued up to as-built 2026-07-15 (see Addendum) · **Amended** (2026-09-30, #20390 ruling A — the artifact-ingestion window decides per entry by each retired conversion's `retiredAfter`; see the amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-09, #22449 ruling B′ — the two D4 projections, the per-major section of `spec-changes.json` and the protocol upgrade guide, are generated at publish rather than committed; see the amendment note under D4, which says what is generated where and where each part landed, and the struck clauses in the P2 phase-status bullet) · **Amended** (2026-10-13, #23038 — the artifact-ingestion window's third disjunct, landed as `58f5c90999`: a floor that is the first release of a major replays that major's conversion step, verdict `'converted-major-step'`; see the 2026-10-13 amendment note under the 2026-09-13 addendum's window bullet) +**Status**: Accepted (2026-07-04, #2582) · trued up to as-built 2026-07-15 (see Addendum) · **Amended** (2026-09-30, #20390 ruling A — the artifact-ingestion window decides per entry by each retired conversion's `retiredAfter`; see the amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-09, #22449 ruling B′ — the two D4 projections, the per-major section of `spec-changes.json` and the protocol upgrade guide, are generated at publish rather than committed; see the amendment note under D4, which says what is generated where and where each part landed, and the struck clauses in the P2 phase-status bullet) · **Amended** (2026-10-13, #23038 — the artifact-ingestion window's third disjunct, landed as `58f5c90999`: a floor that is the first release of a major replays that major's conversion step, verdict `'converted-major-step'`; see the 2026-10-13 amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-26, #25767 ruling D2 = A — a retirement kit (tombstone, `RETIRED_KEYS`/`RETIRED_DEFS` rows, semantic entry, conversion) lives one protocol major after the one it retired in and is then removed; a removed key is refused as a strict unknown key whose refusal names the protocol upgrade guide, and the chain's support floor rises to match; see the 2026-10-26 addendum, which also amends the "never deleted" / "permanent" / "forever" wording in D2, D3, the consequences and the 2026-07-31, 2026-08-01 and 2026-09-13 addenda) **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0059](./0059-third-party-backward-compatibility-gates.md) (layered backward-compat gates — this ADR is its consumer-facing sequel), [ADR-0078](./0078-no-silently-inert-metadata.md) (no declarable-but-unenforced metadata — the un-checked `engines.protocol` is exactly this class), [ADR-0025](./0025-plugin-package-distribution.md) (§3.2 `engines.protocol` / `engines.platform` compatibility ranges, §3.10 #3 protocol-first check order), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (the authoring population this ADR designs for), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs), AGENTS.md Prime Directive #12 (contract-first, no consumer-side dialect fallbacks — §"Why the conversion layer does not violate PD #12" draws the line) **Consumers**: `@objectstack/spec` (protocol version constant, conversion layer, deprecation/change registries), `@objectstack/cli` (`validate`, `doctor`, `migrate meta`), the runtime metadata loader (handshake + conversion), `@objectstack/mcp` (the AI-native change/migration surface), `@objectstack/create-objectstack`, the Release workflow, and every third-party consumer — whose maintainer is assumed to be an **AI agent** @@ -144,7 +144,12 @@ new shape, and the *fleet* upgrades incrementally. accepts N−1 shapes at load), then retired from the load path in N+1 — but it is **never deleted**: a retired conversion graduates into the D3 migration chain as that major's mechanical step, so the transform history is permanent even though the - runtime only ever carries one major of it. The window is deliberately **not** a + runtime only ever carries one major of it. — **amended 2026-10-26 (#25767, D2 = A):** + "never deleted" held for three majors and is withdrawn. The graduated step is + carried for **one further major** (the kit retired in N is in the tree through + N+1 and leaves when N+2 is cut); after that the key is refused as a strict unknown + key, the refusal names the protocol upgrade guide, and the history lives in the + guide, the CHANGELOG and git — see the 2026-10-26 addendum. The window is deliberately **not** a notification device — a warning window is worthless to a consumer that arrives three majors later. It is a **fleet-compatibility window**: the period both shapes are loadable, which is what lets a multi-app fleet upgrade incrementally and roll @@ -176,7 +181,10 @@ document). Two sources feed each major's step: **semantic changes** authored for major (one entry per retirement family, even when a lossless D2 conversion also exists for it; D2 carries the mechanical data repair only), and **graduated conversions** (D2 entries retired from the load path). Together the steps form a **permanent, ordered -chain** — the database-migration model applied to metadata source files. +chain** — the database-migration model applied to metadata source files. (**Amended +2026-10-26**, #25767: "permanent" is bounded by the support floor, which now sits one +major below the current one; a step below the floor is deleted, not kept — see the +2026-10-26 addendum.) - **Cross-major is the designed-for case, not an unsupported edge.** `objectstack migrate meta --from 10` composes the steps 10→11→…→current and applies @@ -387,7 +395,10 @@ scaling framework-side execution when the consumers can execute themselves. - The chain is a **forever artifact**: every step back to the support floor must stay replayable, and full-chain CI replay grows linearly with history. Bounded by the explicit support floor (a release-policy decision, revisitable per major) and by - steps being declarative data + fixtures, not code that rots. + steps being declarative data + fixtures, not code that rots. — **Amended + 2026-10-26** (#25767): the floor is no longer "revisitable"; it is **set by rule** + to one major below the current one and rises at every major cut, so the chain + carries one step and the replay cost is constant rather than linear. - Machine-first artifacts raise the release-engineering bar: `spec-changes.json`, conversion fixtures, and the generated guide are new build products that can themselves regress — they get their own CI checks (schema-validated, fixtures @@ -550,7 +561,12 @@ diverged (#3903). This addendum extends the contract to data at rest: no author to teach, and refusing its historical shape would only break data that once worked. D3 keeps every conversion forever precisely so any past major replays forward — a stored row is the perpetual "consumer arriving - late", and the read path is its chain. + late", and the read path is its chain. (**Amended 2026-10-26**, #25767: "forever" + is now one major. A stored row shaped for a major below the floor is no longer + rewritten at read; it is canonicalized once, with `os migrate meta --stored + --apply` on a release that still carries the step, before the deployment moves + past it — the 2026-08-01 finish line is the mechanism, and it is now the + required one, not an optional one.) - **Flows canonicalize at their own seam.** `AutomationEngine.registerFlow` (the rehydration seam PD #12 names) now also replays retired entries; the generic metadata seams deliberately skip `type: 'flow'` because flow-node @@ -579,7 +595,8 @@ diverged (#3903). This addendum extends the contract to data at rest: The addendum above makes a legacy row read canonical *forever*, which is the correctness guarantee — and, read literally, also a promise that the chain runs -on that row forever. `os migrate meta --stored` +on that row forever. (**Amended 2026-10-26**, #25767: both "forever"s are now one +major — see the 2026-10-26 addendum.) `os migrate meta --stored` (`ObjectStackProtocolImplementation.migrateStoredMetadata`) lets a deployment end that for itself: it walks `sys_metadata` (active + draft, all orgs), replays the same `applyConversionsToStoredItem` pass, and re-saves each changed body @@ -1148,7 +1165,9 @@ unconditional strip would start deleting legal metadata the day the keys return. manifest declares (`engines.protocol`, ADR-0025) and the `@objectstack/spec` version the process actually runs. `floor < runtime` replays the FULL chain, retired entries included, before the strict parse — the artifact is the - "consumer arriving late" D3 keeps every conversion forever for. ~~`floor >= + "consumer arriving late" D3 keeps every conversion forever for (**amended + 2026-10-26**, #25767: for one major, after which the artifact's retired keys meet + the strict refusal and the guide link, exactly as an authored source does). ~~`floor >= runtime` replays nothing: the artifact claims the current or a newer surface, and the strict parse, tombstones included, stays the authority.~~ — **amended 2026-09-30** — `floor >= runtime` replays only the retired entries whose @@ -1263,3 +1282,74 @@ within-line retirements (17.1 → 17.2), which are the case that created it. > still answers first: the protocol constant moves to M when M's prerelease > opens, so a `^M` floor passes it while the package label still names the > last release of M-1, and only then does the window decide. + +## Addendum (2026-10-26) — retirement kits age out after one major (#25767, ruling D2 = A) + +**The ruling.** A *retirement kit* — the `retiredKey()` tombstone, its +`RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` rows, its semantic entry and its D2 +conversion — lives through the protocol major **after** the one it retired in, and is +removed when the next major is cut. At major 18 that keeps the 17.x and 18.x kits and +removes everything retired at or below 16.x; 19 repeats the move one major on. The +"never deleted", "permanent" and "forever" wording in D2, D3, the consequences and +the 2026-07-31 / 2026-08-01 / 2026-09-13 addenda is amended in place above. No gate +was added: the existing deletion gate's clock was shortened and given a second, +registry-independent source. + +**What a removed key meets.** The key leaves the shape entirely, so a `strictObject` +refuses it as an unknown key. The refusal carries one more sentence, after its fix +channels and ahead of its history sentence (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, +`packages/spec/src/shared/protocol-upgrade-guide.ts`, keeping the #5955 order): it +says that keys retired before the previous major are no longer prescribed in the +error and names the protocol upgrade guide +(`https://objectstack.ai/docs/protocol-upgrade`); the guide and the CHANGELOG carry +the prescription from then on. The 17.x kits still carry theirs in the error. + +**The support floor rises with it.** `MIGRATION_SUPPORT_FLOOR` is 17: `os migrate meta +--from 16` (and below) answers `MigrationFloorError`, whose message now names the +floor and the guide link; it rewrites nothing. A source authored against protocol 16 +reaches 17 on a release that still carries the step — any `@objectstack/cli` up to +18.1 — and then runs the current release. The floor is no longer a per-release +decision: it is `CURRENT_MAJOR − 1`, by this rule, at every major cut. + +**What was removed at 18 (this amendment's own diff).** + +- 50 D2 conversions with `retiredAfter` at or below 16.x (5 at 15.0.0, 45 at 16.1.0), + with their fixture pairs and the comments that named them; 12 conversions with + `toMajor: 17` and `retiredAfter: 17.7.0` stay, registered under major 17 for the + data-at-rest seams. +- 55 tombstones whose key was already `[RETIRED]` at `@objectstack/spec@17.0.0` and + whose container is `strictObject` — the ones whose deletion yields a refusal. 58 + tombstones in the same age class sit in containers that **strip** unknown keys + (`z.object` / `.strip()`): deleting one of those would downgrade a refusal to a + silent strip, the very defect PD #12 names, so they stay until their container is + made strict. The four inline-credential refusals (`refusedInlineCredentialKey`) + are security refusals, not retirements, and are untouched. +- The 159 major-17 entry files (29 retired-key rows, 53 retired-def rows, 77 semantic + entries) and the chain's step 17. +- 61 `[RETIRED]` lines of the authorable surface. + +**The deletion gate (`check:authorable-surface`, proof 1).** `TOMBSTONE_AGE_MAJORS` is +1. A deleted `[RETIRED]` line is dated either by an exact `RETIRED_KEYS_BY_MAJOR` row +(registered at a major ≥ 1 behind the current one) or — new — by the previous major's +release tag: a line already `[RETIRED]` on the authorable surface at +`@objectstack/spec@.0.0` was retired before that cut, so its kit +has lived its one major. The tag is immutable and lives on origin, so neither a row +a PR writes nor one it deletes moves the clock, and the rows of a removed region +need no backfill to stay datable. Where the tag cannot be read (offline), the gate +falls back to the registry alone and says so — strictly more refusals, never fewer. + +**Stored rows.** A `sys_metadata` row still carrying a ≤16.x spelling is no longer +rewritten at read. Canonicalize it once, `os migrate meta --stored --apply` on 18.1 or +earlier, before moving the deployment past it. Two consequences are specific: +`app-hidden-to-unpublished` (ADR-0045 amendment 2026-08-09) is among the removed +conversions, so a pre-split `hidden: true` app row is no longer rewritten to +`_unpublished` by any seam — the artifact door's refusal of it by id is moot and was +removed; and the 12 surviving `toMajor: 17` conversions have no chain step to be +listed under, so `os migrate meta` cannot enumerate them (they still run at the +stored-row and artifact seams). + +**Residuals.** The clock reads the release tag, so a repository whose checkout cannot +fetch `refs/tags/@objectstack/spec@.0.0` dates by the registry only; CI's shallow +jobs fetch the one tag, `--depth=1`, exactly as they fetch `origin/main`. The 58 kept +strip-container tombstones are a list in the #25767 report, to be retired container +by container as each is made strict. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 9473b0f21ff..c28c93a1a8f 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -5,10 +5,9 @@ This file is a pointer. The guide is generated from the ADR-0087 registries and **Address:** the docs site, one page per protocol major. - Every major: https://objectstack.ai/docs/protocol-upgrade -- Protocol 16 → 17: https://objectstack.ai/docs/protocol-upgrade/17 - Protocol 17 → 18: https://objectstack.ai/docs/protocol-upgrade/18 -Protocol N's page is `https://objectstack.ai/docs/protocol-upgrade/N`, so a link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet. A hop leaves the site only when the migration chain's support floor rises past it (ADR-0087 D3). +Protocol N's page is `https://objectstack.ai/docs/protocol-upgrade/N`, so a link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet. A hop leaves the site when the migration chain's support floor rises past it, which happens at every major cut since ADR-0087's 2026-10-26 amendment (#25767): the floor is one major below the current one, so the 16 → 17 hop left with the 16.x retirement kits at 18. The schema's strict unknown-key refusal links every retired key here, and a kit's prescription lives in the release that carried it (its CHANGELOG, and the generated guide shipped in that `@objectstack/spec` release). **The copy a release shipped:** every `@objectstack/spec` release after 17.7.0 carries `protocol-upgrade-guide.md` in the package (`node_modules/@objectstack/spec/protocol-upgrade-guide.md`) and attached to its `@objectstack/spec@VERSION` GitHub Release. diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index 50f5ce4f4a9..aec7eb13319 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -428,7 +428,8 @@ function handshakeSlice(manifest: Record): ProtocolHandshakeMan * `--to`, capped at the protocol this runtime implements. `--to` defaults to * {@link CHAIN_TERMINUS_MAJOR}, which runs AHEAD of {@link PROTOCOL_MAJOR} while * this build carries the next major's conversions — measured on a protocol-17 - * build, `--from 16` replays 16 → 18. Those conversions map shapes the + * build, `--from 16` replayed 16 → 18 (the floor has been 17 since #25767, + * so that range now refuses). Those conversions map shapes the * installed schemas refuse onto ones they accept, so the migrated source is * what THIS runtime loads, and `'^18'` would be refused by the same handshake * (measured: "targets protocol ^18 … Run: objectstack migrate meta --from 18"). diff --git a/packages/lint/src/component-field-specs-liveness.test.ts b/packages/lint/src/component-field-specs-liveness.test.ts index 9051ab60ef7..cb0f0ce2d92 100644 --- a/packages/lint/src/component-field-specs-liveness.test.ts +++ b/packages/lint/src/component-field-specs-liveness.test.ts @@ -88,10 +88,14 @@ describe('COMPONENT_FIELD_SPECS liveness against ComponentPropsMap (#6629)', () // ── 0. the detector, before anything is asserted THROUGH it ───────────── describe('the tombstone detector actually detects (anti-vacuity)', () => { // `element:record_picker` is the incident's own schema, and it carries both - // halves of the discrimination at once: `labelField` live, `displayField` - // and `searchFields` tombstoned by #5775. If any of these four assertions - // goes red the reconciliation below is reporting nothing, whatever colour - // it shows. + // halves of the discrimination at once: `labelField` live, `object` and + // `filter` tombstoned (the flat-binding retirement, 17.x). The #5775 pair + // this block was born on — `displayField` / `searchFields` — was a 16.x + // retirement whose tombstones aged out on #25767 (ADR-0087 D2 = A): both + // keys are now ABSENT from the shape, refused as strict unknown keys, which + // is the other verdict the reconciliation keeps separate. If any of these + // assertions goes red the reconciliation below is reporting nothing, + // whatever colour it shows. const shape = shapeOf(map['element:record_picker']); it('reaches a real object shape through the lazySchema proxy', () => { @@ -99,10 +103,13 @@ describe('COMPONENT_FIELD_SPECS liveness against ComponentPropsMap (#6629)', () // Both spellings are PRESENT in the shape — that is the tombstone route // (declared-but-unwritable), and it is why "absent from the shape" and // "retired" have to be two separate verdicts below. - expect(Object.keys(shape!)).toEqual(expect.arrayContaining(['labelField', 'displayField', 'searchFields'])); + expect(Object.keys(shape!)).toEqual(expect.arrayContaining(['labelField', 'object', 'filter'])); + // The aged-out pair is gone from the shape outright (#25767). + expect(Object.keys(shape!)).not.toEqual(expect.arrayContaining(['displayField'])); + expect(Object.keys(shape!)).not.toEqual(expect.arrayContaining(['searchFields'])); }); - it.each(['displayField', 'searchFields'])('recognises the #5775 `%s` tombstone', (key) => { + it.each(['object', 'filter'])('recognises the flat-binding `%s` tombstone', (key) => { expect(isRetiredTombstone(shape![key])).toBe(true); }); diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index ec3aeb9ecbe..44842b2b20e 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -134,16 +134,26 @@ describe('lintLivenessProperties', () => { // UNPARSED stack (`os lint` does not Zod-parse), grades them dead. Until // #16094 this pin asserted silence; the silence was the opt-in's, not the // tombstone's. - it('retired flow keys are graded dead when the rule is handed an unparsed stack', () => { + // + // #25767 (ADR-0087 D2 = A): these two were ≤16.x retirements, so their + // tombstones aged out and left the shape, and their `dead` rows left the + // ledger with them (a strict-removed key has no row — check:liveness calls a + // left-behind row an ORPHAN). The rule therefore has nothing to grade them + // by; the strict parse refuses them as unknown keys, with the protocol + // upgrade guide link in the refusal. Pinned the other way round now, with a + // still-`dead` row in the SAME call so a silent walk cannot pass it. + it('flow keys retired at or below 16.x are no longer graded — their rows left with their tombstones (#25767)', () => { const findings = lintLivenessProperties({ + objects: [{ name: 'widget', externalSharingModel: 'read' }], flows: [{ name: 'f1', errorHandling: { fallbackNodeId: 'n2' }, nodes: [{ id: 'n2', outputSchema: { type: 'object' } }], }], }); - expect(ruleOf(findings, 'errorHandling.fallbackNodeId')).toBe(LIVENESS_DEAD_PROPERTY); - expect(ruleOf(findings, 'nodes.outputSchema')).toBe(LIVENESS_DEAD_PROPERTY); + expect(findings.some((f) => f.message.includes('externalSharingModel'))).toBe(true); + expect(ruleOf(findings, 'errorHandling.fallbackNodeId')).toBeUndefined(); + expect(ruleOf(findings, 'nodes.outputSchema')).toBeUndefined(); }); it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => { @@ -204,11 +214,17 @@ describe('lintLivenessProperties', () => { // above). form.data is the sweep's one CORRECTION: the removal attempt broke // the build — defineForm writes data.provider='schema' on every metadata // form — so its ledger entry flipped to live and it must stay silent. - it('retired view keys are graded dead; the corrected live key stays silent', () => { + // + // #25767: `list.responsive` / `form.defaultSort` were ≤16.x retirements, so + // the tombstones and their rows are gone and the rule no longer grades them + // (the strict parse refuses them as unknown keys). The 17.x list tombstones + // (`filterableFields`, `pageName`, `tabs`) still carry `dead` rows, so one of + // them is the positive this case keeps. + it('retired view keys are graded dead while their tombstone stands; the corrected live key stays silent', () => { const findings = lintLivenessProperties({ views: [{ object: 'task', - list: { type: 'grid', responsive: { breakpoint: 'md' } }, + list: { type: 'grid', responsive: { breakpoint: 'md' }, filterableFields: ['title'] }, form: { type: 'wizard', sections: [{ fields: ['title'] }], @@ -217,8 +233,9 @@ describe('lintLivenessProperties', () => { }, }], }); - expect(ruleOf(findings, 'list.responsive')).toBe(LIVENESS_DEAD_PROPERTY); - expect(ruleOf(findings, 'form.defaultSort')).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, 'list.filterableFields')).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, 'list.responsive')).toBeUndefined(); + expect(ruleOf(findings, 'form.defaultSort')).toBeUndefined(); expect(paths(findings).some((m) => m.includes('form.data'))).toBe(false); }); @@ -228,15 +245,21 @@ describe('lintLivenessProperties', () => { // this rule silent until #16094 made the `dead` verdict warn on its own. At a // parsing door the tombstone's rejection still comes first; handed an // unparsed stack, the rule now grades them dead. - it('the #7176 pass-through-only list keys are graded dead when the rule is handed an unparsed stack', () => { + // + // #25767: the #7176 keys were 16.x retirements, so at 18 their tombstones + // aged out and their rows left the ledger. Handed an unparsed stack the rule + // now has no row for them; a parsing door refuses them as unknown keys. The + // 17.x `list.pageName` tombstone in the same call is the anti-vacuity. + it('the #7176 pass-through-only list keys are no longer graded — their rows left with their tombstones (#25767)', () => { const findings = lintLivenessProperties({ views: [{ object: 'task', - list: { type: 'grid', striped: true, bordered: true, virtualScroll: true }, + list: { type: 'grid', striped: true, bordered: true, virtualScroll: true, pageName: 'tasks' }, }], }); + expect(ruleOf(findings, 'list.pageName')).toBe(LIVENESS_DEAD_PROPERTY); for (const key of ['list.striped', 'list.bordered', 'list.virtualScroll']) { - expect(ruleOf(findings, key), key).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, key), key).toBeUndefined(); } }); @@ -607,12 +630,26 @@ describe('lintLivenessProperties', () => { // so the hint is the `dead` default, never the row's note (the #16094 // author-facing block at the bottom of this file). Pinned per key, so a row // that leaves the ledger or changes verdict shows up by name. + // + // #25767: the four #5010 keys were ≤16.x retirements, so their tombstones + // aged out and their rows left the ledger — the rule grades nothing for + // them now, and the strict widget parse refuses them as unknown keys. The + // 17.x `chartConfig` tombstones (#6888) still carry `dead` rows, so the + // per-key pin moves to them; the four are pinned ABSENT below. + it.each(['type', 'xAxis', 'yAxis', 'series'])( + 'grades the retired `chartConfig.%s` dead when handed an unparsed stack (#6888, #16094)', + (key) => { + const findings = lintLivenessProperties(dash({ chartConfig: { [key]: 'x' } })); + expect(ruleOf(findings, `widgets.chartConfig.${key}`)).toBe(LIVENESS_DEAD_PROPERTY); + }, + ); + it.each(['actionUrl', 'actionType', 'actionIcon', 'aria'])( - 'grades the retired `%s` dead when handed an unparsed stack (#5010, #16094)', + 'no longer grades `%s` — a ≤16.x retirement whose row left with its tombstone (#25767)', (key) => { const value = key === 'aria' ? { ariaLabel: 'Total pipeline' } : 'x'; const findings = lintLivenessProperties(dash({ [key]: value })); - expect(ruleOf(findings, `widgets.${key}`)).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, `widgets.${key}`)).toBeUndefined(); }, ); @@ -634,17 +671,14 @@ describe('lintLivenessProperties', () => { const findings = lintLivenessProperties({ objects: [{ name: 'widget', externalSharingModel: 'read' }], ...dash({ - actionUrl: '/apps/sales/orders', - actionType: 'url', - actionIcon: 'plus', - aria: { ariaLabel: 'Total pipeline' }, + chartConfig: { type: 'bar', xAxis: 'region', yAxis: 'total', series: ['total'] }, colorVariant: 'teal', }), }); const messages = findings.map((f) => f.message); expect(messages.some((m) => m.includes('externalSharingModel'))).toBe(true); - for (const retired of ['actionUrl', 'actionType', 'actionIcon', 'aria']) { - expect(ruleOf(findings, `widgets.${retired}`), retired).toBe(LIVENESS_DEAD_PROPERTY); + for (const retired of ['type', 'xAxis', 'yAxis', 'series']) { + expect(ruleOf(findings, `widgets.chartConfig.${retired}`), retired).toBe(LIVENESS_DEAD_PROPERTY); } expect(messages.some((m) => m.includes('widgets.colorVariant'))).toBe(false); }); @@ -1664,16 +1698,17 @@ describe('the object/field walk, against a synthetic ledger directory (#19268)', // either way, which is the point. it('the SHIPPED field ledger warns on no stable subject — which is why the walk needs a subject of its own', () => { // `picklist` was the last opted-in row (`planned` + `authorWarn`) and left - // when the server began resolving picklist references (`live`). What is - // left is `conditionalRequired`, a `retiredKey` tombstone whose `dead` row - // warns on its own since #16094 — but a parse refuses it first, so it is no - // subject for #11385 either. `mailAssociation` (#22870) opted in as + // when the server began resolving picklist references (`live`). The last + // `dead` row, `conditionalRequired`, warned on its own since #16094 — a + // parse refused it first, so it was no subject for #11385 either — and left + // on #25767 when its ≤16.x tombstone aged out of the shape (a strict-removed + // key keeps no row). `mailAssociation` (#22870) opted in as // `planned` + `authorWarn` — the inbound-mail correspondent flag on an email // field, declared ahead of the mailbox ingest that reads it — and left this // set when that reader landed and the row flipped `live` (#22871): a // verdict that moved, so it was never a stable subject either. The // synthetic slot below is warned by nothing shipped. - expect([...authorWarnedProperties('field')]).toEqual(['conditionalRequired']); + expect([...authorWarnedProperties('field')]).toEqual([]); expect( lintLivenessProperties({ objects: [{ name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }], @@ -1875,12 +1910,15 @@ describe('the dead and live-elsewhere verdicts warn on their own (#16094)', () = // adds for such a key is the rule's verdict when it IS handed an unparsed // stack (the `#7176` pin above), not a second report at a parsing door. it('CONTROL: a tombstoned dead key is refused at parse by the shipped schema', () => { + // `filterableFields` (17.x, #20925) is the control since #25767: `striped` + // was the 16.x one and its tombstone has aged out — it is still refused, + // but as a strict unknown key on `list`, not at its own path. const parsed = ViewSchema.safeParse({ object: 'fx_account', - list: { type: 'grid', striped: true, data: { provider: 'object', object: 'fx_account' }, columns: [{ field: 'name' }] }, + list: { type: 'grid', filterableFields: ['name'], data: { provider: 'object', object: 'fx_account' }, columns: [{ field: 'name' }] }, }); expect(parsed.success).toBe(false); - expect(parsed.error?.issues.map((i) => i.path.join('.'))).toContain('list.striped'); + expect(parsed.error?.issues.map((i) => i.path.join('.'))).toContain('list.filterableFields'); }); // `manifest.runtime` is the one `live-elsewhere` row. The verdict now admits diff --git a/packages/lint/src/page-walk-conversion-parity.test.ts b/packages/lint/src/page-walk-conversion-parity.test.ts index 09fc4c32129..dad2ca43408 100644 --- a/packages/lint/src/page-walk-conversion-parity.test.ts +++ b/packages/lint/src/page-walk-conversion-parity.test.ts @@ -156,16 +156,19 @@ describe('#6775 — walkPageComponents and the conversion walk reach the same co ]); }); - it('a conversion rewrites the probe at every one of them, spelling the same paths — and at the retired `body` besides', () => { + it('a conversion rewrites the probe at every one of them, spelling the same paths — and nowhere else', () => { // Order-insensitive: the walks are free to visit in different orders, but - // the conversion walker may not miss a component lint judges. What it - // reaches BEYOND lint is exactly the retired spelling's probe. + // the conversion walker may not miss a component lint judges. Until #25767 + // it reached ONE position beyond lint — the retired `body` spelling of + // `page:card` — because the slot list carried that entry as retired for the + // stored-row conversion that moved it. That kit was a ≤16.x retirement and + // left the list with its conversion, so today the two walks agree exactly; + // the fixture keeps the `body` probe so the next retired slot entry shows + // up here by path rather than silently. const walked = new Set(walkedProbePaths()); const converted = new Set(convertedProbePaths()); expect([...walked].filter((p) => !converted.has(p))).toEqual([]); - expect([...converted].filter((p) => !walked.has(p))).toEqual([ - 'pages[0].regions[0].components[3].properties.body[0]', - ]); + expect([...converted].filter((p) => !walked.has(p))).toEqual([]); }); it('source-authored pages are the one deliberate difference', () => { @@ -188,7 +191,7 @@ describe('#6775 — walkPageComponents and the conversion walk reach the same co }); describe('#20940 — the three page walks read one slot list and reach the same positions', () => { - it('lint, the exported walk and the conversion walker reach the same probes on one page — the retired spelling aside', () => { + it('lint, the exported walk and the conversion walker reach the same probes on one page', () => { const { lint, exportedWalk, conversion } = reachedBy(); // Guards the fixture: a walk reaching nothing would agree with another // reaching nothing. @@ -198,15 +201,17 @@ describe('#20940 — the three page walks read one slot list and reach the same // The two authoring walks: identical, `card footer` and `two deep` // (under the footer) included — the pair #20940 found apart. expect([...exportedWalk].sort()).toEqual([...lint].sort()); - // The conversion walker: the same, plus exactly the probe under a - // position the list marks retired. - expect([...conversion].filter((title) => !lint.has(title))).toEqual(['card body']); + // The conversion walker: the same. (Until #25767 it also reached `card + // body`, the one position the list marked retired; that entry left with + // its ≤16.x conversion, so the three sets are now equal.) + expect([...conversion].filter((title) => !lint.has(title))).toEqual([]); expect([...lint].filter((title) => !conversion.has(title))).toEqual([]); }); it('the difference IS the list\'s retired entries — no walk keeps a position of its own', () => { const positions = pageComponentSlotPositions(); - expect(positions.filter((p) => p.retired).map((p) => p.key)).toEqual(['body']); + // No position is marked retired today: `body` was, and left on #25767. + expect(positions.filter((p) => p.retired).map((p) => p.key)).toEqual([]); // Every authorable position has a probe on the fixture page, so the // equality above covers the whole list, not a sample of it. const authorable = positions.filter((p) => !p.retired) diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index bcd65d49e9c..d4f7619a1fa 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2852,8 +2852,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // so this site is a member of `retired-key-migrate-sentence.test.ts`'s // widened scan (#7030) on the same textual shape as the spec corpus — no // interpolation lives in this clause, so nothing is lost switching quote style. - 'Run `os migrate meta --from 16` to list the mechanical edits for existing ' - + 'sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', source: JSON.stringify({ id: node.id, type: node.type, config: cfg }), }); } else if (!fn) { diff --git a/packages/lint/src/validate-object-field-refs.ts b/packages/lint/src/validate-object-field-refs.ts index ed751582636..58986956469 100644 --- a/packages/lint/src/validate-object-field-refs.ts +++ b/packages/lint/src/validate-object-field-refs.ts @@ -186,9 +186,11 @@ * ## The retired `compactLayout` alias * * `compactLayout` was renamed to `highlightFields` in `@objectstack/spec` - * 11.7.0 (ADR-0085) and the `object-compactLayout-to-highlightFields` - * conversion normalizes it before a parsed stack reaches any rule, so on the - * parsed tier this alias cannot appear. It is read here anyway, at the same + * 11.7.0 (ADR-0085); the `object-compactLayout-to-highlightFields` conversion + * normalized it before a parsed stack reached any rule until #25767 retired it + * (a ≤16.x kit), and since then the strict parse refuses the alias with the + * rename prescription — so on the parsed tier this alias cannot appear either + * way. It is read here anyway, at the same * position, for one reason and not as a tolerance: the clause this rule takes * over from `validateSemanticRoles` read it, and the `lint` path carries raw * config that has not been through the conversion. Dropping the read would be diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 5ba60477883..d139ff7dfe0 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -100,7 +100,7 @@ * | a pre-D4 stored `sys_metadata` sibling in the gate's universe | no — cancels in the diff| * | **`os lint` on a RAW object-literal config** (never parses; | **yes — fires** | * | `loadConfig` returns the default export as authored, and | | - * | `owd-legacy-read-aliases` is `retiredFromLoadPath`, so | | + * | `owd-legacy-read-aliases` is retired (#25767), so | | * | `normalizeStackInput` leaves the alias intact) | | * | **`defineStack(x, { strict: false })`** | **yes — fires** | * | **`check:doc-security-posture`** (docs gate: statically | **yes — fires**; its | @@ -164,7 +164,8 @@ const CANONICAL_OWD = ['private', 'public_read', 'public_read_write', 'controlle * THREE — "The legacy aliases `read`, `read_write`, `full` are **removed from * the zod enum**" — and only these three have a retirement behind them: the * `owd-legacy-read-aliases` ADR-0087 stored-row conversion for the two `read*` - * spellings, and the `13.owd-full-alias-removed` semantic entry for `full`. + * spellings (a ≤16.x kit, retired on #25767), and the `13.owd-full-alias-removed` + * semantic entry for `full`. */ const OWD_RETIRED_ALIAS_FIX: Record = { read: 'public_read', diff --git a/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts b/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts index 9279e113112..6cdcab89323 100644 --- a/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion-package-body.test.ts @@ -34,8 +34,9 @@ import { applyArtifactForwardConversions } from './artifact-forward-conversion.j const SUBJECT = 'dashboard-refresh-interval-to-refresh-interval-seconds'; -/** The door's DEFAULT-FLIP refusals (`DEFAULT_FLIPS_NOT_REPLAYED_HERE`, module-local by design). */ -const DOOR_DEFAULT_FLIPS: readonly string[] = ['app-hidden-to-unpublished', 'flow-decision-mode-inclusive-explicit']; +/** The door's DEFAULT-FLIP refusals (`DEFAULT_FLIPS_NOT_REPLAYED_HERE`, module-local by design). + * `app-hidden-to-unpublished` left the list when #25767 retired it with the ≤16.x kits. */ +const DOOR_DEFAULT_FLIPS: readonly string[] = ['flow-decision-mode-inclusive-explicit']; const versionTriple = (v: string): [number, number, number] => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)) as [number, number, number]; diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 52f3f6cc21a..cf8d24d3a0b 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -50,7 +50,7 @@ void _mirrorPin; // named, as the blocks below already name them. /** The door's DEFAULT-FLIP refusals, named in their own blocks below (#17885, #15429). */ -const DOOR_DEFAULT_FLIPS: readonly string[] = ['app-hidden-to-unpublished', 'flow-decision-mode-inclusive-explicit']; +const DOOR_DEFAULT_FLIPS: readonly string[] = ['flow-decision-mode-inclusive-explicit']; const versionTriple = (v: string): [number, number, number] => v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)) as [number, number, number]; @@ -379,8 +379,13 @@ describe('the artifact door never stamps a column constraint (ADR-0113, #16693)' /** * #17885 — the artifact door must not replay the DEFAULT-FLIP class. * - * `app-hidden-to-unpublished` (#4829, ADR-0045 amended 2026-08-09) rewrites - * `app.hidden: true` into `app._unpublished: true`. Both keys are live and + * `app-hidden-to-unpublished` (#4829, ADR-0045 amended 2026-08-09) rewrote + * `app.hidden: true` into `app._unpublished: true`. #25767 retired the entry + * with the ≤16.x kits, so the door no longer has it to refuse — the pins below + * stay because the outcome they guard (an authored `hidden: true` reaches + * registration untouched) is the outcome, whatever the registry carries; the + * step-18 reading further down is where the door's refusal-by-id is still + * exercised on a live entry. Both keys are live and * they mean opposite kinds of thing: `hidden` is navigation presentation and * *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the * machine-managed publish gate `filterAppForUser` drops the app on for every @@ -552,18 +557,22 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub }); /** - * ⭐ SEAM SCOPE — the entry is refused by THIS DOOR, not removed from the - * chain. The stored-row seam, where a `hidden: true` row can only have come - * from the pre-split materialization path, still carries the population - * across. A fix that had neutered the entry would go green on every leg - * above and silently strand those rows. + * SEAM SCOPE, after #25767. Until then this case pinned the OTHER half of the + * #17885 ruling: the entry was refused by this door and still fired at the + * stored-row seam, where a `hidden: true` row could only have come from the + * pre-split materialization path. #25767 retired the entry itself (a ≤16.x + * kit, ADR-0087 D2 = A), so no seam rewrites that row any more: a pre-split + * row is canonicalized once, `os migrate meta --stored --apply` on a release + * that still carries the step, and from here the stored-row seam reads + * `hidden: true` as what it says. Pinned so a re-registration of the entry + * is a deliberate act, not a merge accident. */ - it('leaves the entry firing at the stored-row seam it is sound at', () => { + it('the stored-row seam no longer rewrites `hidden: true` either — the entry is retired, not merely refused here', () => { const row = applyConversionsToStoredItem('app', { name: 'production_management', label: 'Production', hidden: true, navigation: [], }) as Record; - expect(row._unpublished, 'the stored-row seam still converts').toBe(true); - expect(row.hidden).toBeUndefined(); + expect(row._unpublished, 'no seam invents the publish gate now').toBeUndefined(); + expect(row.hidden).toBe(true); }); }); diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index de347ae550f..3ad48eeb465 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -415,8 +415,9 @@ export function resolveInstalledSpecVersion(): string | null { * is the door's own refusal, not a fact about the registry, and exporting it * would invite a second caller to inherit a judgement it has not made. * - * - `app-hidden-to-unpublished` (#17885, #4829, ADR-0045 amended 2026-08-09): - * rewrites `app.hidden: true` into `app._unpublished: true`. `hidden` is a + * - `app-hidden-to-unpublished` (#17885, #4829, ADR-0045 amended 2026-08-09; + * retired with the ≤16.x kits on #25767, kept here as the worked example): + * rewrote `app.hidden: true` into `app._unpublished: true`. `hidden` is a * LIVE authorable key — navigation presentation, *"never an access gate"* * (`ui/app.zod.ts`) — while `_unpublished` is the machine-managed publish * gate that `filterAppForUser` (`packages/rest/src/rest-server.ts`) drops @@ -450,7 +451,10 @@ export function resolveInstalledSpecVersion(): string | null { * `packages/services/service-automation/src/engine.ts`); this door does too. */ const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = [ - 'app-hidden-to-unpublished', + // `app-hidden-to-unpublished` stood here until #25767 retired it with the + // ≤16.x kits (ADR-0087 D2 = A): the registry no longer carries it, so there is + // nothing for this door to refuse. The reasoning in the docblock above is kept + // because it is the reasoning the NEXT default flip is judged by. 'flow-decision-mode-inclusive-explicit', ]; diff --git a/packages/metadata-core/src/object-schema-fls-references.ts b/packages/metadata-core/src/object-schema-fls-references.ts index 499b92f5515..d981b77e557 100644 --- a/packages/metadata-core/src/object-schema-fls-references.ts +++ b/packages/metadata-core/src/object-schema-fls-references.ts @@ -605,7 +605,6 @@ export const FIELD_REFERENCE_POSITIONS: Readonly> = { group: keep, widget: keep, hidden: keep, internal: keep, readonly: keep, requiredPermissions: keep, maskingRule: keep, ackPlaintextMasking: keep, system: keep, sortable: keep, inlineHelpText: keep, placeholder: keep, externalId: keep, - conditionalRequired: keep, // A boolean facet of this date field: it declares the field a deadline // (#22227) and names no field. dueLike: keep, diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index 6828216ccc9..18207f6fb14 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -66,6 +66,7 @@ "ObjectStackRawIssue (type)", "P (const)", "PLURAL_TO_SINGULAR (const)", + "PROTOCOL_UPGRADE_GUIDE_URL (const)", "Predicate (type)", "PredicateInput (type)", "PredicateInputSchema (const)", @@ -73,6 +74,7 @@ "Protection (type)", "ProtectionSchema (const)", "QUALIFIED_ITEM_NAME_PATTERN (const)", + "RETIRED_KEY_UPGRADE_GUIDE_SENTENCE (const)", "RateLimitConfig (type)", "RateLimitConfigParsed (type)", "RateLimitConfigSchema (const)", diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index 63ab9814a7a..37f29b34faa 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -24,7 +24,6 @@ "ai/Agent:avatar", "ai/Agent:guardrails", "ai/Agent:instructions", - "ai/Agent:knowledge [RETIRED]", "ai/Agent:label", "ai/Agent:lifecycle [RETIRED]", "ai/Agent:memory", @@ -37,7 +36,6 @@ "ai/Agent:skills", "ai/Agent:structuredOutput", "ai/Agent:surface", - "ai/Agent:tools [RETIRED]", "ai/BlueprintApp:icon", "ai/BlueprintApp:label", "ai/BlueprintApp:name", @@ -317,7 +315,6 @@ "ai/Skill:surface", "ai/Skill:tools", "ai/Skill:triggerConditions", - "ai/Skill:triggerPhrases [RETIRED]", "ai/SkillTriggerCondition:field", "ai/SkillTriggerCondition:operator", "ai/SkillTriggerCondition:value", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 2b7d2eda8ed..494e66face2 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -71,12 +71,10 @@ "api/AnalyticsMetadataResponse:success", "api/AnalyticsQueryRequest:cube", "api/AnalyticsQueryRequest:dimensions", - "api/AnalyticsQueryRequest:format [RETIRED]", "api/AnalyticsQueryRequest:limit", "api/AnalyticsQueryRequest:measures", "api/AnalyticsQueryRequest:offset", "api/AnalyticsQueryRequest:order", - "api/AnalyticsQueryRequest:query [RETIRED]", "api/AnalyticsQueryRequest:timeDimensions", "api/AnalyticsQueryRequest:timezone", "api/AnalyticsQueryRequest:where", @@ -352,7 +350,6 @@ "api/CreateFlowRequest:_packageId", "api/CreateFlowRequest:_packageVersion", "api/CreateFlowRequest:_provenance", - "api/CreateFlowRequest:active [RETIRED]", "api/CreateFlowRequest:description", "api/CreateFlowRequest:edges", "api/CreateFlowRequest:errorHandling", @@ -365,7 +362,6 @@ "api/CreateFlowRequest:runAs", "api/CreateFlowRequest:status", "api/CreateFlowRequest:successMessage", - "api/CreateFlowRequest:template [RETIRED]", "api/CreateFlowRequest:type", "api/CreateFlowRequest:variables", "api/CreateFlowRequest:version", @@ -1266,7 +1262,6 @@ "api/PackageInstallManifest:id", "api/PackageInstallManifest:installModes", "api/PackageInstallManifest:integrity", - "api/PackageInstallManifest:loading [RETIRED]", "api/PackageInstallManifest:main", "api/PackageInstallManifest:name", "api/PackageInstallManifest:namespace", diff --git a/packages/spec/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index 7347885224e..ff55e5f347a 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -157,7 +157,6 @@ "automation/Flow:_packageId", "automation/Flow:_packageVersion", "automation/Flow:_provenance", - "automation/Flow:active [RETIRED]", "automation/Flow:description", "automation/Flow:edges", "automation/Flow:errorHandling", @@ -170,7 +169,6 @@ "automation/Flow:runAs", "automation/Flow:status", "automation/Flow:successMessage", - "automation/Flow:template [RETIRED]", "automation/Flow:type", "automation/Flow:variables", "automation/Flow:version", @@ -189,7 +187,6 @@ "automation/FlowNode:id", "automation/FlowNode:inputSchema", "automation/FlowNode:label", - "automation/FlowNode:outputSchema [RETIRED]", "automation/FlowNode:position", "automation/FlowNode:timeoutMs", "automation/FlowNode:type", @@ -286,7 +283,6 @@ "automation/RetryPolicy:jitter", "automation/RetryPolicy:maxRetries", "automation/RetryPolicy:maxRetryDelayMs", - "automation/RetryPolicy:retryDelayMs [RETIRED]", "automation/ScreenConfig:defaults", "automation/ScreenConfig:description", "automation/ScreenConfig:fields", @@ -308,14 +304,9 @@ "automation/ScreenFieldConfig:required", "automation/ScreenFieldConfig:type", "automation/ScreenFieldConfig:visibleWhen", - "automation/ScriptConfig:actionType [RETIRED]", "automation/ScriptConfig:function", "automation/ScriptConfig:inputs", "automation/ScriptConfig:outputVariable", - "automation/ScriptConfig:recipients [RETIRED]", - "automation/ScriptConfig:script [RETIRED]", - "automation/ScriptConfig:template [RETIRED]", - "automation/ScriptConfig:variables [RETIRED]", "automation/SubflowConfig:flowName", "automation/SubflowConfig:input", "automation/SubflowConfig:outputVariable", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 5fc0f149aed..db15e412316 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -327,7 +327,6 @@ "data/Field:allowCreate", "data/Field:autonumberFormat", "data/Field:conditionalFormatting", - "data/Field:conditionalRequired [RETIRED]", "data/Field:currencyConfig", "data/Field:defaultValue", "data/Field:deleteBehavior", @@ -503,8 +502,6 @@ "data/ImportFieldMapping:transform", "data/Index:fields", "data/Index:name", - "data/Index:partial [RETIRED]", - "data/Index:type [RETIRED]", "data/Index:unique", "data/InlineGridColumn:accept", "data/InlineGridColumn:autofill", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index a7df5d9aa3d..5a9747c566a 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -272,7 +272,6 @@ "kernel/Manifest:id", "kernel/Manifest:installModes", "kernel/Manifest:integrity", - "kernel/Manifest:loading [RETIRED]", "kernel/Manifest:main", "kernel/Manifest:name", "kernel/Manifest:namespace", diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index 5dd8b57868a..5d4dc06eca4 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -162,7 +162,6 @@ "security/RowLevelSecurityPolicy:object", "security/RowLevelSecurityPolicy:operation", "security/RowLevelSecurityPolicy:positions", - "security/RowLevelSecurityPolicy:priority [RETIRED]", "security/RowLevelSecurityPolicy:tags [RETIRED]", "security/RowLevelSecurityPolicy:using", "security/SharingRule:_lock", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index cf6f38d8607..c7672594ecf 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -984,7 +984,6 @@ "system/RetryPolicy:jitter", "system/RetryPolicy:maxRetries", "system/RetryPolicy:maxRetryDelayMs", - "system/RetryPolicy:retryDelayMs [RETIRED]", "system/RouteHandlerMetadata:handler", "system/RouteHandlerMetadata:metadata", "system/RouteHandlerMetadata:method", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index c9b141ccc82..184309824bf 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -14,13 +14,11 @@ "ui/Action:body", "ui/Action:bodyExtra", "ui/Action:bodyShape", - "ui/Action:bulkEnabled [RETIRED]", "ui/Action:component", "ui/Action:confirmText", "ui/Action:description", "ui/Action:disabled", "ui/Action:errorMessage", - "ui/Action:execute [RETIRED]", "ui/Action:execution", "ui/Action:icon", "ui/Action:label", @@ -45,7 +43,6 @@ "ui/Action:requiresFeature", "ui/Action:requiresMembershipReach", "ui/Action:resultDialog", - "ui/Action:shortcut [RETIRED]", "ui/Action:successMessage", "ui/Action:target", "ui/Action:type", @@ -173,27 +170,19 @@ "ui/App:_provenance", "ui/App:_unpublished", "ui/App:active", - "ui/App:apis [RETIRED]", "ui/App:areas", - "ui/App:aria [RETIRED]", "ui/App:branding", "ui/App:contextSelectors", "ui/App:defaultAgent", "ui/App:description", - "ui/App:embed [RETIRED]", "ui/App:hidden", - "ui/App:homePageId [RETIRED]", "ui/App:icon", "ui/App:isDefault", "ui/App:label", - "ui/App:mobileNavigation [RETIRED]", "ui/App:name", "ui/App:navigation", - "ui/App:objects [RETIRED]", "ui/App:protection", "ui/App:requiredPermissions", - "ui/App:sharing [RETIRED]", - "ui/App:version [RETIRED]", "ui/AppBranding:accentColor", "ui/AppBranding:favicon", "ui/AppBranding:logo", @@ -315,7 +304,6 @@ "ui/Dashboard:_packageId", "ui/Dashboard:_packageVersion", "ui/Dashboard:_provenance", - "ui/Dashboard:aria [RETIRED]", "ui/Dashboard:columns", "ui/Dashboard:dateRange", "ui/Dashboard:description", @@ -324,7 +312,6 @@ "ui/Dashboard:header", "ui/Dashboard:label", "ui/Dashboard:name", - "ui/Dashboard:performance [RETIRED]", "ui/Dashboard:protection", "ui/Dashboard:refreshInterval [RETIRED]", "ui/Dashboard:refreshIntervalSeconds", @@ -350,10 +337,6 @@ "ui/DashboardNavItem:requiresService", "ui/DashboardNavItem:type", "ui/DashboardNavItem:visible", - "ui/DashboardWidget:actionIcon [RETIRED]", - "ui/DashboardWidget:actionType [RETIRED]", - "ui/DashboardWidget:actionUrl [RETIRED]", - "ui/DashboardWidget:aria [RETIRED]", "ui/DashboardWidget:chartConfig", "ui/DashboardWidget:colorVariant", "ui/DashboardWidget:compareTo", @@ -367,7 +350,6 @@ "ui/DashboardWidget:options", "ui/DashboardWidget:requiresObject", "ui/DashboardWidget:requiresService", - "ui/DashboardWidget:responsive [RETIRED]", "ui/DashboardWidget:suppressWarnings", "ui/DashboardWidget:title", "ui/DashboardWidget:type", @@ -486,16 +468,13 @@ "ui/ElementNumberProps:prefix", "ui/ElementNumberProps:suffix", "ui/ElementRecordPickerProps:aria", - "ui/ElementRecordPickerProps:displayField [RETIRED]", "ui/ElementRecordPickerProps:emptyText", "ui/ElementRecordPickerProps:filter [RETIRED]", "ui/ElementRecordPickerProps:label", "ui/ElementRecordPickerProps:labelField", "ui/ElementRecordPickerProps:limit [RETIRED]", - "ui/ElementRecordPickerProps:multiple [RETIRED]", "ui/ElementRecordPickerProps:object [RETIRED]", "ui/ElementRecordPickerProps:placeholder", - "ui/ElementRecordPickerProps:searchFields [RETIRED]", "ui/ElementRecordPickerProps:sort [RETIRED]", "ui/ElementRecordPickerProps:targetVariable [RETIRED]", "ui/ElementRecordPickerProps:valueField", @@ -572,11 +551,9 @@ "ui/FormSelectOption:value", "ui/FormSelectOption:visibleWhen", "ui/FormView:allowSkip", - "ui/FormView:aria [RETIRED]", "ui/FormView:buttons", "ui/FormView:columns", "ui/FormView:data", - "ui/FormView:defaultSort [RETIRED]", "ui/FormView:defaultTab", "ui/FormView:defaults", "ui/FormView:description", @@ -742,7 +719,6 @@ "ui/ListView:allowPrinting", "ui/ListView:appearance", "ui/ListView:aria", - "ui/ListView:bordered [RETIRED]", "ui/ListView:bulkActionDefs", "ui/ListView:bulkActions", "ui/ListView:calendar", @@ -769,10 +745,8 @@ "ui/ListView:navigation", "ui/ListView:pageName [RETIRED]", "ui/ListView:pagination", - "ui/ListView:performance [RETIRED]", "ui/ListView:requiredPermissions", "ui/ListView:resizable", - "ui/ListView:responsive [RETIRED]", "ui/ListView:rowActions", "ui/ListView:rowColor", "ui/ListView:rowHeight", @@ -781,14 +755,12 @@ "ui/ListView:sharing", "ui/ListView:showRecordCount", "ui/ListView:sort", - "ui/ListView:striped [RETIRED]", "ui/ListView:tabs [RETIRED]", "ui/ListView:timeline", "ui/ListView:tree", "ui/ListView:type", "ui/ListView:userActions", "ui/ListView:userFilters", - "ui/ListView:virtualScroll [RETIRED]", "ui/NavigationArea:description", "ui/NavigationArea:icon", "ui/NavigationArea:id", @@ -929,7 +901,6 @@ "ui/ObjectListView:allowPrinting", "ui/ObjectListView:appearance", "ui/ObjectListView:aria", - "ui/ObjectListView:bordered [RETIRED]", "ui/ObjectListView:bulkActionDefs", "ui/ObjectListView:bulkActions", "ui/ObjectListView:calendar", @@ -956,10 +927,8 @@ "ui/ObjectListView:navigation", "ui/ObjectListView:pageName [RETIRED]", "ui/ObjectListView:pagination", - "ui/ObjectListView:performance [RETIRED]", "ui/ObjectListView:requiredPermissions", "ui/ObjectListView:resizable", - "ui/ObjectListView:responsive [RETIRED]", "ui/ObjectListView:rowActions", "ui/ObjectListView:rowColor", "ui/ObjectListView:rowHeight", @@ -968,14 +937,12 @@ "ui/ObjectListView:sharing", "ui/ObjectListView:showRecordCount", "ui/ObjectListView:sort", - "ui/ObjectListView:striped [RETIRED]", "ui/ObjectListView:tabs [RETIRED]", "ui/ObjectListView:timeline", "ui/ObjectListView:tree", "ui/ObjectListView:type", "ui/ObjectListView:userActions", "ui/ObjectListView:userFilters", - "ui/ObjectListView:virtualScroll [RETIRED]", "ui/ObjectMapProps:data", "ui/ObjectMapProps:enableClustering", "ui/ObjectMapProps:filter", @@ -1088,9 +1055,7 @@ "ui/PageAccordionProps:aria", "ui/PageAccordionProps:items", "ui/PageAccordionProps:variant", - "ui/PageCardProps:actions [RETIRED]", "ui/PageCardProps:aria", - "ui/PageCardProps:body [RETIRED]", "ui/PageCardProps:bordered", "ui/PageCardProps:children", "ui/PageCardProps:footer", @@ -1112,7 +1077,6 @@ "ui/PageHeaderProps:actions", "ui/PageHeaderProps:aria", "ui/PageHeaderProps:breadcrumb [RETIRED]", - "ui/PageHeaderProps:icon [RETIRED]", "ui/PageHeaderProps:maxVisible", "ui/PageHeaderProps:mobileMaxVisible", "ui/PageHeaderProps:recordChrome", @@ -1154,7 +1118,6 @@ "ui/PageTabsProps:items", "ui/PageTabsProps:position", "ui/PageTabsProps:tabStyle", - "ui/PageTabsProps:type [RETIRED]", "ui/PageVariable:defaultValue", "ui/PageVariable:name", "ui/PageVariable:source", @@ -1197,7 +1160,6 @@ "ui/RecordDetailsProps:fields", "ui/RecordDetailsProps:hideFields", "ui/RecordDetailsProps:inlineEdit", - "ui/RecordDetailsProps:layout [RETIRED]", "ui/RecordDetailsProps:redactFields", "ui/RecordDetailsProps:requiredPermissions", "ui/RecordDetailsProps:sections", diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 7dd8c969ad6..c8fa87d4c2b 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -61,6 +61,7 @@ "ObjectStackRawIssue": "src/shared/error-map.zod.ts#ObjectStackRawIssue (type)", "P": "src/shared/expression.zod.ts#P (const)", "PLURAL_TO_SINGULAR": "src/meta-spelling/manifest-collection-spelling.ts#PLURAL_TO_SINGULAR (const)", + "PROTOCOL_UPGRADE_GUIDE_URL": "src/shared/protocol-upgrade-guide.ts#PROTOCOL_UPGRADE_GUIDE_URL (const)", "Predicate": "src/shared/expression.zod.ts#Predicate (type)", "PredicateInput": "src/shared/expression.zod.ts#PredicateInput (type)", "PredicateInputSchema": "src/shared/expression.zod.ts#PredicateInputSchema (const)", @@ -68,6 +69,7 @@ "Protection": "src/shared/protection.zod.ts#Protection (type)", "ProtectionSchema": "src/shared/protection.zod.ts#ProtectionSchema (const)", "QUALIFIED_ITEM_NAME_PATTERN": "src/shared/identifiers.zod.ts#QUALIFIED_ITEM_NAME_PATTERN (const)", + "RETIRED_KEY_UPGRADE_GUIDE_SENTENCE": "src/shared/protocol-upgrade-guide.ts#RETIRED_KEY_UPGRADE_GUIDE_SENTENCE (const)", "RateLimitConfig": "src/shared/http.zod.ts#RateLimitConfig (type)", "RateLimitConfigParsed": "src/shared/http.zod.ts#RateLimitConfigParsed (type)", "RateLimitConfigSchema": "src/shared/http.zod.ts#RateLimitConfigSchema (const)", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index bc31102c686..635e3911915 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -76,11 +76,6 @@ "evidence": "packages/runtime/src/sandbox/body-runner.ts#actionBodyRunnerFactory (`const raw = action.body`, in the factory whose own header calls it \"the ONE choke point where an `action.body` becomes an executable handler\" — both bind paths reach it: AppPlugin's collectBundleActions walk and engine.setDefaultActionRunner); packages/runtime/src/action-execution.ts#isHeadlessInvokableAction (gates headless dispatch on `action?.target || action?.body`)", "note": "server script (L1/L2) via engine.executeAction→body-runner. 2026-08-26: REPOINTED — the evidence cited packages/runtime/src/http-dispatcher.ts, which reads this key nowhere. Its 68 word-`body` occurrences are the inbound HTTP request body — the `body: any` parameter threaded through the handleX delegates (:890, :961, :1732, :1969 …) and the result envelope at :728 — plus the file's own \"Thin delegate — body extracted to ./domains/…\" extraction comments, where `body` means a function body. The consumer this note already NAMED in prose (body-runner) is where the read has always been; the surrounding action path was extracted into domains/actions.ts + action-execution.ts, and http-dispatcher.ts now only delegates (handleActions → handleActionsRequest at :1969-1970). WHY THE GATE COULD NOT SEE THE ROT: `body` is both a common English word and the name of the HTTP request member this dispatcher is built around, so the word-bounded key-mention check added in #11457 anchors on 68 unrelated hits and passes — the designed, honest limit of that signal, which is why this needed a hand call-graph read. Note the shape the repoint exposes: the NOTE's pointer (\"via engine.executeAction→body-runner\") stayed true the whole time while the EVIDENCE pointer rotted — the same split recorded on `requiredPermissions`, one layer over. 2026-08-28: RE-ANCHORED (commit 93ea19bca) — re-closed by hand against c459da6bc; both consumers stand where the 08-26 repoint put them, so the lines had not yet rotted and this is the grammar migration, not a second repair. The anchor is what makes that claim re-testable next time: `actionBodyRunnerFactory` is the symbol the header already calls the ONE choke point, so a consumer that moves inside this 798-line file keeps the pointer true and one that is deleted turns it red." }, - "execute": { - "status": "dead", - "verifiedAt": "2026-08-29", - "note": "REMOVED 2026-07-28 in protocol 17 (#3855, PR #3883) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of sources by the protocol-17 conversion `action-execute-to-target`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `target`, the only handler slot — rename the key, the value (a handler / flow / URL ref) is unchanged, and `os migrate meta --from 16` lists the mechanical edits. The tombstone is packages/spec/src/ui/action.zod.ts#execute, and packages/cli/src/utils/lower-callables.ts#lowerActionCallable deliberately declines to bind a function-valued `execute` so the tombstone fires instead of the alias silently working in one authoring style and being rejected in every other. LEDGER CORRECTED 2026-08-29 (commit cae2169cf) — the VERDICT was falsified, not the citation. This row read `live` on the claim that a .transform lowers execute -> target and DROPS the alias; no such transform exists. action.zod.ts has exactly two .transform calls (:526 and :1668) and both are lowerRequiresFeature, and the docblock above `target` (:559-562) says the alias was removed in protocol 17. The old pointer packages/spec/src/ui/action.zod.ts:581 was IN RANGE in an 1802-line file that names the key, so existence, line bound and key-mention were all green on it — what :581 holds today is a comment about the `global_nav` enum-VALUE retirement's declaration style, unrelated in every respect — and the entry carried no verifiedAt, so the re-verification clock never asked (the #12516 class in its purest form). HISTORY PRESERVED, because it is the argument for `target` being the single handler slot: DIVERGENCE RESOLVED in #3713 — before that fix three readers disagreed in two directions, the parse kept `target`, objectui ActionRunner did `execute || target`, and the CLI compile step (packages/cli/src/utils/lower-callables.ts) preferred a function on `execute`; #3713 made all three prefer `target`, and protocol 17 then removed the alias outright, so the both-declared conflict is unrepresentable rather than merely agreed-upon (mirrors agent.knowledge.topics -> sources, #1891). The server runtime never read `execute` at all — packages/runtime/src/action-execution.ts:525 gates on `target || body` and dispatches on `target`/`name` — so with the parse-time lowering gone there is no reader on any layer." - }, "params": { "status": "live", "verifiedAt": "2026-09-15", @@ -233,16 +228,6 @@ "status": "dead", "verifiedAt": "2026-09-28", "note": "REMOVED 2026-09-28 (#20323, ADR-0049 enforce-or-remove) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 conversion action-aria-removed. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); write the accessible name as the action's required `label`, and put a region name in the placing node's `aria` block (`page.components[].aria` or the list view `aria`). The `live` grade this row replaces rested on an uncited note — 「PARTIAL — honored by a few objectui renderers, not the core action buttons/menus」 — with no reader behind it: re-measured at the `.objectui-sha` pin f8a9d0fb05, none of the action surfaces (action:button/icon/menu/group/bar, the grid row and bulk action menus, record:quick_actions, the declared-actions bar) reads an action's `aria`; the only `schema.aria` readers there are the placing nodes' own blocks (the record:* page components, the list view, element:button's props). Measured at objectui @f8a9d0fb05 (moved out of the packages/spec/src/ui/action.zod.ts tombstone comment, #23434): none of the surfaces that render an action — `action:button`, `action:icon`, `action:menu`, `action:group`, `action:bar`, the grid's row and bulk action menus, `record:quick_actions`, the declared-actions bar — reads an action's `aria`; the only `schema.aria` readers are the placing nodes' own blocks." - }, - "shortcut": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); register keys in the Console keyboard stack and invoke the action by name." - }, - "bulkEnabled": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); declare the action in the view's `bulkActions` instead." } } } diff --git a/packages/spec/liveness/agent.json b/packages/spec/liveness/agent.json index d95345e24c9..af0e6d0e835 100644 --- a/packages/spec/liveness/agent.json +++ b/packages/spec/liveness/agent.json @@ -51,11 +51,6 @@ "evidence": "cloud @cb8ee7ff: packages/service-ai/src/agent-runtime.ts#resolveActiveSkills — the name list handed to SkillRegistry.listActiveSkills, and the only tool-bearing slot under ADR-0064.", "note": "Agent→Skill→Tool." }, - "tools": { - "status": "dead", - "verifiedAt": "2026-09-15", - "note": "REMOVED in protocol 17 (#3894) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error before any parse, and a parse error after) and dropped from legacy sources by the ADR-0087 conversion `agent-tools-to-skills`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); an agent reaches exactly the tools its surface-compatible skills declare (ADR-0064), so re-declare each entry in a skill — the `skills` row on this type is that slot. LEDGER CORRECTED 2026-09-18 (#18304) — the VERDICT moved `live` -> `dead`, a truth correction and not a relaxation: the row asserted `live` on a key this repo's own AgentSchema had tombstoned, which the published vocabulary cannot mean. `live` is defined as \"Has a runtime consumer\", and retiredKey types the key `never`, so no author can put a value behind it and no consumer in any repo can read one. THE LOAD-BEARING EVIDENCE IS LOCAL AND RE-MEASURABLE HERE, which is the half the old citation never had: the tombstone packages/spec/src/ai/agent.zod.ts#tools, the strip packages/spec/src/conversions/registry.ts#agentToolsToSkills — whose own docblock records that the cloud runtime stopped reading the field entirely (cloud#910) — and that conversion's D3 chain step in packages/spec/src/migrations/registry.ts. Both populations are covered without leaving this checkout: an authored source is refused at tsc and at parse, and a stored row or built artifact has the key DELETED by the replayed conversion chain at rehydration (ADR-0087 addendum, retired entries included) before any consumer sees it. The verdict is also read off this ledger's own convention rather than chosen: measured 2026-09-18 over the 36 governed types the gate walks, 40 top-level keys carry a `[REMOVED]` tombstone description and all 40 are never-typed — 39 of them said `dead` and this one said `live`, the single outlier in the class. ⚠ THE CROSS-REPO HALF IS NOT THIS SEAT'S READING. The zero-consumer census in cloud @cb8ee7ff60c097cc21a584fe9caf8ef4391cc0e8 (the only two mentions there are comments recording the branch's removal) and its lit positive control (`agent.skills`, four real reads in the same tree, same path shape) were taken by the #13272 re-verification on 2026-09-15 — the date stamped here, the same one its ten stamped siblings carry. Cloud is not reachable from an open-source checkout, so the seat that landed this correction re-derived the in-repo half only and cites the cloud half as attributed, never as its own measurement. `live-elsewhere` is REFUSED rather than undeclared: that status requires a genuine foreign enforcer, and a key nothing can carry a value for has nothing to enforce — the same read that would have to support it is the read that found zero. The old `evidence` string (`cloud: packages/service-ai/src/agent-runtime.ts`) is DELETED rather than repointed: a dead row's pointer lives in this note by the gate's own design (EVIDENCE_UNSCANNED_STATUSES in scripts/liveness/check-liveness.mts), and that string named the one file whose remaining mentions of the key are the comments recording its deletion. ⚠ WHY IT SAT `live` SINCE THE 2026-06 AUDIT — the row was exempt from scrutiny rather than scrutinised. Its citation spelled `packages/services/service-ai/...` until #13309, and `FOREIGN_PATH_PREFIXES` in scripts/liveness/evidence.mts classifies that prefix foreign and never resolves it; after the repoint the explicit `cloud` realm marker classifies it foreign too. Both spellings are unresolvable BY DESIGN, so no gate could fail on this row in either direction — only a human, or a seat with cloud access, ever could. NEXT STEP IS A SEPARATE CARD: with the row `dead`, the key is an ADR-0049 enforce-or-remove input (playbook `.claude/skills/spec-property-retirement`). ⛔ Not done here." - }, "active": { "status": "live", "verifiedAt": "2026-09-15", @@ -110,11 +105,6 @@ "evidenceScope": "cross-repo", "evidence": "cloud @cb62c3ea: packages/service-ai/src/agent-runtime.ts#compileStructuredOutput — the reader: it compiles the agent's structuredOutput into the turn's output contract, reached through AgentRuntime.resolveTurnGuardrails; cloud @cb62c3ea: packages/service-ai/src/ai-service.ts#AIService.settleFinalAnswer — the enforcement, on every final answer in chatWithToolsImpl and streamChatWithTools, the forced final call included: format json_object / json_schema parsed and then checked against schema, strict, retryOnValidationFailure and maxRetries (each re-ask a guarded model round whose tokens are metered), fallbackFormat checked against the last answer once the primary's retries are spent, and transformPipeline trim / parse_json / validate. The refusal before the first round, TurnGovernor.unsupportedOutput, answered the members this spec has since retired (regex / grammar / xml and coerce_types) with AI_AGENT_STRUCTURED_OUTPUT_UNSUPPORTED.", "note": "Flipped from experimental on the cloud seat's reading, not on a reading taken from this repo — no seat in the flipping session could read cloud. The reading: the repo:cloud seat repo:cloud#1 (session_01Wxo1xhh2bU66T73q23jzE4), comment 5943158888 on objectstack-ai/objectstack#20274, of cloud main @cb62c3ea (the merge of objectstack-ai/cloud#2570), read 2026-10-02; verifiedAt is that reading's date, and every evidence pointer above is quoted from it. The blanket verdict covers every child: format, schema, strict, retryOnValidationFailure, maxRetries, fallbackFormat and transformPipeline. The same change retired the four members that reading found refused rather than enforced — the regex, grammar and xml formats and the coerce_types step (ruling record 5945617233, letter A): each is refused at parse with a prescription (enumWithRetiredValues in packages/spec/src/ai/agent.zod.ts), and the ADR-0087 conversion agent-structured-output-refused-members-removed repairs stored and existing sources — so every value an author can still write is one the runtime enforces. Producer: none in cloud at that ref; tenant-authored agents are NOT MEASURED. These keys are live because the cloud runtime consumes them; the OPEN framework edition does not run agents (this file's _note). ⛔ Re-attest by re-reading cloud, never by re-stamping the date." - }, - "knowledge": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); restrict retrieval at the knowledge-service/source level; describe grounding in `instructions`. AIKnowledgeSchema removed; the topics→sources rename absorbed pre-release." } } } diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index b2e5d970dec..20a93f6b6ac 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -314,12 +314,6 @@ }, "note": "Drilled because includeAll/placement diverge (dead) from the live core." }, - "homePageId": { - "status": "dead", - "verifiedAt": "2026-08-09", - "evidenceScope": "cross-repo", - "note": "CROSS-REPO RE-VERIFIED 2026-08-09 against objectui @c2fd1223 (#4895 task 1): the retirement holds, and the renderer side now says so in code rather than by absence — OuiAppSchema REJECTS the key (with a pin test asserting it is not accepted again), AppPreview ignores it (pin test), AppContent.resolveLandingRoute() documents the removal, and RootLandingRedirect routes on isDefault. No reader remains. The verdict below was first reached by searching this repo alone and was wrong for two months on exactly that account, so this row now records how wide the last look was. REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). PREMISE CORRECTED 2026-08-03 (#4709), removal upheld: #4667 justified this row with \"no shell ever read it\", which was FALSE and contradicted this repo's own 2026-06 AppSchema liveness audit (docs/audits/2026-06-appschema-property-liveness.md), which listed homePageId LIVE. objectui's console DID read it — resolveLandingRoute(), packages/app-shell/src/console/AppContent.tsx @785b8a5d — and it was the only thing deciding where an app opened. The key is retired for its SHAPE: an ID cross-reference into navigation with no referential integrity, silently falling back to the first item when it dangled. Post-v17 the landing IS the first navigation item (by order), and the ROOT landing follows isDefault routing (objectui RootLandingRedirect). Reorder navigation, or set isDefault, instead; if the capability returns it belongs on the navigation item itself (navigation[].landing), enforce-first. The three aliases that routed here (home / homepage / landingpage) were removed from AppSchema aliases and now carry the same prescription as guidance. Process lesson: a retirement citing liveness MUST reconcile against existing audit records — this contradiction went unnoticed for two months." - }, "requiredPermissions": { "status": "live", "verifiedAt": "2026-09-27", @@ -332,41 +326,6 @@ "evidence": "objectui @940ba24: packages/app-shell/src/hooks/surfaceAgent.ts:81, packages/app-shell/src/layout/ChatDock.tsx:253", "note": "bounded surface-binding knob (ADR-0063): resolved to the two platform agents (ask/build), alias-aware, anything else rejected — exactly as the schema documents." }, - "version": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142, 2026-06 audit) — authoring it is parse-rejected with the prescription (an app is versioned by its package's manifest.version). Row stays while the tombstone keeps the key in the walked shape." - }, - "aria": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142) — app-level ARIA was never read; declare aria on the rendering page component (`page.components[].aria`, live in page.json). NOT on a dashboard widget: `dashboard.widgets[].aria` is `dead` in dashboard.json, retired by #5010 in this same major — corrected 2026-08-08 (#6756)." - }, - "objects": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142) — objects belong to the stack; the ambient chatbot derives an app's object list from its nav items (collectNavObjects), never from App.objects." - }, - "apis": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142) — declarative endpoints belong to the stack." - }, - "sharing": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142, ADR-0049) — a declared-but-unenforced security surface; the live sharing path is FormView.sharing." - }, - "embed": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142, ADR-0049) — no iframe route ever read it; embedding is per form view." - }, - "mobileNavigation": { - "status": "dead", - "verifiedAt": "2026-08-01", - "note": "retiredKey tombstone (#4142) — fully unimplemented; returns if/when a real mobile navigation ships." - }, "_unpublished": { "status": "live", "verifiedAt": "2026-09-27", diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index 46f33a6218b..acd2cd18962 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -166,21 +166,6 @@ "evidence": "packages/rest/src/meta-item-read-gate.ts#filterDashboardForUser (ADR-0057 D10 — the widget is stripped from the payload when the named kernel service is not registered; fail-open when the kernel cannot be probed); packages/rest/src/rest.test.ts#filterDashboardForUser (the pin: drops widgets whose gate reports the service absent, keeps them when it is present)", "note": "server-side capability gate: the widget is stripped from the payload when the named kernel service is not registered (fail-open when the kernel cannot be probed). The authoritative half of the pair — read it as the counter-example to judging a widget key from the renderer repo alone. 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED, both legs — `rest-server.ts:1921-1931` had rotted onto the auth-gate normalization comment in `computeExecCtx`, ~380 lines above the widget gate, and `rest.test.ts:3271-3305` onto a NAV-level case (\"ITEM level: nav entries the caller cannot satisfy are stripped\") ~600 lines above the widget suite — a different gate's tests, still in range and still plausible. Re-closed by hand against 8cb96ec41." }, - "actionUrl": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "CALL GRAPH CLOSED BY HAND 2026-08-03, re-measured 2026-08-04 (objectui @91757a7). All 14 `actionUrl` occurrences in DashboardRenderer.tsx are scoped to `schema.header.actions[]` (:242-245 ActionDef build, :282-284 label i18n, :767-792 dispatch) — that is the DashboardHeaderAction schema, a different shape. Nothing anywhere read `widget.actionUrl`. The second-order cost is settled in the same change: packages/lint/src/validate-dashboard-action-refs.ts enforced ERROR-severity reference integrity on this key while its docblock called it 'the per-widget button', so a dangling target failed the build for an affordance that never rendered — that widget branch is deleted, with a pin test asserting it stays silent. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." - }, - "actionType": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "Same absence as `actionUrl`, same measurement (objectui @91757a7): every `actionType` read in the dashboard renderer belongs to `header.actions[]`. Its only consumer was the action-ref lint, picking which resolution rule to apply to the (unrendered) `actionUrl`; that branch is gone too. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." - }, - "actionIcon": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "The starkest of the three: zero references in either repo outside this schema declaration and one objectui type comment listing spec-derived keys (packages/types/src/complex.ts:676). Not even the action-ref lint looked at it. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." - }, "filter": { "status": "live", "verifiedAt": "2026-08-03", @@ -234,16 +219,6 @@ "verifiedAt": "2026-08-28", "evidence": "packages/lint/src/validate-widget-bindings.ts#validateWidgetBindings (the per-widget `suppressed(rule)` closure — `Array.isArray(w.suppressWarnings) && w.suppressWarnings.includes(rule)` — consulted by every WARNING-severity diagnostic in this rule set; errors are deliberately not suppressible)", "note": "build-time only, and that IS its contract — the key exists to silence a named build diagnostic, so the lint reading it is the whole feature (contrast `actionUrl`, where a lint reads the key but the promised runtime affordance does not exist). 2026-08-28: RE-ANCHORED (commit 8f10a79f7) and REPOINTED — `:367` had rotted onto a closing brace in the measure-aggregate branch, ~18 lines past the read. Recorded because it is a class this batch met four times: the four rule-id positions the old citation listed (`:346, :417, :610, :640`) were BARE line suffixes with no path in front of them, so `PATH_RE` never matched them and they were prose no check has ever resolved, bounded or key-checked. Re-closed by hand against 8cb96ec41." - }, - "responsive": { - "status": "dead", - "verifiedAt": "2026-08-24", - "note": "The key this drill exists for. Nothing in either repo ever read `widget.responsive` — DashboardRenderer / DashboardEditor / plugin-designer name it only in comments. Its literal namesake `view.list.responsive` was judged dead and removed in the 2026-07-30 #3896 sweep precisely because `view` IS drilled through `children`; this one was never asked, and survived on that silence alone. Retired 2026-08-03 via #4876 / PR #4995 (retiredKey tombstone + the protocol-17 `dashboard-widget-responsive-removed` conversion). CORRECTED 2026-08-24 (#11027): this note used to call `useResponsiveConfig` the 'one real per-breakpoint consumer', fed by `page.components[].responsive` — measured false: that hook (and `ResponsiveProtocol`, the second implementation) had zero callers, so the page key was retired too (protocol-18 `page-component-responsive-removed`) and the tombstone here no longer prescribes it. The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error." - }, - "aria": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "CALL GRAPH CLOSED BY HAND 2026-08-03, re-measured 2026-08-04 across both repos: no consumer of `widget.aria` anywhere. The `aria-*` attributes in DashboardRenderer / DatasetWidget are the renderer's own DOM attributes, and objectui's one `.aria` read (plugin-view/src/ObjectView.tsx:989) is the VIEW's. Same false-compliance shape as the dashboard-level `aria` removed in the #3896 sweep — an accessibility guarantee an author can declare and nothing honours. The shared AriaProps shape is untouched and stays live on `page.aria` / `page.components[].aria` and the list view `aria` (this ledger's own `page.props.aria` and `view.props.list.children.aria` rows) — corrected 2026-08-08 (#6756): it does NOT stay live on `app.aria`, whose `app.json` row is `dead` and which the protocol-17 `app-dead-authoring-keys-removed` conversion strips. Retired 2026-08-04 via #5010 / ADR-0049 D2 (retiredKey tombstone + the protocol-17 `dashboard-widget-action-aria-removed` conversion). The row stays because the tombstone keeps the key in the walked shape — the rls.priority precedent — and no authorWarn is needed: authoring it is now a tsc error and a parse error carrying the prescription." } } }, @@ -278,16 +253,6 @@ "evidenceScope": "in-repo", "evidence": "packages/rest/src/meta-item-read-gate.ts#holdsRequiredPermissions is the ONE predicate the `/meta` read gate asks of every carrier of the key (an app, its navigation entries, a list view, a dashboard): the caller must hold every capability named, and an absent or empty list is no gate. The `dashboard` arm of packages/rest/src/meta-item-read-gate.ts#createMetaItemReadGate refuses a dashboard whose capabilities the caller does not all hold, 403 PERMISSION_DENIED (the app's whole refusal), on every door and before the ADR-0057 D10 widget gate — so a dashboard's direct URL is refused by the server — and packages/rest/src/meta-item-read-gate.ts#createMetaListReadGate leaves it out of `GET /meta/dashboard`. Pinned per audience (holder served, non-holder not served, no key served to all) on every `/meta/:type/:name` door by packages/rest/src/meta-alternate-door-read-gates.test.ts, and across both transports by packages/runtime/src/domains/meta-item-read-gate-parity.test.ts and packages/runtime/src/domains/meta-list-read-gate-parity.test.ts", "note": "planned → live 2026-10-10 (#22639, the rest half of the ruling of record 6095014058, letter A): the `/meta` read gate enforces the key on the list read and the by-name read of `dashboard`. The prior PLANNED note (#22611) is superseded and its flip instruction discharged in this same edit: `authorWarn` / `authorHint` dropped, and the describe's not-enforced clause deleted (packages/spec/src/ui/audience-required-permissions.ts). Boundaries: the refusal is WHOLE and reaches an author as anyone (the app's whole refusal, ruling 5856774816); a caller with no signed-in user is not gated (the app arm's rule — an anonymous caller is refused before any `/meta` read of this type). What the console shows a refused direct URL is objectui's half, filed when this one is accepted. The lint resolves the capability names (packages/lint/src/validate-capability-references.ts), since a misspelled one now hides the dashboard from everyone." - }, - "aria": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); no dashboard renderer applied it." - }, - "performance": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); dashboard performance tuning was never implemented." } } } diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 382c36cca8d..c1d249ff5d8 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -138,11 +138,6 @@ "status": "live", "note": "renderer CEL." }, - "conditionalRequired": { - "status": "dead", - "verifiedAt": "2026-08-29", - "note": "REMOVED 2026-07-28 in protocol 17 (#3855, PR #3883 — the commit that retired the last three deprecated authorable aliases) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of sources by the protocol-17 conversion `field-conditionalRequired-to-requiredWhen`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `requiredWhen`, the only predicate slot — rename the key, the value (a CEL predicate) is unchanged, and `os migrate meta --from 16` lists the mechanical edits. The tombstone is packages/spec/src/data/field.zod.ts#conditionalRequired. The consumer-side refusal is packages/objectql/src/validation/rule-validator.ts#ConditionalFieldDef, which declares `requiredWhen` THE ONLY predicate slot and states PD #12 — no consumer-side dialect fallback returns here; that absence is PINNED by the case named `does NOT read the retired conditionalRequired alias (PD #12 — no dialect fallback)` in packages/objectql/src/validation/rule-validator.test.ts, which sits beside a positive case asserting the rule DOES fire once the stored-conversion chain has lowered the alias. LEDGER CORRECTED 2026-08-29 (commit e25e8397a) — the VERDICT was falsified, not the citation, and in BOTH halves of its evidence. (a) This row read `live` on the claim that the FieldSchema .transform lowers conditionalRequired -> requiredWhen and DROPS the alias. No such transform exists: field.zod.ts contains ZERO .transform calls, its only two occurrences of that token are comments recording that `.overwrite()` was chosen over `.transform()` per the measured #6926 precedent, and the describe() on `requiredWhen` says out loud that the alias was removed in protocol 17. (b) The note's own stated reason for the verdict — that objectql rule-validator.ts keeps its `requiredWhen ?? conditionalRequired` fallback on purpose because raw (unparsed) metadata still reaches evaluateValidationRules — was retired by #3903: every path that hands the validator a STORED field definition now replays the ADR-0087 conversion chain at rehydration (applyConversionsToStoredItem), so a pre-17 row arrives with the alias already lowered, and a caller handing raw unconverted legacy input is off-contract. WHY NOTHING SAW THE ROT: the old pointer packages/spec/src/data/field.zod.ts:639 was IN RANGE in a 2,132-line file that names the key five times, so the existence check, the line bound and the key-mention check were all green on it — what :639 holds today is the tail of FieldMaskingRuleSchema, unrelated in every respect — and the entry carried no verifiedAt, so the re-verification clock never asked (the #12516 class). It was also the LAST `path:NNN` citation left in any ledger, which is why retiring it here closes the anchor-adoption worklist. HISTORY PRESERVED, because it is the argument for `requiredWhen` being the single slot: #3754 folded the alias at parse time and removed it from the parsed output (same shape as action.execute -> target in #3742, agent.knowledge.topics -> sources in #1891); before that fix BOTH keys stayed live in the output with no canonicalization, so each consumer re-derived the precedence — the condition that produced the #3713 divergence. That note called removal of the input key the endgame, and protocol 17 is where the endgame landed. RETIREMENT CHAIN, COMPLETE — the close-out machinery is what remains, and it is not a consumer: the ADR-0087 conversion `field-conditionalRequired-to-requiredWhen` in packages/spec/src/conversions/registry.ts, its `RETIRED_KEYS_BY_MAJOR` entry and D3 chain step in packages/spec/src/migrations/registry.ts, and the explicit `NOTE: no entry for conditionalRequired` in packages/spec/src/data/authoring-key-lint.ts. Two INERT survivors are recorded so the next reader does not mistake either for a reader that gives the key meaning: packages/lint/src/validate-expressions.ts still walks the key, and its own docblock calls that branch inert on the parsed compile path because strict FieldSchema rejects the key by name (it gets a slot-agnostic consequence clause rather than a fabricated fourth measurement); packages/drivers/driver-sql/src/builtin-column-collision.ts lists it in a key-classification map as `presentation`, which classifies a name, not a value anyone reads. Re-closed by hand against cae2169cf." - }, "dueLike": { "status": "live", "verifiedAt": "2026-10-09", diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index dbcf793b3eb..b9bb03f1827 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -114,11 +114,6 @@ "status": "live", "evidence": "objectui: packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts:480", "note": "LIVE via objectui metadata-admin authoring UI — the 2026-06 audit missed objectui; verified by reading the file." - }, - "outputSchema": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); node outputs were never validated; downstream nodes read prior outputs via expressions regardless." } } }, @@ -140,11 +135,6 @@ "evidence": "packages/services/service-automation/src/engine.ts", "note": "Was `retryDelayMs` until 17.0.0 (#4964). This block was a THIRD encoding of the retry policy #4661 converged, invisible to that pass because it is an anonymous inline block with no exported name — the dual-source instrument judges duplicated export NAMES. It now builds from `retryPolicyShape()`, so the base delay is spelled `backoffMs` here exactly as on `job.retryPolicy` and a `try_catch` node's `retry`; `retryDelayMs` is tombstoned and rewritten by `retry-policy-converged`. `retryExecution` destructures the parsed key." }, - "retryDelayMs": { - "status": "dead", - "verifiedAt": "2026-08-04", - "note": "RENAMED to `backoffMs` 2026-08-04 (#4964) — the flow-level retry keys converged onto the shared `RetryPolicySchema`, whose tombstone this key now is (it arrives here via `retryPolicyShape()`, so authoring it is a tsc error and a parse error carrying the rename). Rewritten by the `retry-policy-converged` conversion wherever stored documents are replayed; it left the load path at 18 (#23543 F2). The row stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Note this key was LIVE right up to the rename — `retryExecution` read it — so `dead` here means retired, not never-read." - }, "backoffMultiplier": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" @@ -156,23 +146,8 @@ "jitter": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts" - }, - "fallbackNodeId": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); faults route via per-node fault edges — draw a fault edge to the handler node." } } - }, - "active": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the real switch is `status` ('obsolete'/'invalid' disable, 'active' arms)." - }, - "template": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); shared logic is invoked via a subflow NODE referencing the flow by name." } } } diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index f4461d2e39d..330480e724c 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -252,12 +252,6 @@ } } }, - "loading": { - "status": "dead", - "verifiedAt": "2026-08-23", - "evidenceScope": "cross-repo", - "note": "RETIRED, and the row must STAY: `retiredKey()` keeps the key in the walked shape (a tombstone, not a strict removal), so the orphans direction expects a row here — see the ledger README's asymmetry note. Removed in @objectstack/spec 17.0.0 (#4914, ADR-0049), ADR-0087 D3 entry `17.plugin-manifest-loading-retired`. The whole block — strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring — had no runtime reader in ANY of the three repos, so authoring it configured nothing. `loading.sandboxing` is the reason this is recorded as a security finding rather than tidying: it ran nothing in a process, vm, iframe or worker, and its `allowedServices` gated no call, so an author relying on it for isolation had none. This retirement is also what put the manifest on the ledger's agenda — it was found and paid by hand, because no gate could ask." - }, "engine": { "children": { "objectstack": { diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 1b574d40672..8687987f7a0 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -221,12 +221,6 @@ "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts#getApplicablePolicies (`(policy as { enabled?: boolean }).enabled === false` excluded BEFORE object / positions / operation matching; absent = active per the schema default) — pinned by the enabled-gate cases in packages/plugins/plugin-security/src/security-plugin.test.ts", "note": "ENFORCED 2026-07-30, by the security-subset re-verification this stamp records. Until then the entry claimed live citing rls-compiler.ts, and NOTHING read the property: policies OR-combine (any match allows access), so a policy an admin disabled kept CONTRIBUTING ITS GRANT — the schema's own describe ('Disabled policies are not evaluated') was false in the over-share direction, the #3896 shape. Same enforce-or-remove resolution as `positions` (ADR-0049). 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — path-only citation upgraded to an anchor, on the entry whose whole history is that its citation once named a file nothing in it read. Re-closed by hand against c459da6bc." }, - "priority": { - "status": "dead", - "verifiedAt": "2026-07-30", - "evidence": "packages/spec/src/security/rls.zod.ts (retiredKey tombstone — authored values REJECT with the prescription; z.input types the key never)", - "note": "REMOVED 2026-07-30, same day the audit corrected it (was live citing rls-compiler.ts, which never reads it — confirmed across the load site, the projection round-trip and the compiler). Not merely unimplemented: under OR-combination the promised 'conflict resolution' semantics cannot exist, so it went straight to the #3715/#3950 remove disposition inside the v17 breaking window. Tombstoned rather than deleted so the removal is audible; drop the tombstone (and this entry) ~two majors out." - }, "tags": { "status": "dead", "verifiedAt": "2026-09-27", diff --git a/packages/spec/liveness/skill.json b/packages/spec/liveness/skill.json index ce712f179bd..ea8476231e4 100644 --- a/packages/spec/liveness/skill.json +++ b/packages/spec/liveness/skill.json @@ -57,11 +57,6 @@ "evidenceScope": "cross-repo", "evidence": "cloud @cb8ee7ff: packages/service-ai/src/skill-registry.ts#listSkills — an inactive record never enters the pool that listActiveSkills filters; packages/mcp/src/skill-prompts.ts#projectSkillPrompt — an inactive row is dropped from the MCP prompt list.", "note": "inactive skills dropped — by the cloud registry, and not projected as an MCP prompt either (#3905)." - }, - "triggerPhrases": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); routing intent belongs in `triggerConditions`; describe intent in `description`/`instructions`." } } } diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 06343af3cec..d70333646f0 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 47 | 0 | 0 | 4 | 0 | 51 | +| `action` | 47 | 0 | 0 | 1 | 0 | 48 | diff --git a/packages/spec/liveness/state-counts/agent.md b/packages/spec/liveness/state-counts/agent.md index 579d29c20e7..6140b33d72d 100644 --- a/packages/spec/liveness/state-counts/agent.md +++ b/packages/spec/liveness/state-counts/agent.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `agent` | 23 | 0 | 0 | 3 | 0 | 26 | +| `agent` | 23 | 0 | 0 | 1 | 0 | 24 | diff --git a/packages/spec/liveness/state-counts/app.md b/packages/spec/liveness/state-counts/app.md index d7d2151ac5e..80ea528a4fb 100644 --- a/packages/spec/liveness/state-counts/app.md +++ b/packages/spec/liveness/state-counts/app.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `app` | 51 | 0 | 0 | 8 | 4 | 63 | +| `app` | 51 | 0 | 0 | 0 | 4 | 55 | diff --git a/packages/spec/liveness/state-counts/dashboard.md b/packages/spec/liveness/state-counts/dashboard.md index b78a210f296..fa8b7e19b60 100644 --- a/packages/spec/liveness/state-counts/dashboard.md +++ b/packages/spec/liveness/state-counts/dashboard.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `dashboard` | 43 | 0 | 0 | 13 | 0 | 56 | +| `dashboard` | 43 | 0 | 0 | 6 | 0 | 49 | diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 48598f1aa23..624e6039786 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 100 | 0 | 0 | 1 | 6 | 107 | +| `field` | 100 | 0 | 0 | 0 | 6 | 106 | diff --git a/packages/spec/liveness/state-counts/flow.md b/packages/spec/liveness/state-counts/flow.md index 3840cade61f..b72830085ef 100644 --- a/packages/spec/liveness/state-counts/flow.md +++ b/packages/spec/liveness/state-counts/flow.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `flow` | 36 | 0 | 0 | 5 | 0 | 41 | +| `flow` | 36 | 0 | 0 | 0 | 0 | 36 | diff --git a/packages/spec/liveness/state-counts/manifest.md b/packages/spec/liveness/state-counts/manifest.md index d14767a0008..0535ecaaaeb 100644 --- a/packages/spec/liveness/state-counts/manifest.md +++ b/packages/spec/liveness/state-counts/manifest.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `manifest` | 27 | 0 | 1 | 15 | 0 | 43 | +| `manifest` | 27 | 0 | 1 | 14 | 0 | 42 | diff --git a/packages/spec/liveness/state-counts/permission.md b/packages/spec/liveness/state-counts/permission.md index 346eb180cfe..2c124cc04ac 100644 --- a/packages/spec/liveness/state-counts/permission.md +++ b/packages/spec/liveness/state-counts/permission.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `permission` | 37 | 0 | 0 | 5 | 0 | 42 | +| `permission` | 37 | 0 | 0 | 4 | 0 | 41 | diff --git a/packages/spec/liveness/state-counts/skill.md b/packages/spec/liveness/state-counts/skill.md index 3fbf014c6bf..6fd748e36c8 100644 --- a/packages/spec/liveness/state-counts/skill.md +++ b/packages/spec/liveness/state-counts/skill.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `skill` | 16 | 0 | 0 | 1 | 0 | 17 | +| `skill` | 16 | 0 | 0 | 0 | 0 | 16 | diff --git a/packages/spec/liveness/state-counts/view.md b/packages/spec/liveness/state-counts/view.md index 49b06966f2e..11a6aac0aad 100644 --- a/packages/spec/liveness/state-counts/view.md +++ b/packages/spec/liveness/state-counts/view.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `view` | 78 | 0 | 0 | 12 | 0 | 90 | +| `view` | 78 | 0 | 0 | 5 | 0 | 83 | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index f2190cd3e76..a0217004fc8 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -51,7 +51,24 @@ "status": "live", "note": "objectui: ListView.tsx client-side filter shaping (audit L15); no list-render prop reaches the server query (audit data-flow note). The console's filter rows carry a React list key that the wire door removes before the parse (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the ViewFilterRule docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped on each new row by objectui @b9814b334 packages/components/src/custom/filter-builder.tsx#FilterBuilder (its `addCondition`), and again on a stored filter read back into the builder by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseTriplet and objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#parseSingleOrNested (both reached from that file's parseSpecFilter); re-read 2026-10-15, first cited by line, sha-less." }, - "sort": {"status": "live", "note": "objectui: ListView.tsx (audit L15). The console's sort rows carry the same React list key (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the sort entry's docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped by objectui @b9814b334 packages/components/src/custom/sort-builder.tsx#SortBuilder (its `normalize`, for a row without one, and its `addItem`), and again on a stored sort read back by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#toSortItems; re-read 2026-10-15, first cited by line, sha-less.", "children": {"field": {"status": "live", "verifiedAt": "2026-09-13", "evidenceScope": "in-repo", "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468, `fromElement`), which reads `el.field` off every sort entry and is the funnel every metadata ingress passes through; it lowers onto the engine `orderBy`. Drilled here rather than inherited because #17053 retired the bare string arm of this union, which is what made the entry's own keys visible to the walk."}, "order": {"status": "live", "verifiedAt": "2026-09-13", "evidenceScope": "in-repo", "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468) alongside `field`, and validated by its `direction()` helper (:3447) which refuses anything but 'asc'/'desc'. Divergence from `field` would be the signal worth recording; there is none \u2014 a sort entry is inert without both."}}}, + "sort": { + "status": "live", + "note": "objectui: ListView.tsx (audit L15). The console's sort rows carry the same React list key (packages/spec/src/ui/view.zod.ts, the VIEW_CONSOLE_ROW_DECORATIONS header and the sort entry's docblock; moved out of spec source, #23434): `id: crypto.randomUUID()` is stamped by objectui @b9814b334 packages/components/src/custom/sort-builder.tsx#SortBuilder (its `normalize`, for a row without one, and its `addItem`), and again on a stored sort read back by objectui @b9814b334 packages/plugin-view/src/config/view-config-utils.ts#toSortItems; re-read 2026-10-15, first cited by line, sha-less.", + "children": { + "field": { + "status": "live", + "verifiedAt": "2026-09-13", + "evidenceScope": "in-repo", + "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468, `fromElement`), which reads `el.field` off every sort entry and is the funnel every metadata ingress passes through; it lowers onto the engine `orderBy`. Drilled here rather than inherited because #17053 retired the bare string arm of this union, which is what made the entry's own keys visible to the walk." + }, + "order": { + "status": "live", + "verifiedAt": "2026-09-13", + "evidenceScope": "in-repo", + "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468) alongside `field`, and validated by its `direction()` helper (:3447) which refuses anything but 'asc'/'desc'. Divergence from `field` would be the signal worth recording; there is none — a sort entry is inert without both." + } + } + }, "searchableFields": { "status": "live", "note": "objectui: ListView.tsx (audit L15)." @@ -66,16 +83,6 @@ "status": "live", "note": "objectui: ObjectGrid.tsx (audit L15)." }, - "striped": { - "status": "dead", - "verifiedAt": "2026-08-12", - "note": "REMOVED 2026-08-12 (#7176, ADR-0049 enforce-or-remove) — the L15 citation was pass-through-only: every measured reader (spec-bridge, plugin-list, plugin-view/app-shell) copied the key forward and ObjectGrid, where the chain ends, never spells it. Tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 view-list-passthrough-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); there is no authorable striped-rows switch — delete the key." - }, - "bordered": { - "status": "dead", - "verifiedAt": "2026-08-12", - "note": "REMOVED 2026-08-12 (#7176, ADR-0049 enforce-or-remove) — the L15 citation was pass-through-only: every measured reader copied the key forward and ObjectGrid never spells it (the grid frame is the renderer's own borderless constant; DataTable's only 'bordered' hit is a comment). Tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 view-list-passthrough-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); delete the key." - }, "compactToolbar": { "status": "live", "note": "objectui: ListView.tsx:370 + toolbar gating at :1776-1988 (View-settings popover collapse). Post-audit key, verified objectui@fb35e48." @@ -190,11 +197,6 @@ "evidence": "packages/spec/src/ui/bulk-action.zod.ts", "note": "objectui: ListView.tsx:1343 forwards rich defs to ObjectGrid (BulkActionDialog). Dispatch: useBulkExecutor.ts run() — per-record fan-out by default; execution:'aggregate' defs go through the ONE-call bulkCall branch injecting params._selectedIds (ObjectGrid.runBulkActionAggregate, objectui#3139). Verified objectui@4bf612c. #4457 gave the def a SHAPE (it was z.record(z.any())): the entry schema is BulkActionDefSchema, strict, and it refuses the combinations that parse but the executor never reads — so this row's liveness now covers the keys inside a def, not just the array. A PROMOTED def's field-backed params (packages/spec/src/ui/bulk-action.zod.ts; moved out of spec source, #23434): resolved when the dialog opens by objectui main @dca25aff2 packages/plugin-grid/src/resolveBulkActions.ts#resolvePromotedBulkParams through `resolveActionParams` (objectui 29b99490), then renamed onto the bulk vocabulary by the same file's #toBulkParam, which carries the field's type, translated label, options, required, description, placeholder, defaultValue, multiple, `dependsOn`, `accept` and `maxSize` (plus a reference-bearing field's reference and display field) and drops the lookup settings. An authored param's `options[]` entries (packages/spec/src/ui/bulk-action.zod.ts, the `.passthrough()` rationale; moved out of spec source, #23434): objectui @b9814b334 packages/plugin-grid/src/components/bulkParamToField.ts#bulkParamToField spreads each entry into the field metadata (`options?.map(o => ({ ...o, value: String(o.value) }))`), so every extra key reaches the widget, whose option vocabulary is objectui @b9814b334 packages/types/src/field-types.ts#SelectOptionMetadata, extending objectui @b9814b334 packages/types/src/select-option.ts#SelectOptionBase (`color`, `visibleWhen`, `disabled` and `icon` beyond the pair); the select cell reads `option?.color` in objectui @b9814b334 packages/fields/src/index.tsx#SelectCellRenderer. The renderer's own param type now agrees: objectui @b9814b334 packages/types/src/objectql.ts#BulkActionParam types an `options` entry open (an index signature, citing objectstack#4001), where the source's sha-less reading had it closed (`Array<{ label; value }>`). Re-read 2026-10-15, first cited by line, sha-less." }, - "virtualScroll": { - "status": "dead", - "verifiedAt": "2026-08-12", - "note": "REMOVED 2026-08-12 (#7176, ADR-0049 enforce-or-remove) — the L15 citation was pass-through-only: every measured reader copied the key forward and ObjectGrid never spells it; VirtualGrid genuinely virtualizes but is only exported, never instantiated, and its props have no virtualScroll member. Tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 view-list-passthrough-keys-removed conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); large datasets page via pagination — delete the key." - }, "conditionalFormatting": { "status": "live", "note": "objectui: ObjectGrid.tsx (audit L15)." @@ -249,16 +251,6 @@ "userFilters": { "status": "live", "note": "objectui: UserFilters.tsx — element/fields/tabs/showAllRecords wired (audit L15); object views narrow to ObjectUserFiltersSchema (ADR-0047 amendment, #2679/objectui#2338). `allowAddTab` (packages/spec/src/ui/view.zod.ts, the #5073 close record; moved out of spec source, #23434): read as `config.allowAddTab` by objectui @b9814b334 packages/plugin-list/src/UserFilters.tsx#UserFilters and handed to objectui @b9814b334 packages/plugin-list/src/UserFilters.tsx#TabFilters, which draws the add-tab affordance and holds the added tabs in component state only; re-read 2026-10-15, first cited by line, sha-less." - }, - "responsive": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the grid is responsive by its own layout rules." - }, - "performance": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); list performance tuning was never implemented." } } }, @@ -369,16 +361,6 @@ "defaults": { "status": "live", "note": "objectui: ObjectForm.tsx foldFormButtons() reads schema.defaults and folds it into create-mode initialValues (framework#1894 / #2998); an explicit initialValues (URL prefill) still wins." - }, - "defaultSort": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); a related list sorts by its own list view's `sort`." - }, - "aria": { - "status": "dead", - "verifiedAt": "2026-07-30", - "note": "REMOVED 2026-07-30 (#3896 close-out sweep) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 close-out conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); no form renderer applied it; report gaps as renderer issues." } } }, diff --git a/packages/spec/scripts/build-schemas-check-mode.deleted-baseline.test.ts b/packages/spec/scripts/build-schemas-check-mode.deleted-baseline.test.ts index 1b5634b808a..b7adcbb5bb8 100644 --- a/packages/spec/scripts/build-schemas-check-mode.deleted-baseline.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.deleted-baseline.test.ts @@ -821,7 +821,7 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46 expect(output).toContain("waives THIS file's tombstone requirement only"); // The aged-out tombstone survives as A route, no longer as THE reason. - expect(output).toContain('aged out (~two majors)'); + expect(output).toContain('aged out (one major, #25767)'); expect(output).toContain('RETIRED_KEYS_BY_MAJOR'); expect(output).not.toContain('ONE legitimate reason'); diff --git a/packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts b/packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts index 7e39fe8bddc..c6368c88fc6 100644 --- a/packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.deletion-proofs.test.ts @@ -483,6 +483,17 @@ describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)' // not archaeology. Undated ⇒ undeletable, and the after-count is 0 of 97 by // design. The gate below is what that costs and what it buys. // +// #25767 (ADR-0087 amendment, 2026-10-26) shortened the clock to ONE major and +// added a second dating source that is not the registry: a line that was already +// `[RETIRED]` on the authorable surface at the previous major's release tag +// (`@objectstack/spec@.0.0`) was retired before that cut, so +// its kit has lived its one major. That is what dates the historical rows the +// table never named — not a backfill, a read of an immutable tag — and it is +// what lets a region's rows leave the registry without the clock losing them. +// The tag tests below create that tag inside this box; where the tag cannot be +// read (this box has no remote), the gate falls back to the registry alone and +// says so, which can only refuse more. +// // This block needs its own sandbox because the aged-out branch is now a claim // about `src/migrations/registry.ts`, which the main sandbox symlinks. @@ -494,6 +505,20 @@ const UNAGED_DECLARED_KEY = 'data/Object:zzDeclaredThisMajor5898'; const UNAGED_DECLARED = `${UNAGED_DECLARED_KEY} [RETIRED]`; +/** A leaf an OLD clause still ends in (`page.kind`, protocol 11), on a def that + * does not carry it — the leaf-collision pin needs the collision to exist. */ +const COLLIDER_LEAF = 'kind'; + +const DELETED_COLLIDER = `data/Object:${COLLIDER_LEAF} [RETIRED]`; + +/** The tag the gate dates an unregistered tombstone by (#25767). */ +const TOMBSTONE_DATING_TAG = `@objectstack/spec@${CURRENT_MAJOR - 1}.0.0`; + +/** A tombstone no table names, dated only by the tag. */ +const TAG_DATED_KEY = 'data/Object:zzRetiredBeforeTheCut25767'; + +const TAG_DATED = `${TAG_DATED_KEY} [RETIRED]`; + describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#5898)', () => { let box: string; let boxScript: string; @@ -550,14 +575,15 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 // clause match — that is the whole point — so its leaf is checked to be // registered no earlier than the major we declare it at is irrelevant; what // matters is that the key is not already in the baseline or the real table. - for (const injected of [DELETED_AGED, UNAGED_DECLARED]) { + for (const injected of [DELETED_AGED, UNAGED_DECLARED, DELETED_COLLIDER, TAG_DATED]) { expect( pristineSurface.includes(injected) || pristineSurface.includes(injected.replace(RETIRED_MARK, '')), `fixture ${injected} already exists in the committed baseline — pick another`, ).toBe(false); } - expect(CURRENT_MAJOR - AGED_DECLARED_MAJOR).toBeGreaterThanOrEqual(2); + // One major is the clock since #25767; the aged fixture is declared well past it. + expect(CURRENT_MAJOR - AGED_DECLARED_MAJOR).toBeGreaterThanOrEqual(1); box = fixtureTree('build-schemas-tombstone-age-'); fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true }); @@ -601,10 +627,11 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 'a tombstone DECLARED at an aged major may have its baseline line deleted', { timeout: SPAWN_TIMEOUT_MS }, () => { - // The surviving positive route. Note what supplies the major: the table, - // not the leaf — `compactLayout` happens to have an old clause too, so - // this case alone cannot tell the two mechanisms apart. The pin that can - // is in the main sandbox (`data/Object:type`) and in the last test here. + // The registry route. Note what supplies the major: the table, not the + // leaf (`compactLayout`'s own old clause left with its conversion on + // #25767, so nothing else could). The pin that tells the two mechanisms + // apart is in the main sandbox (`data/Object:type`) and in the collider + // test here. seedRetiredKeys({ [AGED_DECLARED_MAJOR]: [DELETED_AGED.replace(RETIRED_MARK, '')] }); seedBoxBase(DELETED_AGED); @@ -635,7 +662,7 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 expect(output).toMatch( new RegExp(`${UNAGED_DECLARED_KEY} — .*tombstone registered at major ${CURRENT_MAJOR}`), ); - expect(output).toMatch(/must age ≥ 2 majors/); + expect(output).toMatch(/must age ≥ 1 major before its line is deleted/); }, ); @@ -643,24 +670,99 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 'the pin: with the table EMPTY, a leaf collision proves nothing — the deletion is refused', { timeout: SPAWN_TIMEOUT_MS }, () => { - // `compactLayout` is registered at protocol 11 by an ADR-0087 clause, so - // the pre-#5898 matcher dated it there and allowed the deletion — exit 0, - // "tombstone aged out". With the declaration removed and nothing else - // changed, the only thing that can still supply major 11 is leaf matching. - expect(minRegisteredMajorForLeaf(DELETED_AGED_LEAF)).toBe(AGED_DECLARED_MAJOR); + // `page.kind` is registered at protocol 11 by an ADR-0087 clause, so the + // pre-#5898 matcher would date a `data/Object:kind` tombstone there and + // allow the deletion — exit 0, "tombstone aged out". With no declaration + // and (in this box, which has no remote) no readable release tag, the only + // thing that could still supply major 11 is leaf matching. + expect(minRegisteredMajorForLeaf(COLLIDER_LEAF)).toBe(AGED_DECLARED_MAJOR); seedRetiredKeys({}); - seedBoxBase(DELETED_AGED); + seedBoxBase(DELETED_COLLIDER); const { status, output } = runBox(['--check']); expect(status).toBe(1); expect(output).toContain('deleted without proof (#4650)'); expect(output).toMatch( - new RegExp(`data/Object:${DELETED_AGED_LEAF} — .*no entry in RETIRED_KEYS_BY_MAJOR`), + new RegExp(`data/Object:${COLLIDER_LEAF} — .*no entry in RETIRED_KEYS_BY_MAJOR`), ); + // The second source was asked and could not answer here — said, not skipped. + expect(output).toContain(`release tag ${TOMBSTONE_DATING_TAG} could not be read here to date it`); expect(output).not.toContain('tombstone aged out'); }, ); + + /** Commit a surface carrying `lines` and name it the previous major's cut. */ + const tagPreviousMajorCut = (...lines: string[]): string => { + writeSurfaceShards(boxSurfaceDir, [...pristineSurface, ...lines].sort()); + boxGit('add', AUTHORABLE_SURFACE_DIR_NAME, AUTHORABLE_DEFAULTS_DIR_NAME); + boxGit('commit', '-q', '--allow-empty', '-m', `release ${TOMBSTONE_DATING_TAG}`); + boxGit('tag', TOMBSTONE_DATING_TAG); + writeSurfaceShards(boxSurfaceDir, pristineSurface); + return boxGit('rev-parse', `refs/tags/${TOMBSTONE_DATING_TAG}^{commit}`); + }; + + it( + 'a tombstone NO table names, but already [RETIRED] at the previous major\'s release tag, may have its line deleted (#25767)', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The second dating source. The table is EMPTY — the registry rows for a + // region leave with it on the major after — and the only thing that dates + // this line is the release tag's surface, which already carried it marked. + seedRetiredKeys({}); + const cut = tagPreviousMajorCut(TAG_DATED); + try { + seedBoxBase(TAG_DATED); + + const { status, output } = runBox(['--check']); + + expect(output).toContain('carry their own proof (#4650)'); + expect(output).toMatch( + new RegExp( + `${TAG_DATED_KEY} — \\[RETIRED\\] at [0-9a-f]+ and already \\[RETIRED\\] at release tag ` + + `${TOMBSTONE_DATING_TAG.replace(/[.@/]/g, '\\$&')} \\(${cut.slice(0, 12)}`, + ), + ); + expect(output).toContain('tombstone aged out'); + expect(output).not.toContain('could not be read here'); + expect(status).toBe(0); + } finally { + boxGit('tag', '-d', TOMBSTONE_DATING_TAG); + } + }, + ); + + it( + 'the tag dates by its MARK, not by its presence: a key still LIVE at the cut was retired after it, and is refused', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The negative half of the same source. The key existed at the previous + // major's cut — unmarked, i.e. still authorable — so its tombstone is a + // post-cut retirement: the current major's kit, which has not lived its + // one major. The tag is readable here; it just does not say what the + // deletion needs it to say. + seedRetiredKeys({}); + const cut = tagPreviousMajorCut(TAG_DATED_KEY); + try { + seedBoxBase(TAG_DATED); + + const { status, output } = runBox(['--check']); + + expect(status).toBe(1); + expect(output).toContain('deleted without proof (#4650)'); + expect(output).toMatch( + new RegExp(`${TAG_DATED_KEY} — .*no entry in RETIRED_KEYS_BY_MAJOR`), + ); + expect(output).toContain( + `it was not [RETIRED] at release tag ${TOMBSTONE_DATING_TAG} (${cut.slice(0, 12)})`, + ); + expect(output).not.toContain('could not be read here'); + expect(output).not.toContain('tombstone aged out'); + } finally { + boxGit('tag', '-d', TOMBSTONE_DATING_TAG); + } + }, + ); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 11120c05fa5..94bda02670a 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -768,6 +768,34 @@ const generatedKeys = new Set(generatedSchemas.keys()); /** The protocol major this build is: the major any retirement it makes belongs to. */ const CURRENT_MAJOR = Number.parseInt(SPEC_VERSION, 10); +/** + * A tombstone may be deleted once it is this many majors old: one, since the + * 2026-10-26 ruling on #25767 (D2 = A) — a retirement kit lives through the + * protocol major AFTER the one it retired in, and leaves when the next major is + * cut. At major 18 that keeps the 17.x and 18.x kits and removes everything + * retired at or below 16.x; major 19 repeats the move one major on. Two sources + * date a deleted `[RETIRED]` line, read in this order by check (c)'s proof 1 + * (the deletion gate further down): + * + * - the registry: an EXACT `${defKey}:${name}` row in RETIRED_KEYS_BY_MAJOR, + * whose major is the one the retirement happened in (the row's region); + * - the previous major's release tag (`@objectstack/spec@.0.0`): + * a line that was ALREADY `[RETIRED]` on the authorable surface the day that + * major was cut predates the cut, so its kit has lived its one major. The + * tag is immutable and lives on origin, so neither the registry row a PR can + * write nor the one it can delete moves this clock — which is also why a + * region whose rows have left the registry (every ≤16.x row did on #25767) + * stays datable without a backfill. + */ +const TOMBSTONE_AGE_MAJORS = 1; + +/** + * The release tag whose surface dates a tombstone the registry no longer names. + * `TOMBSTONE_AGE_MAJORS` behind the current major, at its `.0.0` cut — the + * commit where every retirement of the previous major's kit was already marked. + */ +const TOMBSTONE_DATING_TAG = `@objectstack/spec@${CURRENT_MAJOR - TOMBSTONE_AGE_MAJORS}.0.0`; + // ─── Where a registration is WRITTEN ────────────────────────────────── // RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR and every step's `semantic` // list are GENERATED into src/migrations/registry.ts from one file per entry @@ -1148,9 +1176,10 @@ if (surfaceDoc) { ` the manifest deletion gate, and refuses the deletion unless it holds (#4650 proofs\n` + ` 2 and 3). ⚠️ It waives THIS file's tombstone requirement only — plugin manifests,\n` + ` connector configs and other non-metadata authoring go through their own gates.\n\n` + - ` Separately, a tombstone that has aged out (~two majors) may have its line deleted\n` + + ` Separately, a tombstone that has aged out (one major, #25767) may have its line deleted\n` + ` too — check (c) proof 1, which requires the exact key be declared in\n` + - ` RETIRED_KEYS_BY_MAJOR. Do it in the same PR, deliberately.\n\n` + + ` RETIRED_KEYS_BY_MAJOR or already [RETIRED] at ${TOMBSTONE_DATING_TAG}. Do it in the\n` + + ` same PR, deliberately.\n\n` + ` The routes and their preconditions are the spec-property-retirement playbook's\n` + ` (.claude/skills/); ADR-0104 and #4650 are the decisions behind them.`, ); @@ -1263,8 +1292,8 @@ if (surfaceDoc) { // (b3) A nested entry whose path this build does not emit at all. For a // TOP-LEVEL key that state is the aged-out steady state (b2) exempts // above — the tombstone carried `[RETIRED]` in `authorable-surface/`, - // aged two majors, and check (c) let the line go, with evidence at every - // step. A nested key reaches NONE of that: it has no baseline line (0 + // aged one major (#25767), and check (c) let the line go, with evidence + // at every step. A nested key reaches NONE of that: it has no baseline line (0 // dotted entries on the shipped surface), so checks (a0)/(a)/(b)/(c) are // structurally blind to it and no gated route can produce a registered // nested row whose path is absent. The one origin left is that the row is @@ -1330,15 +1359,17 @@ if (surfaceDoc) { // A deletion is legitimate on exactly one of FOUR proofs, each computed // inside this gate — never argued in a PR description: // -// 1. aged-out tombstone — the base entry carried `[RETIRED]` AND its EXACT -// `${defKey}:${name}` is declared in RETIRED_KEYS_BY_MAJOR at a major -// ≥ TOMBSTONE_AGE_MAJORS behind the current one (the "~two majors" this -// file's description has always promised, now enforced). Since #5898 this +// 1. aged-out tombstone — the base entry carried `[RETIRED]` AND either its +// EXACT `${defKey}:${name}` is declared in RETIRED_KEYS_BY_MAJOR at a major +// ≥ TOMBSTONE_AGE_MAJORS behind the current one (one major since the +// 2026-10-26 ruling on #25767; "~two majors" before it), or the line was +// already `[RETIRED]` on the authorable surface at the previous major's +// release tag (TOMBSTONE_DATING_TAG above), which dates the retirement +// before that cut without a registry row. Since #5898 the registry half // reads the same exact-key table check (b) reads, not a leaf-name match -// against the conversion registry — see TOMBSTONE_AGE_MAJORS below for the -// two false positives that matching produced and why the 97 pre-existing -// tombstones are left undeclared (and therefore undeletable) rather than -// backfilled from a source that cannot date them; +// against the conversion registry — see TOMBSTONE_AGE_MAJORS above and the +// note below it for the two false positives that matching produced; the tag half is what dates +// the tombstones that table never named (#25767); // 2. the def is not reachable from the metadata-type roots (2026-08-02 // ruling on #4650): no metadata document is ever parsed by it, so its // entry was over-collection and there is no author to tombstone for. @@ -1374,8 +1405,62 @@ if (surfaceDoc) { // — "reachable enough", "prescribed enough" — waives a deletion silently, and // this gate's whole history (#4638, #4643, #4662) is that direction of error. -/** A tombstone may be deleted once its registration is this many majors old. */ -const TOMBSTONE_AGE_MAJORS = 2; +/** + * The authorable keys that were `[RETIRED]` at {@link TOMBSTONE_DATING_TAG}, + * carried through declared def renames, or null when the tag cannot be read + * here (an offline container, or a checkout whose remote does not carry the + * tag). Resolved at most once per run and only when a deleted line asks for it, + * so a build that deletes nothing never pays the probe — and in a shallow + * checkout the fetch is `--depth=1` of the one tag, guarded by the probe, + * exactly as `resolveSurfaceBase()` fetches origin/main (#6359): it buys the + * ability to NAME the tag and nothing else. + * + * Null is not a pass. The caller falls back to the registry alone and refuses + * what the registry cannot date, so an environment that cannot see the tag can + * only be STRICTER than one that can. + */ +let tombstoneDatingSurface: { rev: string; retired: Set } | null | undefined; +function retiredAtTombstoneDatingTag(git: GitRun): { rev: string; retired: Set } | null { + if (tombstoneDatingSurface !== undefined) return tombstoneDatingSurface; + const ref = `refs/tags/${TOMBSTONE_DATING_TAG}`; + let probe = git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`); + if (probe.status !== 0) { + git('fetch', '--quiet', '--depth=1', 'origin', `+${ref}:${ref}`); + probe = git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`); + } + if (probe.status !== 0) { + console.log( + `ℹ️ authorable-surface deletion check (#4650): release tag ${TOMBSTONE_DATING_TAG} is not\n` + + ` readable in this checkout and could not be fetched, so deleted tombstone lines are dated\n` + + ` by RETIRED_KEYS_BY_MAJOR alone here (#25767). That can only refuse more, never less.`, + ); + tombstoneDatingSurface = null; + return null; + } + const rev = probe.stdout.trim(); + const surface = readSurfaceKeysAtRev( + git, + rev, + AUTHORABLE_SURFACE_DIR_NAME, + 'keys', + `authorable-surface deletion check (#4650) — tombstone dating at ${TOMBSTONE_DATING_TAG} (#25767)`, + ); + if (!surface) { + console.log( + `ℹ️ authorable-surface deletion check (#4650): no ${SURFACE_FILE_NAME} at ${TOMBSTONE_DATING_TAG}\n` + + ` (${rev.slice(0, 12)}), so deleted tombstone lines are dated by RETIRED_KEYS_BY_MAJOR alone here.`, + ); + tombstoneDatingSurface = null; + return null; + } + const retired = new Set(); + for (const entry of surface.entries) { + if (!entry.endsWith(RETIRED_MARK)) continue; + retired.add(carryAuthorableKey(entry.replace(RETIRED_MARK, ''))); + } + tombstoneDatingSurface = { rev, retired }; + return tombstoneDatingSurface; +} // The aging clock reads RETIRED_KEYS_BY_MAJOR — `registeredRetiredKeys()` above, // the same exact-key map check (b) reads. There is no leaf-name matching left in @@ -1400,17 +1485,20 @@ const TOMBSTONE_AGE_MAJORS = 2; // conversion `stack.api.requireAuth` (#3963). The clock was started from a // different KIND of change to the same surface. // -// Why the historical mapping is not backfilled, and what happens instead: a -// tombstone with no entry in RETIRED_KEYS_BY_MAJOR cannot prove its age, so its -// baseline line stays. That is fail-closed by construction rather than by -// estimate — see the table's "Historical tombstones" section for why neither -// available source could date the 97 pre-existing rows honestly (leaf-matching -// the conversion registry is the very inference #4659 removed; this file's own -// git history begins at 17.0.0-rc.0, so it dates every one of them at major 17 -// — an artifact of the baseline's birth, not archaeology). Deleting one of those -// lines is therefore a deliberate, reviewable act: declare its exact key under -// its true major in the table, and check (b2) verifies the entry still names a -// key this build tombstones. +// Why the historical mapping is not backfilled, and what dates it instead: a +// tombstone with no entry in RETIRED_KEYS_BY_MAJOR is dated by the previous +// major's release tag — `[RETIRED]` on the surface at `@objectstack/spec@17.0.0` +// means retired at or before the 17 cut, i.e. a ≤16.x kit, which at major 18 has +// lived its one major (#25767). Leaf-matching the conversion registry is the +// very inference #4659 removed, and this file's own git history begins at +// 17.0.0-rc.0, so it dates every pre-existing tombstone at major 17 — an +// artifact of the baseline's birth, not archaeology; the tag reads the SAME +// artifact but asks a different question of it, "was it already marked when +// the major was cut", which the birth of the file cannot fake. A tombstone the +// registry does not name AND the tag does not carry stays: fail-closed by +// construction rather than by estimate. Deleting such a line is a deliberate, +// reviewable act: declare its exact key under its true major in the table, and +// check (b2) verifies the entry still names a key this build tombstones. interface SurfaceReachability { /** The metadata-type roots the BFS started from. */ @@ -2850,19 +2938,37 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; } const registeredAt = declaredRetired.get(key); if (registeredAt === undefined) { + // The second dating source (#25767): a line that was already marked + // when the previous major was cut belongs to the kit that major + // retired, and that kit has lived its one major. Read lazily — only a + // deleted tombstone the registry does not name asks for the tag. + const dated = retiredAtTombstoneDatingTag(gitInPackage); + if (dated !== null && dated.retired.has(key)) { + allowed.push( + `${key} — [RETIRED] at ${baseRev} and already [RETIRED] at release tag ` + + `${TOMBSTONE_DATING_TAG} (${dated.rev.slice(0, 12)}; current major ${CURRENT_MAJOR}): ` + + `tombstone aged out (#25767).`, + ); + continue; + } + const tagVerdict = + dated === null + ? `and release tag ${TOMBSTONE_DATING_TAG} could not be read here to date it` + : `and it was not [RETIRED] at release tag ${TOMBSTONE_DATING_TAG} (${dated.rev.slice(0, 12)})`; violations.push( `${key} — def ${how}; tombstoned, but no entry in RETIRED_KEYS_BY_MAJOR names this\n` + - ` EXACT key, so there is nothing that dates the retirement and its aging clock\n` + - ` has no start. Until #5898 a leaf-name match against unrelated ADR-0087\n` + - ` clauses supplied one by coincidence (a flow node's '.type' dated an index\n` + - ` type's tombstone) — always erring early, since it took the Math.min.`, + ` EXACT key, ${tagVerdict}, so there is\n` + + ` nothing that dates the retirement and its aging clock has no start. Until\n` + + ` #5898 a leaf-name match against unrelated ADR-0087 clauses supplied one by\n` + + ` coincidence (a flow node's '.type' dated an index type's tombstone) — always\n` + + ` erring early, since it took the Math.min.`, ); continue; } if (CURRENT_MAJOR - registeredAt < TOMBSTONE_AGE_MAJORS) { violations.push( `${key} — def ${how}; tombstone registered at major ${registeredAt}, current major is\n` + - ` ${CURRENT_MAJOR} — a tombstone must age ≥ ${TOMBSTONE_AGE_MAJORS} majors before its line is deleted.`, + ` ${CURRENT_MAJOR} — a tombstone must age ≥ ${TOMBSTONE_AGE_MAJORS} major before its line is deleted (#25767).`, ); continue; } @@ -2893,15 +2999,16 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; ` compared against the baseline at merge base ${baseRev} with origin/main,\n` + ` which this commit cannot rewrite.\n\n` + ` A line may only leave this file when:\n` + - ` 1. its key was tombstoned (\`retiredKey()\` → "[RETIRED]") AND that key is\n` + + ` 1. its key was tombstoned (\`retiredKey()\` → "[RETIRED]") AND either that key is\n` + ` declared — EXACTLY, as '\${defKey}:\${name}' — in RETIRED_KEYS_BY_MAJOR\n` + - ` under a major ≥ ${TOMBSTONE_AGE_MAJORS} behind this one (≤ v${CURRENT_MAJOR - TOMBSTONE_AGE_MAJORS}). Tombstones that\n` + - ` predate that table are deliberately undeclared: nothing could date them\n` + - ` honestly, so they are NOT deletable until someone establishes the true\n` + - ` major and writes it down (#5898) — as one entry file under\n` + - ` ${entryDirOf('retired-key')}, named for that major per\n` + - ` ${MIGRATION_ENTRIES_README}, then \`${MIGRATION_REGISTRY_COMMAND}\`;\n` + - ` the table is generated, ⛔ never typed. The\n` + + ` under a major ≥ ${TOMBSTONE_AGE_MAJORS} behind this one (≤ v${CURRENT_MAJOR - TOMBSTONE_AGE_MAJORS}), or its line was\n` + + ` already [RETIRED] on the authorable surface at release tag\n` + + ` ${TOMBSTONE_DATING_TAG} — retired before that cut, so its kit has lived the one\n` + + ` major it is owed (#25767). A tombstone that neither source dates is NOT\n` + + ` deletable until someone establishes the true major and writes it down\n` + + ` (#5898) — as one entry file under ${entryDirOf('retired-key')}, named for\n` + + ` that major per ${MIGRATION_ENTRIES_README}, then\n` + + ` \`${MIGRATION_REGISTRY_COMMAND}\`; the table is generated, ⛔ never typed. The\n` + ` D2 conversion (src/conversions/registry.ts) naming the surface stays\n` + ` required — it is the prescription consumers follow — but it is no longer\n` + ` what dates the clock; or\n` + diff --git a/packages/spec/scripts/lib/renamed-defs.ts b/packages/spec/scripts/lib/renamed-defs.ts index 989c8d08c74..8d0dc27c218 100644 --- a/packages/spec/scripts/lib/renamed-defs.ts +++ b/packages/spec/scripts/lib/renamed-defs.ts @@ -67,7 +67,8 @@ export const RENAMED_DEFS: Readonly> = { // longer emits, which is precisely the decay guard that made this table // trustworthy. The removal is carried by the real retirement kit instead — // a `retiredKey()` tombstone on `ConnectorSchema.rateLimitConfig`, the - // `connector-rate-limit-config-removed` D2 conversion, and the deliberate + // `connector-rate-limit-config-removed` D2 conversion (a ≤16.x kit, retired + // on #25767; the tombstone stays because its container strips), and the deliberate // manifest deletion of both `integration/ConnectorRateLimitConfig` and // `integration/RateLimitStrategy`. A retirement must never ride this table // (same ruling as `automation/ConflictResolution` below). diff --git a/packages/spec/src/ai/agent.test.ts b/packages/spec/src/ai/agent.test.ts index 586c8ab0bc8..8aa8cc61594 100644 --- a/packages/spec/src/ai/agent.test.ts +++ b/packages/spec/src/ai/agent.test.ts @@ -71,12 +71,12 @@ describe('AIModelConfigSchema', () => { }); }); -describe('agent.tools retirement (ADR-0064) — tombstoned; tools move into skills', () => { - it('REJECTS a legacy inline tools array, with the fix in the message', () => { - // Tombstoned, not deleted: AgentSchema is `strictObject`, so a plain - // deletion would reject the key with a generic unknown-key error. - // `retiredKey()` is what makes the rejection carry the prescription — - // which is the payload, and what this assertion pins. +describe('agent.tools retirement (ADR-0064) — aged out; tools move into skills', () => { + it('REFUSES a legacy inline tools array as an unknown key naming the upgrade guide', () => { + // The tombstone that carried the prescription was a 16.x retirement kit + // and left the shape at 18 (#25767, ADR-0087 D2 = A). AgentSchema is + // `strictObject`, so the key is still refused loudly — the unknown-key + // refusal names it and the guide the `skills` prescription now lives in. expect(() => AgentSchema.parse({ name: 'legacy', @@ -85,7 +85,7 @@ describe('agent.tools retirement (ADR-0064) — tombstoned; tools move into skil instructions: 'x', tools: [{ type: 'action', name: 'create_ticket' }], }), - ).toThrow(/agent\.tools.*removed.*use `skills`/s); + ).toThrow(/`tools`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('parses cleanly once the capability moves into skills', () => { @@ -203,15 +203,17 @@ describe('AgentSchema', () => { })).toThrow(/knowledge/); }); - it('the rejection names where retrieval scoping actually lives', () => { + it('the refusal is the strict unknown-key one, naming the guide where the scoping prescription lives', () => { + // The `knowledge` tombstone aged out at 18 with its kit (#25767). let message = ''; try { AgentSchema.parse({ name: 'a', label: 'A', instructions: 'x', knowledge: { sources: ['s'] } }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/knowledge-service|source level/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/unrecognized_keys/); + expect(message).toMatch(/`knowledge`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should accept agent with skills (Agent→Skill→Tool architecture)', () => { diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index d1d8b9badd9..d3f194962f1 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -384,57 +384,6 @@ export const AgentSchema = lazySchema(() => strictObject({ /** Capabilities — Skill-based (primary) */ skills: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).optional().describe('Skill names to attach (Agent→Skill→Tool architecture)'), - /** - * [REMOVED in protocol 17 — #3894] The legacy inline - * `{type,name,description}[]` fallback. - * - * ADR-0064's central invariant is "an agent's tool set is the union of its - * surface-compatible skills' tools; nothing falls through to the global - * registry", and this field was the one seam that broke it: the runtime - * resolved `agent.tools[].name` against the FULL registry with no surface - * check, so an `ask`-surface agent could name an authoring tool and get it. - * - * Tombstoned rather than deleted: `AgentSchema` is `strictObject`, so a plain - * deletion would already REJECT the key — but only with a generic unknown-key - * error. The prescription is the payload. An author who wrote `tools` has to - * be told the specific thing this key's removal means: the capability moves - * into a skill, and ADR-0064's union is the only path from an agent to a tool - * — which this tombstone says and an unknown-key rejection cannot. It also - * types the key `never`, so the same mistake fails `tsc` at the authoring site - * before any parse runs. Those are the two channels an upgrading author, very - * often an AI (ADR-0033), actually reads (`shared/retired-key.ts`). - */ - tools: retiredKey( - '`agent.tools` was removed in @objectstack/spec 17 — use `skills`. ' + - 'An agent reaches exactly the tools its surface-compatible skills declare ' + - '(ADR-0064), so move each reference into a skill: a platform tool by its ' + - 'registered name, or `action_` for one of your own AI-exposed Actions. ' + - 'This is NOT a rename — there is no key the value moves to: the migration ' + - 'DELETES the key and emits a notice naming each tool that was listed, and ' + - 'you re-declare each one in a skill by hand. ' + - 'ADR-0064 itself still reads `Proposed` and is cloud-owned — that scopes its ' + - 'RUNTIME half (tool resolution, which lives in cloud `service-ai`), not this ' + - 'rejection: the authoring invariant binds you here, and ADR-0109 ' + - '(Accepted — implemented) is the in-repo record that carries it. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - /** Knowledge */ - // `knowledge` REMOVED (#3896 audit close-out) — a GROUNDING claim nothing - // enforced: the RAG path never reads the agent record; `search_knowledge` - // takes `sourceIds` from the LLM's own tool-call arguments (cloud - // knowledge-tools.ts:96). An author who "scoped" retrieval here scoped - // nothing — the dangerous direction, same shape as tool.permissions. - knowledge: retiredKey( - '`agent.knowledge` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + - 'declaring knowledge sources/indexes on an agent never scoped retrieval: the ' + - "`search_knowledge` tool takes `sourceIds` from the LLM's tool-call arguments, not from " + - 'the agent record. Delete the block. Restrict retrieval at the knowledge-service / ' + - 'source level (per-source permissions), and describe intended grounding in ' + - '`instructions` so the model asks for the right sources. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** Interface */ active: z.boolean().default(true), access: z.array(z.string()).optional().describe('Who can chat with this agent'), diff --git a/packages/spec/src/ai/skill.test.ts b/packages/spec/src/ai/skill.test.ts index e0325a06fbe..860f7240aaf 100644 --- a/packages/spec/src/ai/skill.test.ts +++ b/packages/spec/src/ai/skill.test.ts @@ -193,15 +193,18 @@ describe('defineSkill', () => { }); describe('retired `triggerPhrases` — phrases never routed a skill; triggerConditions do', () => { - it('REJECTS the retired key with the routing prescription', () => { + it('REFUSES the aged-out key as an unknown key naming the upgrade guide (#25767)', () => { + // The tombstone that carried the routing prescription was a 16.x kit and + // left the shape at 18; the strict refusal names the key and the guide. let message = ''; try { SkillSchema.parse({ name: 'case_mgmt', label: 'Cases', triggerPhrases: ['open a ticket'] }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/triggerConditions/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/unrecognized_keys/); + expect(message).toMatch(/`triggerPhrases`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('does not offer the retired key as the fix for a near-miss of it', () => { diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts index f989ac415de..7f66c79a744 100644 --- a/packages/spec/src/ai/skill.zod.ts +++ b/packages/spec/src/ai/skill.zod.ts @@ -20,7 +20,6 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; // whichever export happens to be declared first (#7113). import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; -import { retiredKey } from '../shared/retired-key'; /** * The trigger-condition operators whose `value` is a LIST rather than a scalar @@ -374,24 +373,6 @@ export const SkillSchema = lazySchema(() => strictObject({ */ tools: z.array(z.string().regex(/^[a-z_][a-z0-9_]*\*?$/)).describe('Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) — bound by the cloud agent runtime only'), - /** - * Natural language phrases that trigger skill activation. - * Used for intent matching and skill routing. - */ - // `triggerPhrases` REMOVED (#3896 audit close-out): phrases were NEVER - // matched against the user's message — activation is `triggerConditions` ∩ - // the agent's `skills[]` allowlist (+ explicit /skill-name pinning). The - // cloud API served the field back to clients, a dead-end projection that - // made the false capability look extra real. - triggerPhrases: retiredKey( - '`skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (audit close-out) ' + - "— phrases were never matched against the user's message; skill activation is " + - "`triggerConditions` (AND of context field/operator/value) intersected with the agent's " + - '`skills[]`, plus explicit /skill-name pinning. Delete the key. Put routing intent in ' + - '`triggerConditions`; describe intent in `description`/`instructions` for the LLM. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * Programmatic conditions for skill activation. * Evaluated against the runtime context (object name, user role, etc.). diff --git a/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts b/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts index 452d7937b2f..3374f9a4e8e 100644 --- a/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts +++ b/packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts @@ -4,17 +4,20 @@ * [#17487] The shipped confirmation-gate prescriptions are held to the door * that actually runs — in BOTH directions. * - * Three customer-facing prescriptions tell an author what + * One customer-facing prescription tells an author what * `action.ai.requiresConfirmation: true` does: the `requiresConfirmation` entry * of `TOOL_RETIRED_KEY_GUIDANCE` (reached as a parse error on the `.strict()` * `ToolSchema`, the one channel every consumer bumping `@objectstack/spec` is - * guaranteed to hit) and the ADR-0087 D3 entry's `replacement` and - * `acceptanceCriteria`, which are what `spec-changes.json`, the upgrade guide - * and `os migrate meta` project to consumers. + * guaranteed to hit). The ADR-0087 D3 entry whose `replacement` and + * `acceptanceCriteria` carried the same sentence into `spec-changes.json`, the + * upgrade guide and `os migrate meta` was a 16.x retirement kit and aged out + * at 18 (#25767, ADR-0087 amendment); the guidance entry is an unknown-key + * prescription on the strict surface rather than a ledger tombstone, so it + * stays, and this pin now ties that one carrier to the door. * * ## Why this pin exists, and why it reads the RUNTIME * - * All three used to carry a hedge — "the declaration is the contract, not yet + * All of them used to carry a hedge — "the declaration is the contract, not yet * the behaviour … until that door lands, such a call simply RUNS" — written * while the runtime door was a separate, unlanded card. The door landed. The * hedge then DENIED a door that exists, and denied it in the dangerous @@ -66,7 +69,6 @@ import { describe, it, expect } from 'vitest'; import { ToolSchema } from './tool.zod'; import { ActionSchema } from '../ui/action.zod'; -import { entry as toolConfirmationEntry } from '../migrations/entries/semantic/17.tool-requires-confirmation-retired'; const HERE = dirname(fileURLToPath(import.meta.url)); /** …/packages/spec/src/ai → repo root */ @@ -89,20 +91,19 @@ function toolRejectionMessage(): string { } /** - * The three carriers, each named. A prescription reaching consumers through a - * different projection is the same text, so one set of assertions covers all. + * The carriers, each named — one since #25767 (the D3 entry's two projections + * aged out with its kit). Kept as a list so a carrier that returns is added + * here and judged by the same assertions. */ function shippedPrescriptions(): Array<[string, string]> { return [ ['ToolSchema rejection (TOOL_RETIRED_KEY_GUIDANCE.requiresConfirmation)', toolRejectionMessage()], - ['ADR-0087 D3 entry — replacement', toolConfirmationEntry.replacement], - ['ADR-0087 D3 entry — acceptanceCriteria', toolConfirmationEntry.acceptanceCriteria], ]; } /** * Claims that the refusal is not performed. A closed list, every member taken - * verbatim from a sentence one of these three carriers really shipped — so the + * verbatim from a sentence one of the carriers really shipped — so the * predicate answers "did the retired shape come back", never "does this read * hedged to me". */ @@ -143,7 +144,7 @@ function aiDoorBody(): string { } describe('the confirmation-gate prescriptions match the door that runs', () => { - it('anchors on real text in all three carriers', () => { + it('anchors on real text in every carrier', () => { // Anti-vacuity for every assertion below: an empty carrier would pass // "carries no denial" by reading nothing. for (const [name, text] of shippedPrescriptions()) { @@ -174,7 +175,7 @@ describe('the confirmation-gate prescriptions match the door that runs', () => { expect( aiDoorBody(), '`invokeBusinessAction` no longer calls `actionConfirmationRefusal` — the prescriptions ' - + 'in `ai/tool.zod.ts` and the ADR-0087 D3 entry now describe a door that does not run', + + 'in `ai/tool.zod.ts` now describes a door that does not run', ).toMatch(/actionConfirmationRefusal\(/); }); diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts index 9923f029ced..8f4dd425ef0 100644 --- a/packages/spec/src/ai/tool.zod.ts +++ b/packages/spec/src/ai/tool.zod.ts @@ -207,9 +207,10 @@ export const ToolSchema = lazySchema(() => strictObject({ // promised a capability gate on invocation that nothing enforced, and // `active: false` read as "withdrawn" while the tool kept reaching the LLM // set and `POST /ai/tools/:name/execute` kept running it. The `.strict()` - // parse rejects each with its prescription (TOOL_RETIRED_KEY_GUIDANCE), and - // the `tool-inert-authoring-keys-removed` conversion strips them from - // authored sources. + // parse rejects each with its prescription (TOOL_RETIRED_KEY_GUIDANCE); the + // `tool-inert-authoring-keys-removed` conversion that stripped them from + // authored sources was a ≤16.x kit and retired on #25767 — the prescription + // is the surviving route. /** * ADR-0010 §3.7 — Package-level protection envelope. Package * authors declare lock policy here, in a code package's source; diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index 956f40db77a..429d1a52c90 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -6,7 +6,6 @@ import { FilterConditionSchema } from '../data/filter.zod'; import { AggregationFunction, DateGranularity } from '../data/query.zod'; import { strictObject } from '../shared/strict-object'; import { BaseResponseSchema } from './contract.zod'; -import { retiredKey } from '../shared/retired-key'; /** * Analytics API Protocol @@ -51,17 +50,6 @@ export const AnalyticsEndpoint = z.enum([ export const AnalyticsQueryRequestSchema = lazySchema(() => AnalyticsQuerySchema.extend({ cube: z.string().describe('Target cube name'), - query: retiredKey( - '`query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. ' + - 'The { cube, query: {...} } envelope was the dialect of the retired degraded analytics shim — ' + - 'the real engine never understood it. Move the query.* fields to the body top level: ' + - '{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }.', - ), - format: retiredKey( - '`format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. ' + - 'It was never implemented — every response is the JSON envelope. Delete the key; ' + - 'for CSV/XLSX use the export surface instead.', - ), }).strict() ); diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 8efe368c655..1d4ac46f925 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -196,7 +196,7 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ + 'To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a ' + "share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of " + 'opening the whole data plane. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', ), /** diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index a900d3e77c5..5dd56d0f602 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -159,7 +159,7 @@ const CRUD_ALIAS_GUIDANCE = { + 'release. Use `objectName`. Rename the key; the value (the object name) is unchanged. If `objectName` is ' + 'also present, this node names two different objects — decide which object it acts on, put it on ' + '`objectName`, and delete `object`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', filters: 'The CRUD node config key `filters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a ' + 'consumer-side executor fallback, which the loader rewrote to `filter` (singular) until this release. Use ' @@ -1347,7 +1347,7 @@ export const MapConfigSchema = lazySchema(() => strictObject({ + 'Use `flowName`. Rename the key; the value (the per-item subflow\'s name) is unchanged. If `flowName` is ' + 'also present, the two name DIFFERENT subflows — decide which flow runs per item, put it on `flowName`, and ' + 'delete `flow`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', }, }, { /** diff --git a/packages/spec/src/automation/control-flow.test.ts b/packages/spec/src/automation/control-flow.test.ts index 9ce5411ed73..b357f946f35 100644 --- a/packages/spec/src/automation/control-flow.test.ts +++ b/packages/spec/src/automation/control-flow.test.ts @@ -115,17 +115,18 @@ describe('TryCatchConfigSchema', () => { }); // #4661: `retryDelayMs` was the automation spelling of `backoffMs` before the - // retry policy converged onto one declaration. It is TOMBSTONED rather than - // deleted precisely because this schema is not `.strict()` — a plain removal - // would have Zod swallow the authored number and silently fall back to the - // 1000ms default. Assert the loud rejection, not merely its absence. - it('rejects the retired `retryDelayMs` spelling with the rename prescription', () => { + // retry policy converged onto one declaration. Its tombstone was a 16.x + // retirement kit and aged out at 18 (#25767); the retry policy is strict, so + // an authored number is still refused loudly — as an unknown key naming the + // upgrade guide — rather than swallowed into the 1000ms default. Assert the + // loud rejection, not merely its absence. + it('refuses the aged-out `retryDelayMs` spelling as an unknown key naming the upgrade guide', () => { const parse = () => TryCatchConfigSchema.parse({ try: { nodes: [node('t')] }, retry: { maxRetries: 3, retryDelayMs: 500 }, }); - expect(parse).toThrow(/backoffMs/); - expect(parse).toThrow(/retryDelayMs/); + expect(parse).toThrow(/Unrecognized key\(s\) on this retry policy: `retryDelayMs`/); + expect(parse).toThrow(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('retry is opt-in: a declared but empty retry block does not retry', () => { diff --git a/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts b/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts index 57b18269536..16dbec3c12d 100644 --- a/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts +++ b/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts @@ -298,15 +298,22 @@ describe('what stays as it was (lit controls)', () => { expect(flowNodeConfigRefusals('decision', { condition: 'a == b', whateverElse: 1 })).toEqual([]); }); - it('CONTROL: a tombstoned retired script key keeps its existing path', () => { + it('an aged-out retired script key is an unknown key now, so the key arm refuses it at its own path (#25767)', () => { + // `actionType`'s tombstone was a 16.x retirement kit and left the shape at + // 18 (ADR-0087 D2 = A). The contract's own parse refuses it as an unknown + // key naming the upgrade guide, and this arm — which used to hold back + // because the tombstone answered at its own path — now draws its one + // refusal at the key, like any other undeclared key. const config = { ...MEASURED, actionType: 'email' }; const own = getBuiltinNodeConfigContracts().get('script')!.schema.safeParse(config); expect(own.success ? [] : own.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.') }))).toEqual([ - { code: 'invalid_type', path: 'actionType' }, + { code: 'unrecognized_keys', path: '' }, ]); - expect(own.success ? '' : own.error!.issues[0]!.message).toMatch(/was removed in @objectstack\/spec 17/); - expect(flowNodeConfigRefusals('script', config)).toEqual([]); - expect(issuesOf(flowWith('script', config))).toEqual([]); + expect(own.success ? '' : own.error!.issues[0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(flowNodeConfigRefusals('script', config).map(({ code, path }) => ({ code, path }))).toEqual([ + { code: 'node-config-refused-by-contract', path: 'actionType' }, + ]); + expect(issuesOf(flowWith('script', config)).map(({ path }) => path)).toEqual(['nodes.1.config.actionType']); }); }); @@ -337,18 +344,20 @@ describe('try_catch: its retry is closed, so the spec judges its keys like every .map(({ path }) => path).sort()).toEqual(['bogusKey', 'retry.other']); }); - it('a retryDelayMs the conversion leaves behind is refused at its own path, in the tombstone\'s words', () => { + it('a retryDelayMs the conversion leaves behind is refused at its own path, as the unknown key it is now', () => { // `retry-policy-converged` keeps both spellings when the values differ, and - // leaves a `null`: registration's walk refused both as undeclared keys. + // leaves a `null`: registration's walk refused both as undeclared keys. The + // spelling's tombstone aged out at 18 (#25767), so the refusal is the + // strict unknown-key one — naming the key, the policy, and the upgrade + // guide the rename now lives in — closed by the arm's own "rename or remove". for (const retry of [{ backoffMs: 500, retryDelayMs: 700 }, { retryDelayMs: null }]) { const config = { try: TRY_REGION, retry }; const refusals = flowNodeConfigRefusals('try_catch', config); expect(refusals.map(({ code, path }) => ({ code, path })), JSON.stringify(retry)).toEqual([ { code: 'node-config-refused-by-contract', path: 'retry.retryDelayMs' }, ]); - // The tombstone's upgrade is the remedy; no "undeclared key" closing on a key the contract declares retired. - expect(refusals[0]!.message).toContain('Rename the key to `backoffMs`'); - expect(refusals[0]!.message).not.toMatch(/Rename the key to one/); + expect(refusals[0]!.message).toContain('Unrecognized key(s) on this retry policy: `retryDelayMs`'); + expect(refusals[0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); expect(issuesOf(flowWith('try_catch', config)).map(({ path }) => path)).toEqual(['nodes.1.config.retry.retryDelayMs']); } }); @@ -417,7 +426,8 @@ describe('try_catch: its retry is closed, so the spec judges its keys like every } expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); expect(refusal!.issues!.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.retry.retryDelayMs']); - expect(refusal!.issues![0]!.message).toContain('Rename the key to `backoffMs`'); + expect(refusal!.issues![0]!.message).toContain('Unrecognized key(s) on this retry policy: `retryDelayMs`'); + expect(refusal!.issues![0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); diff --git a/packages/spec/src/automation/flow-builtin-node-config-values.test.ts b/packages/spec/src/automation/flow-builtin-node-config-values.test.ts index 5470205f902..7cb07cc38c4 100644 --- a/packages/spec/src/automation/flow-builtin-node-config-values.test.ts +++ b/packages/spec/src/automation/flow-builtin-node-config-values.test.ts @@ -413,21 +413,21 @@ describe('what stays accepted (lit controls)', () => { expect(flowNodeConfigRefusals('http', config).map(({ path }) => path)).toEqual(['timeoutMs']); }); - it('CONTROL: key membership is not this arm\'s — an undeclared key and a tombstoned one', () => { + it('CONTROL: key membership is not this arm\'s — an undeclared key and an aged-out retired one', () => { // [#21982] An undeclared key on these builtins draws the KEY arm's one // refusal at its location; this arm adds nothing beside it. [#22343] So - // does a tombstoned key on a type the key arm judges it for — `try_catch`'s - // `retry.retryDelayMs` — while a retired `script` key keeps the scope it - // had (the lint names it): neither draws a second refusal from this arm. + // does a retired key the key arm judges — `try_catch`'s `retry.retryDelayMs` + // and, since its tombstone aged out at 18 (#25767), `script`'s `actionType` + // too: each is an unknown key now, and neither draws a second refusal here. for (const [type, config, key] of [ ['http', { ...VALID.http, bogusKey: 1 }, 'bogusKey'], ['screen', { fields: [{ name: 'qty', visible: 'x' }] }, 'fields[0].visible'], ['try_catch', { ...VALID.try_catch, retry: { retryDelayMs: 500 } }, 'retry.retryDelayMs'], + ['script', { ...VALID.script, actionType: 'email' }, 'actionType'], ] as const) { expect(flowNodeConfigRefusals(type, config).map(({ code, path }) => ({ code, path })), type) .toEqual([{ code: 'node-config-refused-by-contract', path: key }]); } - expect(flowNodeConfigRefusals('script', { ...VALID.script, actionType: 'email' })).toEqual([]); // CONTROL: the contracts refuse all four — the arm holds back, the contract does not. for (const [type, config] of [ ['http', { ...VALID.http, bogusKey: 1 }], diff --git a/packages/spec/src/automation/flow-sibling-block-loose-keys.ts b/packages/spec/src/automation/flow-sibling-block-loose-keys.ts index b358a8aa7cd..cb6334c7d8c 100644 --- a/packages/spec/src/automation/flow-sibling-block-loose-keys.ts +++ b/packages/spec/src/automation/flow-sibling-block-loose-keys.ts @@ -46,9 +46,9 @@ export const WAIT_EVENT_CONFIG_LOOSE_KEYS: ReadonlyArray { diff --git a/packages/spec/src/automation/flow.test.ts b/packages/spec/src/automation/flow.test.ts index 3c32b8e6adc..a216b20f119 100644 --- a/packages/spec/src/automation/flow.test.ts +++ b/packages/spec/src/automation/flow.test.ts @@ -370,7 +370,7 @@ describe('FlowSchema', () => { }); }); - it('REJECTS the retired `active` with the status prescription', () => { + it('REFUSES the aged-out `active` as an unknown key naming the upgrade guide (#25767)', () => { let message = ''; try { FlowSchema.parse({ @@ -379,8 +379,9 @@ describe('FlowSchema', () => { } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/status/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/unrecognized_keys/); + expect(message).toMatch(/`active`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should default runAs to user', () => { @@ -938,7 +939,7 @@ describe('FlowSchema - errorHandling', () => { }); }); - it('REJECTS the retired errorHandling.fallbackNodeId — faults route via fault edges', () => { + it('REFUSES the aged-out errorHandling.fallbackNodeId as an unknown key (faults route via fault edges; #25767)', () => { expect(() => FlowSchema.parse({ name: 'fallback_flow', label: 'Fallback', @@ -949,7 +950,7 @@ describe('FlowSchema - errorHandling', () => { ], edges: [], errorHandling: { strategy: 'continue', fallbackNodeId: 'fallback' }, - })).toThrow(/fault edge/); + })).toThrow(/`fallbackNodeId`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('should accept flow without errorHandling (optional)', () => { @@ -1304,7 +1305,10 @@ describe('BPMN — Wait Event Configuration', () => { * (#4158). Retiring the pair is what flipped them, which is the point — the * schema now says what `wait` actually does. */ - it('rejects the retired timeout keys instead of stripping them', () => { + it('refuses the retired timeout keys instead of stripping them', () => { + // Their tombstones were 16.x retirement kits and aged out at 18 (#25767); + // the block is strict, so each is an unknown-key refusal naming the key and + // the upgrade guide, where the `timerDuration` prescription now lives. for (const retired of [{ timeoutMs: 7_200_000 }, { onTimeout: 'fail' }]) { const result = FlowNodeSchema.safeParse({ id: 'wait_timer', @@ -1314,22 +1318,25 @@ describe('BPMN — Wait Event Configuration', () => { }); const key = Object.keys(retired)[0]; expect(result.success, `${key} must be rejected, not silently dropped`).toBe(false); - // The prescription names the issue and the replacement (or its absence). - expect(JSON.stringify(result.error?.issues), `${key} guidance`).toMatch(/was removed in @objectstack\/spec 17/); + const issues = JSON.stringify(result.error?.issues); + expect(issues, `${key} refusal`).toContain('unrecognized_keys'); + expect(issues, `${key} refusal`).toContain(`\`${key}\``); + expect(issues, `${key} refusal`).toContain('https://objectstack.ai/docs/protocol-upgrade'); } }); /** * #6758 — a prescription that does not parse is WORSE than no prescription: - * the author lands in the exact rejection the tombstone exists to spare them, - * and the second one is a bare `invalid_type` with no guidance attached. Both - * author-facing channels on this block print a `timerDuration` value to copy, - * and `timerDuration` is `z.string()` — so every value they print must be - * QUOTED. Until this test they printed the bare number `60000`, which is - * TS2322 at the authoring site and `expected string, received number` at the - * parse (the ADR-0087 conversion has always known better: it writes - * `String(next.timeoutMs)`, `conversions/registry.ts` — "Moving the number - * unstringified would produce a block that no longer parses"). + * the author lands in the exact rejection the guidance exists to spare them, + * and the second one is a bare `invalid_type` with no guidance attached. The + * author-facing channel on this block prints a `timerDuration` value to copy, + * and `timerDuration` is `z.string()` — so every value it prints must be + * QUOTED. Until this test the channels printed the bare number `60000`, which + * is TS2322 at the authoring site and `expected string, received number` at + * the parse (the ADR-0087 conversion that moved `timeoutMs` knew better: it + * wrote `String(next.timeoutMs)`). The `timeoutMs` tombstone that was the + * other channel aged out at 18 with its kit (#25767); the `timeout` + * misspelling guidance is the channel that remains. * * The value is EXTRACTED from the message rather than compared to a copy: a * hard-coded `'60000'` here would go green the moment someone reworded the @@ -1346,12 +1353,9 @@ describe('BPMN — Wait Event Configuration', () => { }; const channels = { - // Channel 1 — `retiredKey()`'s `z.never` message, raised when an upgrading - // author still writes the removed key. - 'the `timeoutMs` tombstone': messageFor({ eventType: 'timer', timeoutMs: 60_000 }, 'invalid_type'), - // Channel 2 — the `strictObject` `guidance` entry for the `timeout` - // misspelling. Worse than channel 1: there is no first failure to learn - // from, so a bad prescription here is the FIRST thing the schema ever says. + // The `strictObject` `guidance` entry for the `timeout` misspelling: there + // is no first failure to learn from, so a bad prescription here is the + // FIRST thing the schema ever says. 'the `timeout` misspelling guidance': messageFor({ eventType: 'timer', timeout: 60_000 }, 'unrecognized_keys'), }; @@ -1976,19 +1980,20 @@ describe('unknown keys are rejected, not stripped', () => { expect(result.data!.errorHandling!.backoffMs).toBe(5000); }); - it('errorHandling: `retryDelayMs` is the tombstone and carries the rename', () => { + it('errorHandling: `retryDelayMs` is an unknown key now, refused on THIS surface with the upgrade guide', () => { + // Its tombstone aged out at 18 (#25767). The refusal still names the + // surface it was written on — naming a scope narrower than the truth is + // what let the #4661 divergence read as reviewed for a whole release. const result = FlowSchema.safeParse({ ...minimalFlow, errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 5000 }, }); expect(result.success).toBe(false); const message = JSON.stringify(result.error!.issues); - expect(message).toContain('was removed in @objectstack/spec 17.0.0'); - expect(message).toContain('backoffMs'); - // The prescription must name THIS surface, not only the two #4661 knew - // about — naming a scope narrower than the truth is what let this - // divergence read as reviewed for a whole release. - expect(message).toContain('flow.errorHandling'); + expect(message).toContain('unrecognized_keys'); + expect(message).toContain('`retryDelayMs`'); + expect(message).toContain('`errorHandling`'); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('errorHandling: `maxAttempts` gets the off-by-one, not a rename', () => { diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index eb01b00f699..7e0d87b2702 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -20,7 +20,6 @@ import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { NON_BLANK_STRING } from '../shared/refinement-projection'; -import { retiredKey } from '../shared/retired-key'; import { retryPolicyShape } from '../shared/retry-policy.zod'; import { strictObject } from '../shared/strict-object'; import { collectFlowGraphs, parseFlowNodeRegions } from './control-flow.zod'; @@ -728,16 +727,6 @@ function flowNodeObject() { return strictObject( }, )).optional().describe('Input parameter schema for this node').meta({ title: 'Input Schema' }), - // `outputSchema` REMOVED (#3896 audit close-out): declared, never validated — - // no engine path checked node outputs against it (ledger: dead). - outputSchema: retiredKey( - '`flow.nodes[].outputSchema` was removed in @objectstack/spec 17.0.0 (audit ' + - 'close-out) — it was never validated: the engine does not check node outputs against ' + - 'it, so it documented a contract nothing enforced. Delete the key. Downstream nodes ' + - "read prior outputs via expressions ({{nodeId.field}}) regardless of any declaration. " + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * Wait Event Configuration (for 'wait' nodes) * Defines what external event or condition should resume the paused execution. @@ -776,49 +765,6 @@ function flowNodeObject() { return strictObject( /** Signal name to listen for — for signal/webhook events */ signalName: z.string().optional().describe('Named signal or webhook event to wait for'), - /** - * `wait` never had a timeout. Both keys below described one and neither - * delivered it (#4158) — the pair is retired in 17 rather than left standing - * as a promise the runtime does not keep (PD #10). (Both tombstones below say - * 17 and the ADR-0087 conversion is `toMajor: 17`; this line said 18, the - * #4350 class — a tombstone naming a major that never shipped it.) - * - * `timeoutMs` said "maximum wait time" and its ONLY reader used it as the - * timer *duration* when `timerDuration` was absent — so it did something, just - * not what it said. `timerDuration` already expresses that (`parseIsoDuration` - * reads a bare numeric *string* as milliseconds — the number must be quoted, - * because `timerDuration` is `z.string()` and the schema is what the author - * meets), which is why the conversion can move it losslessly, stringifying on - * the way, instead of dropping it. - * - * `onTimeout` had ZERO readers anywhere. Setting it changed nothing, and the - * showcase set it — a declared default (`'fail'`) stamped on every wait node - * that no code ever consulted. - * - * Real timeout semantics — resume the run at a deadline and either fail the - * node or continue past it — remain unimplemented. If they are wanted, they - * should be built to a requirement, not retrofitted to fit two keys that - * happened to be declared. - */ - timeoutMs: retiredKey( - '`waitEventConfig.timeoutMs` was removed in @objectstack/spec 17. It documented a ' - + 'timeout guard that never existed: nothing ever failed or resumed a wait on a deadline. Its ' - + 'only reader treated it as the timer DURATION when `timerDuration` was absent, so use ' - + '`timerDuration` — but QUOTE the number: the key is a string, and a bare numeric string is ' - + "read as milliseconds, making `timeoutMs: 60000` and `timerDuration: '60000'` the same wait " - + "(`timerDuration: 'PT1M'` is the ISO 8601 spelling of that same 60s). Stored flows are " - + 'converted automatically — the conversion does the quoting for you. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing ' - + 'sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - onTimeout: retiredKey( - '`waitEventConfig.onTimeout` was removed in @objectstack/spec 17. It had no readers at ' - + 'all — no code path ever inspected it, so neither `fail` nor `continue` ever happened. Delete ' - + 'the key. There is no replacement: `wait` has no timeout, and a wait node resumes only when ' - + 'its timer elapses or its signal arrives. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing ' - + 'sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), }).superRefine((wec, ctx) => { // A timer with nothing to count is the forever-hang in its second // spelling. `parseIsoDuration` is the executor's only source for the wake @@ -1089,14 +1035,6 @@ export const FlowSchema = lazySchema(() => strictObject( /** Metadata & Versioning */ version: z.number().int().default(1).describe('Version number'), status: z.enum(['draft', 'active', 'obsolete', 'invalid']).default('draft').describe('Deployment status'), - // `template` REMOVED (#3896 audit close-out): no reader in designer or engine. - template: retiredKey( - '`flow.template` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + - 'no designer or engine path ever read it, so flagging a flow as a template/subflow did ' + - 'nothing. Delete the key. Shared logic is invoked via a subflow NODE referencing the ' + - 'flow by name. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** Trigger Type */ type: z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api']).describe('Flow type'), @@ -1108,19 +1046,6 @@ export const FlowSchema = lazySchema(() => strictObject( nodes: z.array(FlowNodeSchema).describe('Flow nodes'), edges: z.array(FlowEdgeSchema).describe('Flow connections'), - // `active` REMOVED (#3896 audit close-out) — the rls.enabled shape, in the - // over-permissive direction: the spec default was `false` while the engine - // treated an unset flow as ENABLED, and `active: false` never stopped a flow - // (`status` is the enforced lifecycle). An author who "disabled" a flow here - // left it firing. - active: retiredKey( - '`flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it ' + - 'never had an effect: the engine arms flows from `status`, and `active: false` did NOT ' + - 'stop a flow (worse, the default read as disabled while the engine treated unset as ' + - "enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and " + - "disable a flow, `status: 'active'` to arm it. " + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), // ADR-0049 / #1888 — ENFORCED. The service-automation engine establishes the // declared identity for the run's data operations and restores the caller's // context afterward: `system` runs elevated (a full-access, RLS-bypassing @@ -1355,17 +1280,6 @@ export const FlowSchema = lazySchema(() => strictObject( maxRetries: retryPolicyShape().maxRetries .describe("Retry attempts after the initial one. Read only under strategy: 'retry', which requires >= 1; 0 (the default) means no retry."), - // `fallbackNodeId` REMOVED (#3896 audit close-out): the engine routes - // unrecoverable errors via per-node FAULT EDGES, never this — an author - // who configured a fallback here had none. - fallbackNodeId: retiredKey( - '`flow.errorHandling.fallbackNodeId` was removed in @objectstack/spec 17.0.0 (' + - 'audit close-out) — the engine routes unrecoverable node errors via per-node fault ' + - "edges (an edge with type: 'fault'), and never read this key: a fallback " + - 'configured here silently did not exist. Delete the key and draw a fault edge from ' + - 'the failing node to the handler node instead. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), }).superRefine((eh, ctx) => { // `strategy: 'retry'` with 0 attempts is `strategy: 'fail'` wearing a // different label — the flow runs once and stops. That is a declared diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index 65a51335550..bd02b525660 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -108,26 +108,26 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { + 'spelling the loader rewrote to `recipients` until this release. Use `recipients`. Rename the key; the value ' + '(who gets notified) is unchanged. If `recipients` is also present, the two name DIFFERENT recipients — ' + 'decide who gets notified, put them on `recipients`, and delete `to`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', subject: 'The `notify` node config key `subject` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the ' + 'email-idiom spelling the loader rewrote to `title` until this release. Use `title`. Rename the key; the ' + 'value (the heading text) is unchanged. If `title` is also present with a DIFFERENT value, reconcile them ' + 'onto `title`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', body: 'The `notify` node config key `body` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the ' + 'email-idiom spelling the loader rewrote to `message` until this release. Use `message`. Rename the key; ' + 'the value (the body text) is unchanged. If `message` is also present with a DIFFERENT value, reconcile ' + 'them onto `message`. (`body` IS canonical on an `http` node — the key is wrong only here.) ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', url: 'The `notify` node config key `url` was removed in @objectstack/spec 18 (ADR-0087 D2) — `url` elsewhere on ' + 'the platform means "HTTP endpoint to call" (`http` node, webhooks), a different concept from an in-app ' + 'click target, and the loader rewrote it to `actionUrl` until this release. Use `actionUrl`. Rename the ' + 'key; the value (the click-through link) is unchanged. If `actionUrl` is also present with a DIFFERENT ' + 'link, reconcile them onto `actionUrl`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', source: 'The nested `notify` node config `source: { object, id }` was removed in @objectstack/spec 18 (ADR-0087 D2) — ' + 'the click-through target is the flat PAIR `sourceObject` + `sourceId`, which the loader lifted the nested ' @@ -136,7 +136,7 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { + 'value, reconcile onto the flat pair and delete `source`. ' + 'Note the pair only takes effect together: a half-specified target is dropped so the inbox never renders a ' + 'dead link. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', }; /** diff --git a/packages/spec/src/automation/schemaless-node-config.test.ts b/packages/spec/src/automation/schemaless-node-config.test.ts index 8ccce8c314d..0191a253ce8 100644 --- a/packages/spec/src/automation/schemaless-node-config.test.ts +++ b/packages/spec/src/automation/schemaless-node-config.test.ts @@ -49,12 +49,13 @@ function unknownKeyMessage(schema: Parseable, value: unknown): string | undefine return result.error!.issues.find((i) => i.code === 'unrecognized_keys')?.message; } -/** Every key the contract still declares, tombstones included. */ -const SCRIPT_SHAPE_KEYS = [ - 'actionType', 'function', 'inputs', 'outputVariable', - 'recipients', 'script', 'template', 'variables', -]; -/** The keys #4343 retired — each must reject with its own prescription. */ +/** Every key the contract declares. */ +const SCRIPT_SHAPE_KEYS = ['function', 'inputs', 'outputVariable']; +/** + * The keys #4343 retired. Their tombstones were a 16.x retirement kit and aged + * out of the shape at 18 (#25767, ADR-0087 D2 = A): each is refused as a strict + * unknown key naming the upgrade guide, where the per-branch prescriptions now live. + */ const SCRIPT_RETIRED: ReadonlyArray<[string, unknown]> = [ ['actionType', 'email'], ['template', 'task_done'], @@ -90,16 +91,15 @@ describe('ScriptConfigSchema (converged to a function call)', () => { expect(ScriptConfigSchema.safeParse({ function: '' }).success).toBe(false); }); - it.each(SCRIPT_RETIRED)('rejects the retired `%s` with its own prescription', (key, value) => { + it.each(SCRIPT_RETIRED)('refuses the aged-out `%s` as an unknown key naming the upgrade guide', (key, value) => { const result = ScriptConfigSchema.safeParse({ function: 'score_lead', [key]: value }); expect(result.success).toBe(false); - const message = result.error!.issues.map((i) => i.message).join('\n'); - // The tombstone's payload is the prescription, not "unrecognized key" — - // this string IS the upgrade doc for whoever hits it (retired-key.ts). - expect(message).toContain(`\`script.config.${key}\``); - expect(message).toMatch(/was removed in @objectstack\/spec 17/); - expect(message).toMatch(/os migrate meta --from 16/); - expect(result.error!.issues[0]!.path).toEqual([key]); + const issue = result.error!.issues[0] as { code: string; keys?: string[]; path: PropertyKey[]; message: string }; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.keys).toEqual([key]); + expect(issue.message).toContain(`\`${key}\``); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(issue.message).not.toMatch(/os migrate meta --from 16/); }); it('names every violated key at once, so one refusal lists the whole job', () => { @@ -107,18 +107,9 @@ describe('ScriptConfigSchema (converged to a function call)', () => { function: 'score_lead', actionType: 'email', template: 't', recipients: ['a'], }); expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.path[0]).sort()) - .toEqual(['actionType', 'recipients', 'template']); - }); - - it('prescribes a different mechanism per branch — the retirement is not one rename', () => { - const messageFor = (key: string, value: unknown) => - ScriptConfigSchema.safeParse({ function: 'f', [key]: value }).error!.issues[0]!.message; - // Mail has a real delivery path; Slack does not go through it (no slack - // channel exists — that is a connector), and an inline body is a function. - expect(messageFor('actionType', 'email')).toMatch(/`notify` node/); - expect(messageFor('actionType', 'email')).toMatch(/connector_action/); - expect(messageFor('script', 'return 1;')).toMatch(/defineStack\(\{ functions \}\)/); + const issues = result.error!.issues as Array<{ code: string; keys?: string[] }>; + expect(issues).toHaveLength(1); + expect([...(issues[0]!.keys ?? [])].sort()).toEqual(['actionType', 'recipients', 'template']); }); }); @@ -438,14 +429,14 @@ describe('DecisionConfigSchema — `mode` beside a non-empty `conditions` list i }); describe('structural contract — what the downstream walkers require', () => { - it('keeps the tombstoned keys IN the shape, so the ratchet can see them retired', () => { - // A `retiredKey()` is still a property. Deleting it outright would read as - // "the key vanished" to the authorable-surface gate, which is the hard - // failure the tombstone route exists to avoid. + it('the aged-out keys are OUT of the shape — the ratchet saw them retired for one major, then dated their exit', () => { + // A `retiredKey()` stayed a property for the major after its retirement so + // the authorable-surface gate could see it retired rather than vanished; + // at 18 the gate dated each line's exit by the 17.0.0 release tag (#25767) + // and the keys left the shape. None may come back as a declared key. expect(Object.keys(ScriptConfigSchema.shape).sort()).toEqual(SCRIPT_SHAPE_KEYS); for (const [key] of SCRIPT_RETIRED) { - expect(ScriptConfigSchema.shape[key as keyof typeof ScriptConfigSchema.shape].description) - .toMatch(/^\[REMOVED\]/); + expect(ScriptConfigSchema.shape).not.toHaveProperty(key); } }); diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index fe3d6c680ab..4132ca4b9e6 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -162,14 +162,14 @@ const SCRIPT_KEY_GUIDANCE: Readonly> = { + 'AI/template-emitted alias the loader rewrote to `function` until this release. Use `function`. Rename the ' + 'key; the value (the callable reference) is unchanged. If `function` is also present, the two name DIFFERENT ' + 'callables — decide which one runs, put it on `function`, and delete `functionName`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', input: 'The `script` node config key `input` was removed in @objectstack/spec 18 (ADR-0087 D2) — the singular ' + "spelling leaked in from `connector_action`, where `connectorConfig.input` is a DIFFERENT and canonical " + 'surface (do not "fix" that one), and the loader rewrote it to `inputs` until this release. Use `inputs`. ' + 'Rename the key; the value (the input map) is unchanged. If `inputs` is also present with DIFFERENT values, ' + 'reconcile them onto `inputs`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', }; /** `subflow` prescriptions — one retired spelling, one wrong layer. */ @@ -180,7 +180,7 @@ const SUBFLOW_KEY_GUIDANCE: Readonly> = { + 'release. Use `flowName`. Rename the key; the value (the invoked flow\'s name) is unchanged. If `flowName` ' + 'is also present, the two name DIFFERENT flows — decide which one this step invokes, put it on `flowName`, ' + 'and delete `flow`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', timeoutMs: "A subflow step's timeout is the engine's per-node guard, so it belongs on the NODE, not in its config: " + '`{ id, type: "subflow", timeoutMs: 30000, config: { … } }`. `FlowNodeSchema.timeoutMs` is the declared key.', @@ -293,10 +293,13 @@ function decisionModeWithConditionsRefusal(mode: unknown): string { * spelling of `function`, and the `invoke_function` marker named nothing on * its own. * - * All five keys are tombstoned below; the ADR-0087 D2 conversion - * `flow-node-script-branch-keys-removed` rewrites stored metadata rows (moving - * a shorthand `actionType` into `function`, where that is what it meant) and is - * what `os migrate meta` lists as a mechanical edit for authored sources. + * All five keys are refused below as strict unknown keys: their tombstones + * were a ≤16.x retirement kit and aged out on #25767 (ADR-0087 D2 = A), together + * with the `flow-node-script-branch-keys-removed` conversion that rewrote stored + * metadata rows (moving a shorthand `actionType` into `function`, where that is + * what it meant). A stored row still carrying one of them is canonicalized on a + * release that carries the step (`os migrate meta --stored --apply` on 18.1 or + * earlier); the refusal's trailing sentence names the protocol upgrade guide. */ export const ScriptConfigSchema = lazySchema(() => strictObject({ surface: 'this script node config', @@ -333,54 +336,6 @@ export const ScriptConfigSchema = lazySchema(() => strictObject({ outputVariable: flowBoundVariableNameSchema('outputVariable').optional() .describe("Flow variable the function's return value is bound to — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}`"), - // The four retired dispatch branches (#4343). Each tombstone carries its own - // prescription because the three replacements are different mechanisms, not - // one rename: real messaging is `notify`, Slack is a connector, and inline - // logic belongs in a registered function. - actionType: retiredKey( - '`script.config.actionType` was removed in @objectstack/spec 17 — none of its values ' - + 'did what it said. The two built-ins were logger-backed stubs that recorded the intent and ' - + 'delivered nothing under any configuration, and every other value was a second spelling of ' - + '`config.function`. Replace it per branch: for `email` use a `notify` node (it delivers ' - + 'through the messaging service — the in-app inbox by default, real email once ' - + '`@objectstack/plugin-email` is installed); for `slack` use a `connector_action` node with ' - + 'the Slack connector, or an `http` node posting to a webhook; for anything else, move the ' - + 'name into `config.function`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for the shorthand ' - + 'case into `config.function`; `--write` applies the ones it can prove, and the stub and ' - + 'marker values are removed.', - ), - template: retiredKey( - '`script.config.template` was removed in @objectstack/spec 17 — it fed only the ' - + 'logger-backed `email`/`slack` stubs, which never rendered or sent a message, so no template ' - + 'id was ever resolved. Delete the key. For templated content use a `notify` node and name an ' - + 'email template (`email_template`) in its own `template` key, with `templateData` filling its ' - + 'placeholders: the email and in-app inbox channels render that template in each recipient\'s ' - + 'locale. Otherwise the node carries inline `title`/`message`. The messaging service\'s ' - + '`sys_notification_template` rows are read only by the `email` and `sms` channels, `email` ' - + 'only when the node names no template. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - recipients: retiredKey( - '`script.config.recipients` was removed in @objectstack/spec 17 — the addresses were ' - + 'logged, never messaged: the `email`/`slack` branches it fed delivered nothing. Use a ' - + '`notify` node, whose `recipients` (user ids, field refs or addresses) reach the messaging ' - + 'service for real. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - variables: retiredKey( - '`script.config.variables` was removed in @objectstack/spec 17 — it injected values ' - + 'into a template no side effect ever rendered. Delete the key. A `notify` node carries ' - + 'structured data in `payload`; a registered function takes it in `config.inputs`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - script: retiredKey( - '`script.config.script` was removed in @objectstack/spec 17 — the built-in runtime has ' - + 'no server-side JS sandbox, so an inline body was recognized and never executed: the node ' - + 'warned and completed as a no-op. Move the logic into a registered function ' - + '(`defineStack({ functions })`) and name it in `config.function`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), })); export type ScriptConfig = z.input; diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 7afc0e3b859..621491a7a6a 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -13,7 +13,7 @@ import { validateDriverConfig, } from '../data/driver/config-registry.zod.js'; import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; -import { ElementButtonPropsSchema, PageHeaderProps, PageTabsProps } from '../ui/component.zod.js'; +import { ElementButtonPropsSchema, PageHeaderProps } from '../ui/component.zod.js'; import { PageSchema } from '../ui/page.zod.js'; import { JobSchema, RetryPolicySchema } from '../system/job.zod.js'; import { defineStack } from '../stack.zod.js'; @@ -45,10 +45,10 @@ import { CONVERSION_NOTICE_CODE, type ConversionNotice } from './types.js'; * same-major pair is absorbed instead (`spec-property-retirement` §0). */ const SUPERSEDED_FIXTURES: Readonly> = { - // `connector.fieldMappings[].transform` (17) lives only inside - // `connector.fieldMappings`, which protocol 18 retired whole with the rest of - // connector-attached sync (ADR-0049). - 'field-mapping-transform-removed': { by: 'connector-sync-keys-removed', stripped: ['fieldMappings'] }, + // Empty since #25767: the one entry here (`field-mapping-transform-removed`, + // 17, whose only surface protocol 18 retired whole with + // `connector-sync-keys-removed`) aged out with the 16.x kits. The mechanism + // stays for the next such pair. }; describe('conversion layer (ADR-0087 D2)', () => { @@ -683,202 +683,28 @@ describe('conversion layer (ADR-0087 D2)', () => { * the test that fails first when either half moves. */ it('⛔ does NOT replay a RETIRED entry, while the stored-row door does', () => { - const entry = ALL_CONVERSIONS.find((c) => c.id === 'app-hidden-to-unpublished'); + const entry = ALL_CONVERSIONS.find((c) => c.id === 'object-external-sharing-model-removed'); // Premise: without this the assertions below would pass vacuously. expect(entry?.retiredFromLoadPath, 'premise: the entry under test is retired').toBe(true); - const authored = { name: 'account', label: 'Account', hidden: true, navigation: [] }; + const authored = { name: 'portal_case', label: 'Case', sharingModel: 'public_read', externalSharingModel: 'private' }; // Authoring funnel: the old shape survives verbatim, nothing is reported. const notices: ConversionNotice[] = []; const out = normalizeStackInput( - { apps: [structuredClone(authored)] }, + { objects: [structuredClone(authored)] }, { onConversionNotice: (n) => notices.push(n) }, ); - expect((out.apps as any[])[0]).toEqual(authored); + expect((out.objects as any[])[0]).toEqual(authored); expect(notices).toEqual([]); // Data at rest: the SAME bytes through the stored-row door DO convert. - const stored = applyConversionsToStoredItem('app', structuredClone(authored)) as Record< + const stored = applyConversionsToStoredItem('object', structuredClone(authored)) as Record< string, unknown >; - expect(stored.hidden).toBeUndefined(); - expect(stored._unpublished).toBe(true); - }); - }); - - describe('flow-node-wait-timeout-keys-removed', () => { - const wecFlow = (waitEventConfig: Record) => ({ - flows: [ - { - name: 'settle', - label: 'Settle', - type: 'autolaunched', - edges: [], - nodes: [ - { id: 'n1', type: 'start', label: 'Start' }, - { id: 'w', type: 'wait', label: 'Wait', waitEventConfig }, - ], - }, - ], - }); - const wecOf = (stack: Record) => (stack.flows as any[])[0].nodes[1].waitEventConfig; - // Retired from the load path, so the default `applyConversions` skips it — - // exactly like `stack-api-require-auth-removed`. - const convert = (stack: Record) => collectConversionNotices(stack, { includeRetired: true }); - - it('moves `timeoutMs` to `timerDuration` as a STRING the schema accepts', () => { - const { stack, notices } = convert(wecFlow({ eventType: 'timer', timeoutMs: 60_000 })); - expect(wecOf(stack)).toEqual({ eventType: 'timer', timerDuration: '60000' }); - expect(notices).toHaveLength(1); - // The move is only lossless if the result still parses — `timerDuration` is - // `z.string()`, so carrying the number across would have broken the block. - expect(() => FlowSchema.parse((stack.flows as any[])[0])).not.toThrow(); - }); - - it('drops `timeoutMs` instead of moving it when `timerDuration` already won', () => { - const { stack, notices } = convert( - wecFlow({ eventType: 'timer', timerDuration: 'PT5M', timeoutMs: 999 }), - ); - expect(wecOf(stack)).toEqual({ eventType: 'timer', timerDuration: 'PT5M' }); - expect(notices).toHaveLength(1); - }); - - it('drops `onTimeout` — it never had a reader, so there is nothing to preserve', () => { - const { stack, notices } = convert(wecFlow({ eventType: 'timer', timerDuration: 'PT1M', onTimeout: 'continue' })); - expect(wecOf(stack)).toEqual({ eventType: 'timer', timerDuration: 'PT1M' }); - expect(notices).toHaveLength(1); - }); - - it('leaves a block carrying neither key untouched', () => { - const { stack, notices } = convert(wecFlow({ eventType: 'signal', signalName: 'paid' })); - expect(wecOf(stack)).toEqual({ eventType: 'signal', signalName: 'paid' }); - expect(notices).toHaveLength(0); - }); - - it('tombstones both keys so a source that skipped conversion is rejected, not stripped', () => { - for (const bad of [{ timeoutMs: 60_000 }, { onTimeout: 'continue' }]) { - const flow = (wecFlow({ eventType: 'timer', timerDuration: 'PT1M', ...bad }).flows as any[])[0]; - expect(() => FlowSchema.parse(flow), `${Object.keys(bad)[0]} must be rejected`) - .toThrow(/was removed in @objectstack\/spec 17/); - } - }); - }); - - describe('flow-node-script-branch-keys-removed', () => { - /** One `script` node in a flow shaped the way the conversion walks it. */ - const scriptFlow = (config: Record) => ({ - flows: [ - { - name: 'task_lifecycle', - label: 'Task lifecycle', - type: 'autolaunched', - edges: [], - nodes: [ - { id: 'n1', type: 'start', label: 'Start' }, - { id: 's', type: 'script', label: 'Script', config }, - ], - }, - ], - }); - const cfgOf = (stack: Record) => (stack.flows as any[])[0].nodes[1].config; - // Retired from the load path (the keys misdescribed themselves), so the - // default `applyConversions` skips it — only `os migrate meta` replays it. - const convert = (stack: Record) => collectConversionNotices(stack, { includeRetired: true }); - - it('moves a shorthand `actionType` into `function` — that is what it named', () => { - const { stack, notices } = convert(scriptFlow({ actionType: 'score_lead', outputVariable: 'score' })); - expect(cfgOf(stack)).toEqual({ function: 'score_lead', outputVariable: 'score' }); - expect(notices).toHaveLength(1); - expect(notices[0]!.to).toBe('config.function'); - }); - - it('drops a shorthand `actionType` instead of moving it when `function` already won', () => { - const { stack, notices } = convert(scriptFlow({ actionType: 'stale_name', function: 'score_lead' })); - expect(cfgOf(stack)).toEqual({ function: 'score_lead' }); - expect(notices).toHaveLength(1); - }); - - it('drops the built-in ids and the bare marker — neither was ever a function name', () => { - for (const actionType of ['email', 'slack', 'invoke_function']) { - const { stack, notices } = convert(scriptFlow({ actionType, function: 'score_lead' })); - expect(cfgOf(stack), actionType).toEqual({ function: 'score_lead' }); - expect(notices, actionType).toHaveLength(1); - expect(notices[0]!.to, actionType).toMatch(/removed/); - } - }); - - it('drops the stub payload keys — nothing ever read them, so there is nothing to preserve', () => { - const { stack, notices } = convert(scriptFlow({ - actionType: 'email', - template: 'task_done', - recipients: ['{record.owner}'], - variables: { taskName: '{record.name}' }, - })); - expect(cfgOf(stack)).toEqual({}); - expect(notices).toHaveLength(4); - }); - - it('drops an inline `script` body the runtime never executed', () => { - const { stack, notices } = convert(scriptFlow({ script: 'return { ok: true };' })); - expect(cfgOf(stack)).toEqual({}); - expect(notices).toHaveLength(1); - }); - - it('leaves an already-converged node untouched', () => { - const { stack, notices } = convert(scriptFlow({ function: 'score_lead', inputs: { id: '{record.id}' } })); - expect(cfgOf(stack)).toEqual({ function: 'score_lead', inputs: { id: '{record.id}' } }); - expect(notices).toHaveLength(0); - }); - - it('leaves a non-script node carrying the same key names alone', () => { - // Unlike the wait retirement, these tombstones live on the script config - // contract — no other node type is parsed against it, so a `template` key - // elsewhere is that node's own business. - const stack0 = { - flows: [{ - name: 'f', - nodes: [{ id: 'n', type: 'notify', config: { template: 'x', recipients: ['a'] } }], - }], - }; - const { stack, notices } = convert(stack0); - expect(stack).toEqual(stack0); - expect(notices).toHaveLength(0); - }); - - it('tombstones every retired key so a source that skipped conversion is rejected, not stripped', () => { - // NOTE the channel: unlike `waitEventConfig`, a node's `config` is - // `z.record(z.unknown())` on `FlowNodeSchema`, so `FlowSchema.parse` does - // NOT reach these tombstones — they answer whoever AUTHORS the key (`tsc` - // types it `never`; this parse raises the prescription). A stored flow is - // reached by the other half: `registerFlow` replays this conversion even - // though it is retired (#3903), and the execute-time parse then refuses - // what is left over for naming no callable. - for (const bad of [ - { actionType: 'email' }, - { template: 't' }, - { recipients: ['a'] }, - { variables: { x: 1 } }, - { script: 'return 1;' }, - ]) { - const key = Object.keys(bad)[0]!; - expect( - () => ScriptConfigSchema.parse({ function: 'score_lead', ...bad }), - `${key} must be rejected`, - ).toThrow(/was removed in @objectstack\/spec 17/); - } - // The flow-level parse is deliberately blind to the TOMBSTONES — pinned so - // the note above stays true if `FlowNodeSchema.config` is ever tightened. - // What it does see since #20316 is the one key the script contract - // requires, left out: a stripped node naming no callable is refused at - // the build doors now, not only at execute — and by that, never by a - // tombstone. - expect(() => FlowSchema.parse((scriptFlow({ function: 'score_lead', actionType: 'email' }).flows as any[])[0])).not.toThrow(); - const stripped = FlowSchema.safeParse((scriptFlow({ actionType: 'email' }).flows as any[])[0]); - expect(stripped.success).toBe(false); - expect(stripped.error!.issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.config.function']); - expect(stripped.error!.issues[0].message).not.toMatch(/was removed in @objectstack\/spec 17/); + expect(stored.externalSharingModel).toBeUndefined(); + expect(stored.sharingModel).toBe('public_read'); }); }); @@ -1204,7 +1030,7 @@ describe('conversion layer (ADR-0087 D2)', () => { expect(JobSchema.safeParse(converted).success).toBe(true); }); - it('the flow half needed nothing more: an authored `retryDelayMs` meets its tombstone naming `backoffMs`', () => { + it('the flow half needed nothing more: an authored `retryDelayMs` meets the strict refusal (its tombstone aged out, #25767)', () => { const issues = refusalOf({ flows: [{ name: 'sync_orders', @@ -1215,8 +1041,9 @@ describe('conversion layer (ADR-0087 D2)', () => { edges: [{ id: 'e1', source: 'start', target: 'end' }], }], }); - expect(issues.map((i) => i.path.join('.'))).toEqual(['flows.0.errorHandling.retryDelayMs']); - expect(issues[0]!.message).toContain('Rename the key to `backoffMs`'); + expect(issues.map((i) => i.path.join('.'))).toEqual(['flows.0.errorHandling']); + expect(issues[0]!.message).toContain('`retryDelayMs`'); + expect(issues[0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('CONTROL — a job with no block, and a block stating `maxRetries: 1`, both parse; an omitted multiplier keeps its default', () => { @@ -1356,159 +1183,6 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); - // #4456 — the driver-factory `??` fallback graduation. The mappings are - // driver-scoped by construction; these pin the two edges the flat fixture - // pair cannot express as sharply: the same key converting under one driver - // and not another, and the load-path posture. - describe('datasource-config-driver-key-aliases', () => { - const convert = (datasources: unknown[]) => - collectConversionNotices({ datasources }, { includeRetired: true }); - - it('renames `database` → `filename` ONLY under sqlite — it is canonical for the SQL/mongo drivers', () => { - const { stack, notices } = convert([ - { name: 'a', driver: 'sqlite', config: { database: './a.db' } }, - { name: 'b', driver: 'postgres', config: { host: 'db', database: 'analytics' } }, - { name: 'c', driver: 'mysql', config: { host: 'db', database: 'orders' } }, - { name: 'd', driver: 'mongo', config: { host: 'db', database: 'events' } }, - ]); - const [a, b, c, d] = stack.datasources as Array<{ config: Record }>; - expect(a!.config).toEqual({ filename: './a.db' }); - expect(b!.config).toEqual({ host: 'db', database: 'analytics' }); - expect(c!.config).toEqual({ host: 'db', database: 'orders' }); - expect(d!.config).toEqual({ host: 'db', database: 'events' }); - expect(notices.filter((n) => n.conversionId === 'datasource-config-driver-key-aliases')).toHaveLength(1); - }); - - it('still lands for a row whose driver id is ITSELF being renamed', () => { - // The pairs are keyed by CANONICAL driver id, and commit e2798fab7 renamed mongo's. - // A stored `driver: 'mongo'` must therefore still find the mongo pairs - // (through the alias) even as the sibling conversion rewrites its id — - // otherwise the rename would quietly un-convert every legacy mongo config. - const { stack, notices } = convert([ - { name: 'events', driver: 'mongo', config: { uri: 'mongodb://db/x', user: 'svc' } }, - ]); - const [events] = stack.datasources as Array<{ driver: string; config: Record }>; - expect(events!.config).toEqual({ url: 'mongodb://db/x', username: 'svc' }); - expect(events!.driver).toBe('mongodb'); - // Two key renames (`uri` → `url`, `user` → `username`) plus the id rename. - expect(notices.filter((n) => n.conversionId === 'datasource-config-driver-key-aliases')).toHaveLength(2); - expect(notices.filter((n) => n.conversionId === 'datasource-driver-mongo-to-mongodb')).toHaveLength(1); - }); - - it('does not touch a plugin-contributed driver id — no contract, no rewrite', () => { - const before = { datasources: [{ name: 'x', driver: 'com.vendor.snowflake', config: { user: 'svc' } }] }; - const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - }); - - it('is retired from the load path — an AUTHORED legacy spelling is not silently absorbed', () => { - // Without includeRetired (the normalizeStackInput posture) the stack is - // untouched: the authoring gate rejects `file:` with a rename hint, and - // this conversion must not run ahead of it and defeat #4410. - const before = { datasources: [{ name: 'a', driver: 'sqlite', config: { file: './a.db' } }] }; - const { stack, notices } = collectConversionNotices(structuredClone(before)); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - }); - }); - /** - * #5011 — `dashboard.widgets[].compareTo` converged on the executor contract. - * - * The fixture pair above already proves the three mechanical rewrites. What it - * cannot express is the entry's real judgement: the durations it deliberately - * does NOT touch, and the fact that it takes no acceptance window. Both are - * asserted here, because a conversion that quietly rewrote `{ offset: '7d' }` - * into a kind would turn a loud rejection into a wrong comparison — the exact - * failure class #5011 exists to end. - */ - describe('dashboard-widget-compareto-converged', () => { - const dash = (compareTo: unknown) => ({ - dashboards: [{ - name: 'revenue_review', - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], compareTo }], - }], - }); - const convert = (compareTo: unknown) => - collectConversionNotices(structuredClone(dash(compareTo)), { includeRetired: true }); - const widgetOf = (stack: Record) => - (stack.dashboards as Array<{ widgets: Array> }>)[0]!.widgets[0]!; - - it('rewrites both bare strings, value verbatim, container changed', () => { - for (const kind of ['previousPeriod', 'previousYear'] as const) { - const { stack, notices } = convert(kind); - expect(widgetOf(stack).compareTo).toEqual({ kind }); - expect(notices).toHaveLength(1); - expect(notices[0]!.from).toBe(`'${kind}'`); - } - }); - - it("rewrites `{ offset: '1y' }` — the one duration with an exact equivalent", () => { - const { stack, notices } = convert({ offset: '1y' }); - expect(widgetOf(stack).compareTo).toEqual({ kind: 'previousYear' }); - expect(notices).toHaveLength(1); - }); - - /** - * The deliberate non-rewrite, and the assertion this whole describe exists - * for. `previousPeriod` shifts by the resolved window's own length, which - * equals `7d` only when the window happens to be seven days — so a - * mechanical rewrite would silently change which rows the comparison column - * counts. Left untouched, the strict schema meets the author with the - * prescription instead. - */ - it('leaves every other duration UNTOUCHED and silent, rather than guessing', () => { - for (const offset of ['7d', '1M', '2w', '30d', '3y']) { - const before = dash({ offset }); - const { stack, notices } = convert({ offset }); - expect(stack, `${offset} must not be rewritten`).toEqual(before); - expect(notices, `${offset} must emit no notice`).toHaveLength(0); - } - }); - - it('never synthesises a `dimension` — the executor resolves it, the chain cannot', () => { - // The conversion sees a stack, not a dataset: it cannot know which time - // dimension carries the window. Writing a guess here would convert a loud - // runtime error into a wrong comparison. - for (const input of ['previousPeriod', 'previousYear', { offset: '1y' }]) { - expect(widgetOf(convert(input).stack)).not.toHaveProperty('compareTo.dimension'); - } - }); - - it('is retired from the load path — the old spellings get NO acceptance window', () => { - // Without `includeRetired` (the normalizeStackInput posture) nothing is - // rewritten: the strict schema must be what an AUTHORED legacy spelling - // meets, so the prescription is delivered instead of the shape being - // silently absorbed (PD #12). - for (const input of ['previousPeriod', 'previousYear', { offset: '1y' }]) { - const before = dash(input); - const { stack, notices } = collectConversionNotices(structuredClone(before)); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - } - }); - - it('touches only `compareTo` — a widget without one passes through by reference', () => { - const before = { - dashboards: [{ - name: 'other', widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'] }], - }], - }; - const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - }); - - it('is idempotent by construction — the converged shape is not a match', () => { - // No test replays a conversion twice for us (the skill's note), so this - // asserts it directly: `{ kind }` is neither a string arm nor an `offset`. - const before = dash({ kind: 'previousYear' }); - const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); - expect(stack).toEqual(before); - expect(notices).toHaveLength(0); - }); - }); - /** * `page-header-subtitle-alias` (#4827, objectui#3226). * @@ -1823,294 +1497,6 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); - /** - * `page-tabs-type-to-tab-style` (#6776). - * - * The fixture pair above pins before → after and the notice count. What needs - * its own cover here is everything the fixture cannot show: - * - * - the **discriminator** — this entry keys on the component's `type`, and - * the key it rewrites is also called `type`, so "which `type`" is the - * whole correctness question; - * - the **reach** — `page:tabs` is one of the seven named slots, and all - * four in-repo authoring sites are `slots.tabs`, so a region-only walk - * would rewrite nothing that actually exists; - * - **idempotence** and the #4923 both-keys rule; - * - the **acceptance face in both directions**, since a rename moves what - * the schema accepts as well as what it refuses. - */ - describe('page-tabs-type-to-tab-style', () => { - const regionPage = (properties: Record) => ({ - pages: [{ name: 'sys_position_detail', regions: [{ name: 'main', components: [{ type: 'page:tabs', properties }] }] }], - }); - const slottedPage = (properties: Record) => ({ - pages: [{ name: 'sys_user_detail', regions: [], slots: { tabs: { type: 'page:tabs', properties } } }], - }); - const convert = (stack: Record) => { - const notices: ConversionNotice[] = []; - const out = applyConversions(stack, { includeRetired: true, onNotice: (n) => notices.push(n) }); - return { out, notices }; - }; - type Comp = { type: string; properties: Record }; - type Pg = { regions: { components: Comp[] }[]; slots?: { tabs: Comp | Comp[] } }; - const pageOf = (stack: Record) => (stack.pages as Pg[])[0]!; - const propsOf = (stack: Record, where: 'region' | 'slot') => { - const page = pageOf(stack); - return where === 'region' - ? page.regions[0]!.components[0]!.properties - : (page.slots!.tabs as Comp).properties; - }; - - it('rewrites `properties.type` → `tabStyle` on a region-level page:tabs', () => { - const { out, notices } = convert(regionPage({ type: 'card', items: [] })); - expect(propsOf(out, 'region')).toEqual({ tabStyle: 'card', items: [] }); - expect(notices.map((n) => n.conversionId)).toEqual(['page-tabs-type-to-tab-style']); - expect(notices[0]!.path).toBe('pages[0].regions[0].components[0].properties.tabStyle'); - }); - - it('reaches `slots.tabs` — the shape every in-repo site actually uses', () => { - // Region-only reach was the pre-#6776 behaviour of `mapPageComponents`, - // and for THIS key it would have converted nothing: `page:tabs` is a - // named slot, and all four sites in this repo are slotted record pages. - // A conversion that cannot reach the corpus makes the tombstone's - // "run `os migrate meta`" prescription a false promise. - const { out, notices } = convert(slottedPage({ type: 'pill', position: 'top', items: [] })); - expect(propsOf(out, 'slot')).toEqual({ tabStyle: 'pill', position: 'top', items: [] }); - expect(notices[0]!.path).toBe('pages[0].slots.tabs.properties.tabStyle'); - }); - - it('reaches an ARRAY-valued slot too, indexing the path', () => { - const stack = { - pages: [{ - name: 'sys_user_detail', - regions: [], - slots: { tabs: [{ type: 'page:tabs', properties: { type: 'card', items: [] } }] }, - }], - }; - const { out, notices } = convert(stack); - const slot = pageOf(out).slots!.tabs as Comp[]; - expect(slot[0]!.properties).toEqual({ tabStyle: 'card', items: [] }); - expect(notices[0]!.path).toBe('pages[0].slots.tabs[0].properties.tabStyle'); - }); - - it('never touches the node\'s OWN `type` — the dispatch key is not the prop', () => { - // The one confusion this entry has to be immune to: `component.type` is - // `'page:tabs'` and stays that way; only `properties.type` moves. - const { out } = convert(regionPage({ type: 'card', items: [] })); - expect(pageOf(out).regions[0]!.components[0]!.type).toBe('page:tabs'); - }); - - it('leaves a non-tabs component alone, including a nested `type` in its props', () => { - const stack = { - pages: [{ - name: 'p', - regions: [{ - name: 'main', - components: [{ type: 'element:button', properties: { label: 'Open', action: { type: 'url', target: '/x' } } }], - }], - }], - }; - // Identity, not just equality: nothing converted, so copy-on-write shares. - expect(applyConversions(stack, { includeRetired: true })).toBe(stack); - }); - - it('keeps BOTH when `tabStyle` already says something different', () => { - const stack = regionPage({ tabStyle: 'pill', type: 'card', items: [] }); - const { out, notices } = convert(stack); - expect(out).toBe(stack); - expect(notices).toEqual([]); - }); - - it('drops the redundant twin when both spellings agree', () => { - const { out, notices } = convert(regionPage({ tabStyle: 'pill', type: 'pill', items: [] })); - expect(propsOf(out, 'region')).toEqual({ tabStyle: 'pill', items: [] }); - expect(notices).toHaveLength(1); - }); - - it('is idempotent — the converted result replays to itself with no second notice', () => { - const once = applyConversions(regionPage({ type: 'card', items: [] }), { includeRetired: true }); - const notices: ConversionNotice[] = []; - const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); - expect(twice).toBe(once); - expect(notices).toEqual([]); - }); - - /** - * The acceptance face, both directions — a KEY verdict, so the criterion is - * the props schema's own judgement of the key (#5046's distinction), not a - * full-parse-green demand on a value. - * - * `PageSchema` stays green on both spellings because - * `PageComponent.properties` is an open bag that never judged the key at - * all; that gap is the defect's mechanism, and the #5068 props gate is the - * only place either verdict is visible. - */ - it('the props schema refuses `type` BY NAME and accepts `tabStyle`; PageSchema accepts both', () => { - expect(PageTabsProps.safeParse({ type: 'card', items: [] }).success).toBe(false); - // The refusal carries the prescription, not a bare "unrecognized key" — - // a `retiredKey` tombstone rather than an undeclared key, which is what - // makes the removal audible to an upgrading (often AI) author. - expect(() => PageTabsProps.parse({ type: 'card', items: [] })) - .toThrow(/`type`.*removed.*`tabStyle`/s); - expect(PageTabsProps.safeParse({ tabStyle: 'card', items: [] }).success).toBe(true); - - const page = (properties: Record) => ({ - name: 'sys_position_detail', - label: 'Position', - type: 'record' as const, - object: 'sys_position', - regions: [{ name: 'main', components: [{ type: 'page:tabs', properties }] }], - }); - expect(PageSchema.safeParse(page({ type: 'card', items: [] })).success).toBe(true); - expect(PageSchema.safeParse(page({ tabStyle: 'card', items: [] })).success).toBe(true); - }); - }); - - /** - * `app-hidden-to-unpublished` (#4829, ADR-0045 amended 2026-08-09). - * - * The fixture pair pins before → after and the notice count. What needs its - * own cover here is the property the fixture cannot express, and the one this - * entry would be dangerous without: **its reach is the stored population and - * nothing else.** - * - * `hidden` is not a retired key. It keeps its birth contract — navigation - * presentation — so an author writing `hidden: true` (the Account / - * personal-settings case, which the platform itself does) must come out of the - * load path with `hidden: true` still on the app. A conversion firing there - * would rewrite that into an unpublished app and reproduce #4829 one layer - * down, through the very machinery meant to repair it. `retiredFromLoadPath` - * is what buys that, so it is asserted directly rather than inferred. - */ - describe('app-hidden-to-unpublished', () => { - const storedApp = (app: Record) => ({ apps: [{ name: 'production_management', ...app }] }); - const convertStored = (stack: Record) => { - const notices: ConversionNotice[] = []; - const out = applyConversions(stack, { includeRetired: true, onNotice: (n) => notices.push(n) }); - return { out, notices }; - }; - const appOf = (stack: Record) => (stack.apps as Record[])[0]!; - - it('rewrites a stored `hidden: true` app to `_unpublished: true`, dropping `hidden`', () => { - const { out, notices } = convertStored(storedApp({ hidden: true, label: 'PM', navigation: [] })); - expect(appOf(out)).toEqual({ name: 'production_management', _unpublished: true, label: 'PM', navigation: [] }); - expect(notices.map((n) => n.conversionId)).toEqual(['app-hidden-to-unpublished']); - expect(notices[0]!.path).toBe('apps[0]._unpublished'); - expect(notices[0]!.from).toBe('hidden'); - expect(notices[0]!.to).toBe('_unpublished'); - }); - - it('leaves `hidden: false` alone — it meant "listed" under both regimes', () => { - const before = storedApp({ hidden: false, navigation: [] }); - const { out, notices } = convertStored(before); - expect(out).toBe(before); - expect(notices).toEqual([]); - }); - - it('a row that already carries `_unpublished` keeps BOTH keys, untouched', () => { - // The machine has already spoken about this row. Reconciling a - // disagreeing pair is a human's call, not the loader's — and it is what - // makes the pass idempotent on a row that has already converted. - const before = storedApp({ hidden: true, _unpublished: false, navigation: [] }); - const { out, notices } = convertStored(before); - expect(out).toBe(before); - expect(notices).toEqual([]); - }); - - it('is idempotent — the converted result replays to itself with no second notice', () => { - const once = applyConversions(storedApp({ hidden: true, navigation: [] }), { includeRetired: true }); - const notices: ConversionNotice[] = []; - const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); - expect(twice).toBe(once); - expect(notices).toEqual([]); - }); - - it('replays on the STORED seam, which is the population it exists for', () => { - const converted = applyConversionsToStoredItem('app', { name: 'edu_admin', hidden: true, navigation: [] }); - expect(converted).toEqual({ name: 'edu_admin', _unpublished: true, navigation: [] }); - }); - - // ---- the reach, asserted in the negative ---- - - it('⛔ does NOT fire on the LOAD path — an authored `hidden: true` survives verbatim', () => { - // `includeRetired` defaults to false, which IS the load seam - // (`normalizeStackInput` for defineStack / validate / lint). This is the - // assertion that keeps the built-in Account app — authored `hidden: true` - // for the avatar-menu reason — from being converted into an app no normal - // user may reach, which is the #4829 defect itself. - const account = { apps: [{ name: 'account', label: 'Account', hidden: true, navigation: [] }] }; - const notices: ConversionNotice[] = []; - const out = applyConversions(account, { onNotice: (n) => notices.push(n) }); - expect(out).toBe(account); - expect(appOf(out)).toEqual({ name: 'account', label: 'Account', hidden: true, navigation: [] }); - expect(notices).toEqual([]); - }); - - it('is declared `retiredFromLoadPath` — the flag IS the load-path exclusion', () => { - const entry = ALL_CONVERSIONS.find((c) => c.id === 'app-hidden-to-unpublished'); - expect(entry).toBeDefined(); - expect(entry!.retiredFromLoadPath).toBe(true); - expect(entry!.toMajor).toBe(17); - }); - - // ---- the seam's own refusal (`excludeConversionIds`, #17885) ---- - - /** - * A seam that has opened the retired window may still refuse a named - * entry. The artifact-ingestion door does exactly this for the DEFAULT-FLIP - * class: its evidence is the artifact's DECLARED `engines.protocol` floor, - * which is a dependency range and not an age, so "this input predates the - * flip" is a guess there — while at the stored-row seam above it is a fact. - * Pinned on the primitive so the option cannot quietly stop working while - * the door's own suite still reads green for another reason. - */ - it('a seam can refuse a named entry by id even with `includeRetired: true`', () => { - const before = storedApp({ hidden: true, label: 'PM', navigation: [] }); - const notices: ConversionNotice[] = []; - const out = applyConversions(before, { - includeRetired: true, - excludeConversionIds: ['app-hidden-to-unpublished'], - onNotice: (n) => notices.push(n), - }); - expect(out).toBe(before); - expect(appOf(out)).toEqual({ name: 'production_management', hidden: true, label: 'PM', navigation: [] }); - expect(notices).toEqual([]); - }); - - /** - * ⭐ FIRING CONTROL for the refusal above — it excludes ONE id, not the - * window. `page-kind-jsx-to-html` (retired too, at 18) and the retired app - * entry are driven through the SAME call: the named one is refused, the other - * still fires. - */ - it('refuses only the named id — the rest of the chain still runs', () => { - const notices: ConversionNotice[] = []; - const out = applyConversions( - { - apps: [{ name: 'production_management', hidden: true, navigation: [] }], - pages: [{ name: 'landing', kind: 'jsx', source: '
hi
' }], - }, - { - includeRetired: true, - excludeConversionIds: ['app-hidden-to-unpublished'], - onNotice: (n) => notices.push(n), - }, - ); - expect(appOf(out).hidden, 'the refused entry did not fire').toBe(true); - expect(appOf(out)._unpublished).toBeUndefined(); - expect((out.pages as Record[])[0]!.kind, 'the rest of the chain did').toBe('html'); - expect(notices.map((n) => n.conversionId)).toEqual(['page-kind-jsx-to-html']); - }); - - it('an absent or empty exclusion list changes nothing', () => { - const shape = () => storedApp({ hidden: true, navigation: [] }); - const withEmpty = applyConversions(shape(), { includeRetired: true, excludeConversionIds: [] }); - const without = applyConversions(shape(), { includeRetired: true }); - expect(appOf(withEmpty)._unpublished).toBe(true); - expect(appOf(without)._unpublished).toBe(true); - }); - }); - /** * `field-reference-to-alias` (#13700 — ui#6837 half 1). * diff --git a/packages/spec/src/conversions/page-component-walk.test.ts b/packages/spec/src/conversions/page-component-walk.test.ts index 6f2520da21c..375329d64a3 100644 --- a/packages/spec/src/conversions/page-component-walk.test.ts +++ b/packages/spec/src/conversions/page-component-walk.test.ts @@ -64,11 +64,6 @@ describe('a conversion reaches a component nested in another component', () => { properties: (child) => ({ tabStyle: 'line', items: [{ label: 'Tab', children: [child] }] }), path: 'properties.items[0].children[0]', }, - { - label: 'properties.body[] — a page:card body', - properties: (child) => ({ body: [child] }), - path: 'properties.body[0]', - }, { label: 'properties.footer[] — a page:card footer', properties: (child) => ({ footer: [child] }), @@ -100,12 +95,13 @@ describe('a conversion reaches a component nested in another component', () => { }); } - it('reaches every position `pageComponentSlotPositions()` names, the retired spelling included', () => { + it('reaches every position `pageComponentSlotPositions()` names', () => { const positions = pageComponentSlotPositions(); // Guards the case against passing vacuously on an empty list, and ties it - // to the four explicit shapes above. + // to the three explicit shapes above. (`body`, the retired page:card + // spelling, left the list with its tombstone at 18 — #25767.) expect(positions.map(({ key, panelKey }) => (panelKey === undefined ? key : `${key}[].${panelKey}`)).sort()) - .toEqual(['body', 'children', 'footer', 'items[].children']); + .toEqual(['children', 'footer', 'items[].children']); for (const { key, panelKey } of positions) { const properties = panelKey === undefined ? { [key]: [staleHeader('Nested')] } @@ -163,27 +159,6 @@ describe('a conversion reaches a component nested in another component', () => { ]); }); - it('visits a moved sub-tree ONCE — the descent reads the mapped component', () => { - // `page-card-body-to-children` renames `properties.body` → `children` on the - // outer card. Because the descent happens after the mapper, the inner card - // is walked under the canonical key and emits one notice, not one per - // spelling — and its own path is spelled with `children`, not `body`. - const { stack, notices } = collectConversionNotices( - regionPage({ - type: 'page:card', - properties: { title: 'Outer', body: [{ type: 'page:card', properties: { title: 'Inner', body: [] } }] }, - }), - { includeRetired: true }, - ); - expect(notices.map((n) => n.path)).toEqual([ - 'pages[0].regions[0].components[0].properties.children', - 'pages[0].regions[0].components[0].properties.children[0].properties.children', - ]); - expect(componentAt(stack, 'regions', 0, 'components', 0)).toEqual({ - type: 'page:card', - properties: { title: 'Outer', children: [{ type: 'page:card', properties: { title: 'Inner', children: [] } }] }, - }); - }); }); describe('the page-component walk stays copy-on-write and shape-gated', () => { diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 67a991df227..852347f8bd0 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -26,7 +26,7 @@ import { renameConfigKey, renameKey, } from './walk.js'; -import { resolveDriverId, type BuiltinDriverId } from '../data/driver/config-registry.zod.js'; +import { resolveDriverId } from '../data/driver/config-registry.zod.js'; import { RETIRED_SUB_DAY_INTERVALS } from '../data/analytics.zod.js'; import { ClockTimeValueSchema } from '../data/field-value.zod.js'; import { @@ -256,242 +256,6 @@ const flowNodeFilterAlias: MetadataConversion = { }, }; -/** - * Object `compactLayout` → `highlightFields` (spec 11.7.0, ADR-0085; alias - * retired at authoring in 11.9.1, #2536). - * - * A pure key rename — the value (ordered field-name list) is unchanged. - * **Retired from the load path**: the schema tombstones `compactLayout` with a - * fix-it error, so the loader must NOT quietly accept it. Protocol 11 is below - * `MIGRATION_SUPPORT_FLOOR`, so no migration step carries this conversion any - * more and `migrate meta --from 10|11` refuses before it would ever reach it - * (backfilled per the ADR-0087 true-up — the rename shipped before the - * conversion layer existed). Its one remaining reader is the stored-row replay - * (`applyConversionsToStoredItem`, `conversions/stored.ts`), which is not - * floor-scoped and still walks it for rows at rest. - */ -const objectCompactLayoutRename: MetadataConversion = { - id: 'object-compactLayout-to-highlightFields', - toMajor: 11, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'object.compactLayout', - summary: "object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles)", - apply(stack, emit) { - return mapCollection(stack, 'objects', (obj, path) => { - const renamed = renameKey(obj, 'compactLayout', 'highlightFields'); - if (!renamed) return obj; - emit({ from: 'compactLayout', to: 'highlightFields', path: `${path}.highlightFields` }); - return renamed; - }); - }, - fixture: { - before: { - objects: [{ name: 'crm_lead', label: 'Lead', compactLayout: ['name', 'status'] }], - }, - after: { - objects: [{ name: 'crm_lead', label: 'Lead', highlightFields: ['name', 'status'] }], - }, - expectedNotices: 1, - }, -}; - -/** - * Stack collection `roles:` → `positions:` (protocol 13, ADR-0090 D3). - * - * The distribution concept was renamed Role → Position across the platform; - * the stack-definition collection key renamed with it. A pure key move — the - * item shapes migrate separately (`position.parent` removal is semantic, see - * the step-13 TODOs, historically). **Retired from the load path**: ADR-0090 - * shipped this as a pre-launch one-step rename with no alias window. Protocol - * 13 is below `MIGRATION_SUPPORT_FLOOR`, so no migration step carries this - * conversion any more; the entry now survives only as the stored-row replay - * (`applyConversionsToStoredItem`, `conversions/stored.ts`), which is not - * floor-scoped. - */ -const stackRolesToPositions: MetadataConversion = { - id: 'stack-roles-to-positions', - toMajor: 13, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'stack.roles', - summary: "stack collection key 'roles' → 'positions' (ADR-0090 D3)", - apply(stack, emit) { - const renamed = renameKey(stack, 'roles', 'positions'); - if (!renamed) return stack; - emit({ from: 'roles', to: 'positions', path: 'positions' }); - return renamed; - }, - fixture: { - before: { - roles: [{ name: 'sales_rep', label: 'Sales Rep' }], - }, - after: { - positions: [{ name: 'sales_rep', label: 'Sales Rep' }], - }, - expectedNotices: 1, - }, -}; - -/** - * OWD legacy aliases `read` / `read_write` → canonical (protocol 13, - * ADR-0090 D4). - * - * The two aliases with an unambiguous canonical spelling convert mechanically; - * the third legacy alias `'full'` has NO lossless target (full access includes - * transfer/delete — wider than `public_read_write`) and is delegated to the - * step-13 semantic TODO instead (D2 scope guard: lossless only). Handles both - * `object.sharingModel` and the nested `object.security.sharingModel` spot. - * **Retired from the load path** (one-step removal; authoring rejects with a - * fix-it). - */ -const owdLegacyReadAliases: MetadataConversion = { - id: 'owd-legacy-read-aliases', - toMajor: 13, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'object.sharingModel', - summary: "object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4)", - apply(stack, emit) { - const CANONICAL: Record = { - read: 'public_read', - read_write: 'public_read_write', - }; - return mapCollection(stack, 'objects', (obj, path) => { - let next = obj; - const direct = next.sharingModel; - if (typeof direct === 'string' && CANONICAL[direct]) { - emit({ from: direct, to: CANONICAL[direct]!, path: `${path}.sharingModel` }); - next = { ...next, sharingModel: CANONICAL[direct] }; - } - const security = next.security; - if (security && typeof security === 'object' && !Array.isArray(security)) { - const nested = (security as Record).sharingModel; - if (typeof nested === 'string' && CANONICAL[nested]) { - emit({ from: nested, to: CANONICAL[nested]!, path: `${path}.security.sharingModel` }); - next = { ...next, security: { ...(security as Record), sharingModel: CANONICAL[nested] } }; - } - } - return next; - }); - }, - fixture: { - before: { - objects: [ - { name: 'crm_deal', label: 'Deal', sharingModel: 'read' }, - { name: 'crm_note', label: 'Note', security: { sharingModel: 'read_write' } }, - ], - }, - after: { - objects: [ - { name: 'crm_deal', label: 'Deal', sharingModel: 'public_read' }, - { name: 'crm_note', label: 'Note', security: { sharingModel: 'public_read_write' } }, - ], - }, - expectedNotices: 2, - }, -}; - -/** - * Sharing-rule recipient type `'role'` → `'position'` (protocol 13, - * ADR-0090 D3). - * - * Applies to both `sharedWith.type` and the owner-rule `ownedBy.type`. The - * removed `'role_and_subordinates'` recipient is NOT converted — its v2 - * replacement (`unit_and_subordinates`) expands a *different* tree (business - * units, not the retired role hierarchy), so it is a step-13 semantic TODO. - * **Retired from the load path** (one-step rename, no alias window). - */ -const sharingRecipientRoleToPosition: MetadataConversion = { - id: 'sharing-recipient-role-to-position', - toMajor: 13, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'sharingRule.sharedWith.type', - summary: "sharing-rule recipient type 'role' → 'position' (ADR-0090 D3)", - apply(stack, emit) { - const renameRecipient = (rule: Record, key: string, path: string) => { - const recipient = rule[key]; - if (!recipient || typeof recipient !== 'object' || Array.isArray(recipient)) return rule; - const dict = recipient as Record; - if (dict.type !== 'role') return rule; - emit({ from: 'role', to: 'position', path: `${path}.${key}.type` }); - return { ...rule, [key]: { ...dict, type: 'position' } }; - }; - return mapCollection(stack, 'sharingRules', (rule, path) => { - let next = renameRecipient(rule, 'sharedWith', path); - next = renameRecipient(next, 'ownedBy', path); - return next; - }); - }, - fixture: { - before: { - sharingRules: [ - { - name: 'share_sales', - type: 'owner', - object: 'crm_deal', - sharedWith: { type: 'role', value: 'sales_mgr' }, - ownedBy: { type: 'role', value: 'sales_rep' }, - }, - ], - }, - after: { - sharingRules: [ - { - name: 'share_sales', - type: 'owner', - object: 'crm_deal', - sharedWith: { type: 'position', value: 'sales_mgr' }, - ownedBy: { type: 'position', value: 'sales_rep' }, - }, - ], - }, - expectedNotices: 2, - }, -}; - -/** - * Book audience gated arm `{ profile }` → `{ permissionSet }` (protocol 14, - * ADR-0090 D2 fallout; shipped in 14.0.0 as a pre-launch one-step rename). - * - * Packages own permission sets but never positions (ADR-0090 D9), so the - * gate is a capability reference. Value carried over 1:1. **Retired from the - * load path** — the zod union rejects `{ profile }` at parse. Protocol 14 is - * below `MIGRATION_SUPPORT_FLOOR`, so no migration step carries this - * conversion any more; this entry now survives only as the stored-row replay - * (`applyConversionsToStoredItem`, `conversions/stored.ts`) the one-step ship - * skipped, which is not floor-scoped. - */ -const bookAudienceProfileToPermissionSet: MetadataConversion = { - id: 'book-audience-profile-to-permission-set', - toMajor: 14, - retiredFromLoadPath: true, - retiredAfter: '15.0.0', - surface: 'book.audience', - summary: "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)", - apply(stack, emit) { - return mapCollection(stack, 'books', (book, path) => { - const audience = book.audience; - if (!audience || typeof audience !== 'object' || Array.isArray(audience)) return book; - const dict = audience as Record; - if (typeof dict.profile !== 'string' || dict.permissionSet != null) return book; - emit({ from: 'profile', to: 'permissionSet', path: `${path}.audience.permissionSet` }); - const { profile, ...rest } = dict; - return { ...book, audience: { ...rest, permissionSet: profile } }; - }); - }, - fixture: { - before: { - books: [{ name: 'crm_admin_guide', audience: { profile: 'crm_admin' } }], - }, - after: { - books: [{ name: 'crm_admin_guide', audience: { permissionSet: 'crm_admin' } }], - }, - expectedNotices: 1, - }, -}; - /** Rename a visibility alias key on a dict, emitting with the given path. */ function renameVisibilityAlias( dict: Record, @@ -736,8 +500,9 @@ const pageComponentVisibilityToVisibleWhen: MetadataConversion = { * canonical key by a schema transform and dropped from the parsed output. They * are now removed from the spec outright, and each schema TOMBSTONES its key * with a fix-it error (`retiredKey`, `shared/retired-key.ts`) so the loader - * cannot quietly accept it — the same shape as - * `object-compactLayout-to-highlightFields` above. + * cannot quietly accept it — the same shape the retired + * `object-compactLayout-to-highlightFields` conversion had (a ≤16.x kit, + * removed with its region on #25767). * * All three are therefore `retiredFromLoadPath: true` from the day they land: * there is no alias window, deliberately. What the entries buy is the two @@ -748,7 +513,7 @@ const pageComponentVisibilityToVisibleWhen: MetadataConversion = { * tool are projections of that record, composed across however many majors * the consumer is jumping; * - the step-17 chain entry references them by id, so - * `os migrate meta --from 16` REWRITES the consumer's source mechanically + * `os migrate meta --from 16` (a step the support floor has since retired, #25767) REWRITES the consumer's source mechanically * instead of asking them to hand-edit. * * The tombstone error is the backstop for someone who did neither, and it says @@ -788,82 +553,6 @@ function mapObjectFieldsKey(stack: Dict, collection: string, from: string, to: s }); } -/** - * Action `execute` → `target` (protocol 17, #3713 / #3742 / #3855). - * - * A pure key rename — the value (a handler/flow/URL ref) is unchanged. Actions - * appear both top-level and nested under their object, so both are walked. - */ -const actionExecuteToTarget: MetadataConversion = { - id: 'action-execute-to-target', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'action.execute', - summary: "action key 'execute' → 'target' (the deprecated handler alias; the spec and the renderer had resolved the pair in opposite directions, so one key now names the handler)", - apply(stack, emit) { - const renameOn = (action: Dict, path: string): Dict => { - const renamed = renameKey(action, 'execute', 'target'); - if (!renamed) return action; - emit({ from: 'execute', to: 'target', path: `${path}.target` }); - return renamed; - }; - const withTopLevel = mapCollection(stack, 'actions', renameOn); - return mapCollection(withTopLevel, 'objects', (obj, path) => { - const nested = mapCollection(obj, 'actions', (action, actionPath) => - renameOn(action, `${path}.${actionPath}`), - ); - return nested; - }); - }, - fixture: { - before: { - actions: [{ name: 'convert', label: 'Convert', type: 'script', execute: 'convertHandler' }], - }, - after: { - actions: [{ name: 'convert', label: 'Convert', type: 'script', target: 'convertHandler' }], - }, - expectedNotices: 1, - }, -}; - -/** - * Field `conditionalRequired` → `requiredWhen` (protocol 17, #3754 / #3855). - * - * A pure key rename — the value (a CEL predicate, bare or enveloped) is - * unchanged. Covers object fields and object-extension fields: the same - * `FieldSchema`, so the same alias. - */ -const fieldConditionalRequiredToRequiredWhen: MetadataConversion = { - id: 'field-conditionalRequired-to-requiredWhen', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'field.conditionalRequired', - summary: "field key 'conditionalRequired' → 'requiredWhen' (the deprecated predicate alias, folded into the canonical key so no reader picks its own precedence)", - apply(stack, emit) { - const withObjects = mapObjectFieldsKey(stack, 'objects', 'conditionalRequired', 'requiredWhen', emit); - return mapObjectFieldsKey(withObjects, 'objectExtensions', 'conditionalRequired', 'requiredWhen', emit); - }, - fixture: { - before: { - objects: [{ - name: 'crm_task', - label: 'Task', - fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, - }], - }, - after: { - objects: [{ - name: 'crm_task', - label: 'Task', - fields: { due_date: { type: 'date', requiredWhen: 'record.stage == "closed"' } }, - }], - }, - expectedNotices: 1, - }, -}; - /* * ABSORBED — `agent-knowledge-topics-to-sources` (protocol 17, #3855). The * rename lived inside the same unreleased major that now REMOVES the whole @@ -874,58 +563,6 @@ const fieldConditionalRequiredToRequiredWhen: MetadataConversion = { * prescription covers the historical `topics` spelling. */ -/** - * Agent `tools` → dropped (protocol 17, #3894 / #3820). - * - * NOT a rename — there is no key to move the value to. ADR-0064 says an - * agent's tool set is exactly the union of its surface-compatible skills' - * tools, and `agent.tools[]` was the seam that broke it (it resolved names - * against the FULL registry with no surface check). Each entry has to become - * a reference inside a SKILL, which needs a human decision about which skill - * — so this conversion drops the dead key and emits one notice per agent - * naming what was lost, rather than guessing a destination. - * - * Dropping is safe: the cloud runtime stopped reading the field entirely - * (cloud#910), so by protocol 17 it contributes nothing at load time. What - * the notice preserves is the AUTHOR's knowledge of which tools they meant. - */ -const agentToolsToSkills: MetadataConversion = { - id: 'agent-tools-to-skills', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'agent.tools', - summary: "agent key 'tools' removed — declare capability in a skill (ADR-0064: an agent's tools are exactly its skills' tools, and this inline slot resolved names against the whole registry with no surface check)", - apply(stack, emit) { - return mapCollection(stack, 'agents', (agent, path) => { - if (!('tools' in agent) || agent.tools == null) return agent; - const next: Dict = { ...agent }; - delete next.tools; - // The notice carries `tools → skills` at the agent's path: the author - // sees WHICH agent lost inline references and where the capability has - // to be re-declared. The tool names themselves stay in their git - // history, which is where a judgement call should be read from. - emit({ from: 'tools', to: 'skills', path: `${path}.skills` }); - return next; - }); - }, - fixture: { - before: { - agents: [ - { - name: 'support_bot', - skills: ['case_management'], - tools: [{ type: 'action', name: 'create_ticket' }], - }, - ], - }, - after: { - agents: [{ name: 'support_bot', skills: ['case_management'] }], - }, - expectedNotices: 1, - }, -}; - /** * Sharing-rule `accessLevel: 'full'` → `'edit'` (protocol 17, #3865). * @@ -956,7 +593,7 @@ const agentToolsToSkills: MetadataConversion = { * a sharing rule (`os validate` / `os build` / `os lint`). Stored rows keep * healing: stored-row rehydration (`applyConversionsToStoredItem`) replays the * full chain, this entry included, and so do the artifact-ingestion door - * (inside its window) and `os migrate meta --from 16`. + * (inside its window) and `os migrate meta --from 16` (a step the support floor has since retired, #25767). */ const sharingRuleAccessLevelFullToEdit: MetadataConversion = { id: 'sharing-rule-access-level-full-to-edit', @@ -1040,7 +677,7 @@ function renameFlowConfigAliases( * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16`. + * `os migrate meta --from 16` (a step the support floor has since retired, #25767). */ const flowNodeCrudObjectAlias: MetadataConversion = { id: 'flow-node-crud-object-alias', @@ -1173,7 +810,7 @@ function liftNotifySourceShape(stack: Dict, emit: Emit): Dict { * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16`. + * `os migrate meta --from 16` (a step the support floor has since retired, #25767). */ const flowNodeNotifyConfigAliases: MetadataConversion = { id: 'flow-node-notify-config-aliases', @@ -1409,7 +1046,7 @@ function liftWaitEventConfig(stack: Dict, emit: Emit): Dict { * Stored flows are the `includeRetired` seams: the automation engine's flow * rehydration, stored-row rehydration (`applyConversionsToStoredItem`), the * metadata save door's flow fallback, the artifact-ingestion door inside its - * window, and `os migrate meta --from 16` (a step-17 member). + * window, and `os migrate meta --from 16` (a step the support floor has since retired, #25767) (a step-17 member). */ const flowNodeWaitEventConfigLift: MetadataConversion = { id: 'flow-node-wait-event-config-lift', @@ -1498,7 +1135,7 @@ const flowNodeWaitEventConfigLift: MetadataConversion = { * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16`. + * `os migrate meta --from 16` (a step the support floor has since retired, #25767). */ const flowNodeMapFlowAlias: MetadataConversion = { id: 'flow-node-map-flow-alias', @@ -1567,7 +1204,7 @@ const flowNodeMapFlowAlias: MetadataConversion = { * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16`. + * `os migrate meta --from 16` (a step the support floor has since retired, #25767). */ const flowNodeSubflowFlowAlias: MetadataConversion = { id: 'flow-node-subflow-flow-alias', @@ -1832,7 +1469,7 @@ const flowNodeConnectorConfigLift: MetadataConversion = { * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16`. + * `os migrate meta --from 16` (a step the support floor has since retired, #25767). * * The fixture below carried `actionType: 'invoke_function'` through both sides * until #4343 retired that key — an end state protocol 17 no longer reaches, so @@ -1900,4228 +1537,513 @@ const flowNodeScriptConfigAliases: MetadataConversion = { }; /** - * App dead authoring keys removed (protocol 17, #4001 app step; 2026-06 - * AppSchema liveness audit). - * - * Seven keys were authorable and never read by any consumer in framework or - * objectui: `version` (an app is versioned by its package's - * `manifest.version`), `aria` (no renderer read app-level ARIA), `objects` / - * `apis` (the spec's own "config file convenience" — objects register via - * `defineStack`, and the chatbot derives an app's object list from its nav - * items), `sharing` / `embed` (declared-but-unenforced security surface — the - * only live path is `FormView.sharing`; ADR-0049 class), and - * `mobileNavigation` (fully unimplemented — even `packages/mobile` ignored - * it). Pure lossless deletes: none ever had a runtime effect. - * - * `retiredFromLoadPath`: the schema tombstones each key (`retiredKey`, tsc - * `never` + a parse-time prescription), same posture as its step-17 siblings. - * - * ## Extended by #4509 — the two context-selector keys - * - * `contextSelectors[].includeAll` and `contextSelectors[].placement` join the - * same conversion rather than opening a second `app` entry: both target major - * 17, both are pure deletes on the same collection, and a separate entry would - * have to keep its fixture disjoint from this one's for no gain. - * - * They differ from the seven above in why they went out. Both carried schema - * defaults, which the liveness advisory lint cannot warn on — a default - * materialises at parse time, so an authored value is indistinguishable from a - * supplied one. Removal was the only channel that could reach an author. - * - * `includeAll` was the sharper of the two: not unread but deliberately - * DISOBEYED. Context selectors are mandatory-scope, so the shell never rendered - * an "All" row — on Studio's package selector an All row would undo the - * selector's own `filter` and list the platform's system/cloud kernel packages. - * `STUDIO_APP` authored `includeAll: true` against a renderer that ignored it, - * which is what a flag reading alive while doing nothing looks like from the - * inside. + * ⛔ WITHDRAWN — there is deliberately NO `field-required-notnull-explicit` + * conversion in this registry, and re-adding one is the mistake this comment + * exists to stop (#16693; maintainer ruling, decision batch #85, 2026-09-08, + * option A). + * + * It read `required: true` and wrote `storage: { notNull: true }` beside it, + * on the argument that pre-17 `field.required` bound three meanings to one + * knob — the write contract, the physical NOT NULL, and the drift + * expectation — so writing the constraint down preserved an old source's + * full meaning. ADR-0113 split those axes, and two facts retired the argument: + * + * 1. Adding `storage.notNull` wherever `required: true` appears IS the + * implication ADR-0113 abolished. `required` is the write-time contract; + * `storage.notNull` alone binds the column. A conversion cannot be what + * decides a column constraint — that is the author's explicit act. + * 2. `retiredFromLoadPath: true` did NOT hold the transform to + * `os migrate meta`, whatever this entry's docblock used to claim. The + * artifact-ingestion door replays the whole chain with `includeRetired: + * true` (`applyArtifactForwardConversions`, `@objectstack/metadata-core`), + * keyed off the artifact's declared `engines.protocol` FLOOR rather than + * its age — and `^17.0.0` is the range `create-objectstack` stamps. So + * every scaffolded app was handed NOT NULL columns it never asked for, plus + * a boot warning telling its author to write the same tightening into the + * source. On a populated database that instruction is a `tighten_not_null` + * / `severity: error` / `category: destructive` migration, prescribed as + * the remedy for a deprecation notice. + * + * No migration is owed to anyone: genuinely pre-ADR-0113 artifacts are not + * measured to exist, and an app that wants NOT NULL columns declares + * `storage.notNull` deliberately. Existing columns are left exactly as they + * are. + * + * ⚠️ Fact 2 is about the MECHANISM, not about this entry, and it outlived the + * entry: `retiredFromLoadPath` still holds nothing back at three runtime seams + * (ADR-0087's 2026-09-13 addendum names all three). Before setting that flag on a DEFAULT FLIP — as opposed to a + * lossless delete or a rename — read that addendum, because the flag does not mean + * what its name says: its own docblock (`RetiredConversionState` in `types.ts`) has + * stated the authoring-only reach since commit 29dd1a6dd. + */ + +/** + * The #3896 close-out sweep, part 2 (protocol 17): the remaining inert + * authoring keys leave the surface, one conversion per metadata type. All are + * pure lossless deletes — none of these keys ever had a runtime effect to + * lose — and all are retired from the load path: each schema tombstones its + * keys with the prescription (`retiredKey`), so the loader rejects loudly and + * only `os migrate meta` lists the mechanical edits for existing sources. */ -const appDeadAuthoringKeysRemoved: MetadataConversion = { - id: 'app-dead-authoring-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: - 'app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / ' - + 'app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / ' - + 'app.homePageId / app.areas.order', - summary: "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits found each one unread or wrongly encoded; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, homePageId WAS read by objectui's console before v17 but encoded the landing page as an ID cross-reference that silently fell back when it dangled — the landing page is the first nav item (the first retirement record said nothing read it, a premise since corrected; the retirement stands), and no renderer ever sorted areas)", - apply(stack, emit) { - const RETIRED = [ - 'version', 'aria', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation', - 'homePageId', - ]; - const RETIRED_SELECTOR = ['includeAll', 'placement']; - return mapCollection(stack, 'apps', (app, path) => { - const next = stripKeys(app, RETIRED, emit, path); - // `contextSelectors` and `areas` are ARRAYS one level down, so stripKeys - // (top-level only) cannot reach them. Drill in and copy-on-write at both - // levels, so an app with nothing to strip keeps its identity for change - // detection. - const stripInArray = (key: string, retired: string[], src: Record) => { - const arr = src[key]; - if (!Array.isArray(arr)) return src; - let touched = false; - const mapped = arr.map((el, i) => { - if (!el || typeof el !== 'object' || Array.isArray(el)) return el; - const stripped = stripKeys( - el as Record, - retired, - emit, - `${path}.${key}[${i}]`, - ); - if (stripped !== el) touched = true; - return stripped; - }); - return touched ? { ...src, [key]: mapped } : src; - }; - return stripInArray('areas', ['order'], stripInArray('contextSelectors', RETIRED_SELECTOR, next)); - }); - }, - fixture: { - before: { - apps: [{ - name: 'portal', - label: 'Portal', - version: '1.0.0', - sharing: { enabled: true }, - embed: { enabled: true }, - mobileNavigation: { mode: 'bottom_nav' }, - homePageId: 'nav_home', - contextSelectors: [{ - id: 'active_package', - label: 'Package', - optionsSource: { endpoint: '/api/v1/packages', valueKey: 'id', labelKey: 'name' }, - includeAll: true, - placement: 'sidebar_header', - }], - areas: [{ id: 'area_sales', label: 'Sales', order: 1, navigation: [] }], - navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], - }], - }, - after: { - apps: [{ - name: 'portal', - label: 'Portal', - contextSelectors: [{ - id: 'active_package', - label: 'Package', - optionsSource: { endpoint: '/api/v1/packages', valueKey: 'id', labelKey: 'name' }, - }], - areas: [{ id: 'area_sales', label: 'Sales', navigation: [] }], - navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], - }], - }, - // Eight notices: five top-level keys (`version`, `sharing`, `embed`, - // `mobileNavigation`, `homePageId`), the two on the single context - // selector, and `order` on the single area. - expectedNotices: 8, - }, +const stripKeys = ( + item: Record, + keys: readonly string[], + emit: (n: { from: string; to: string; path: string }) => void, + path: string, +): Record => { + let touched = false; + const next: Record = { ...item }; + for (const key of keys) { + if (!(key in next)) continue; + delete next[key]; + emit({ from: key, to: '(removed)', path: `${path}.${key}` }); + touched = true; + } + return touched ? next : item; }; /** - * `app.areas[].visible` / `app.areas[].requiredPermissions` removed - * (protocol 17, #4651, ADR-0049). - * - * Deliberately its own conversion rather than two more keys on - * `app-dead-authoring-keys-removed` above. That entry's summary is a list of - * inert authoring keys; these two are a **security** finding, and the summary - * string is what `spec-changes.json`, the generated upgrade guide and the - * `spec_changes` MCP tool serve to an upgrading consumer. Folded into the - * catch-all, "a gate that never gated has been removed" would arrive buried in - * a sentence about `version` and `mobileNavigation` — and it is the one line of - * this release an author with a gated area has to read. - * - * The defect: `filterAppForUser` (`packages/rest/src/rest-server.ts`) checks the - * APP's `requiredPermissions`, then walks ONLY `item.navigation` — it never - * reads `item.areas` — while the client renders every area in the switcher. An - * area declaring `requiredPermissions: ['sales.admin']` parsed, stored, served - * and rendered for every user. Fail open, on the surface whose per-ITEM and - * per-APP siblings of the same name ARE enforced (ADR-0078 false compliance). - * - * Stripping is lossless in the only sense that matters: the keys changed no - * outcome, so metadata behaves identically with or without them. What the - * author loses is the BELIEF that the area was gated — which is the point of - * the removal, and why the strict schema's `guidance` prescription (see - * `ui/app.zod.ts`) names the two layers that do enforce instead of just saying - * "removed". - * - * `retiredFromLoadPath`: the strict schema refuses the keys outright, so there - * is no alias window; the entry exists so `spec-changes.json` carries the - * removal and `os migrate meta --from 16` can list the mechanical edits for authored sources. + * All conversions, keyed by the protocol major that introduced the canonical + * shape. Newest majors last; ordering within a major is application order. */ -const appAreaFailOpenGatesRemoved: MetadataConversion = { - id: 'app-area-fail-open-gates-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'app.areas.visible / app.areas.requiredPermissions', - summary: "navigation-area keys 'visible'/'requiredPermissions' removed (ADR-0049 — FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user, while the identically named keys on a navigation ITEM and on the APP are enforced; gate the items inside the area, or gate the app)", - apply(stack, emit) { - const RETIRED_AREA_GATES = ['visible', 'requiredPermissions']; - return mapCollection(stack, 'apps', (app, path) => { - // `areas` is an ARRAY one level down, so `stripKeys` (top-level only) - // cannot reach it. Copy-on-write at both levels, so an app with nothing - // to strip keeps its identity for change detection. - const areas = app.areas; - if (!Array.isArray(areas)) return app; - let touched = false; - const mapped = areas.map((el, i) => { - if (!isDict(el)) return el; - const stripped = stripKeys(el, RETIRED_AREA_GATES, emit, `${path}.areas[${i}]`); - if (stripped !== el) touched = true; - return stripped; - }); - return touched ? { ...app, areas: mapped } : app; - }); - }, - fixture: { - // DISJOINT from `app-dead-authoring-keys-removed`'s fixture above and from - // every other entry: this app carries none of the keys another conversion - // strips, and that fixture's area carries neither gate — so each replays - // through the whole table hitting only its own entry. - before: { - apps: [{ - name: 'sales_portal', - label: 'Sales Portal', - areas: [ - { - id: 'area_admin', - label: 'Admin', - visible: "'sales_admin' in current_user.positions", - requiredPermissions: ['sales.admin'], - navigation: [{ id: 'nav_forecast', label: 'Forecast', type: 'object', objectName: 'forecast' }], - }, - // Untouched on purpose: the per-ITEM gate inside this area is the - // ENFORCED layer the prescription points at, and it spells the two - // key names identically. A sweep by key name alone would delete the - // working gate along with the fake one. - { - id: 'area_sales', - label: 'Sales', - navigation: [{ - id: 'nav_leads', label: 'Leads', type: 'object', objectName: 'lead', - visible: "'sales' in current_user.positions", - requiredPermissions: ['sales.access'], - }], - }, - ], - }], - }, - after: { - apps: [{ - name: 'sales_portal', - label: 'Sales Portal', - areas: [ - { - id: 'area_admin', - label: 'Admin', - navigation: [{ id: 'nav_forecast', label: 'Forecast', type: 'object', objectName: 'forecast' }], - }, - { - id: 'area_sales', - label: 'Sales', - navigation: [{ - id: 'nav_leads', label: 'Leads', type: 'object', objectName: 'lead', - visible: "'sales' in current_user.positions", - requiredPermissions: ['sales.access'], - }], - }, - ], - }], - }, - // Two notices: both gates on the ONE area that declared them. The second - // area and the nav item inside it must produce none. - expectedNotices: 2, - }, -}; /** - * RLS-policy `priority` removed (protocol 17, #3896 security audit). - * - * A pure DELETE with no rename target, because the promised semantics never - * existed: no outcome depends on an order. Applicable policies OR-combine on - * reads (any match allows access), and a write's check is chosen once per - * operation across the applicable policies, then OR-combined - * (`RowLevelSecurityPolicySchema.check`), so there is no conflict for a - * priority to resolve and evaluation order cannot change an outcome. The 2026-07-30 security-subset - * liveness re-verification closed the call graph — collection site, projection - * round-trip, compiler — and found NO reader, ever. Dropping the key is - * therefore strictly lossless: outcomes are identical with or without it. + * `datasource.driver: 'mongo'` → `'mongodb'` (protocol 17, commit e2798fab7; + * retired from the load path at 18, #23543 F1). * - * `retiredFromLoadPath`: the schema tombstones the key (`retiredKey`, tsc - * `never` + a parse-time prescription), same posture as its step-17 siblings. + * ## Why a stored value has to move at all + * + * `mongo` and `mongodb` were both accepted spellings from #4410 through 17 — + * this conversion never rescued a broken boot, and a stored row it has not + * reached still resolves the same contract and builds the same driver. What moved is the + * CANONICAL id: commit e2798fab7 landed the ruling that renamed it to `mongodb`, the spelling both boot + * hosts, the driver package (`@objectstack/driver-mongodb`) and every URL scheme + * already used, so that the id which selects a driver and the id which selects + * its config contract are one string with no mapping layer between them. + * + * That rename is visible in data because the canonical id is PUBLISHED as + * `DRIVER_CATALOG.id` (`@objectstack/service-datasource`), documented as "used + * as `datasource.driver`" — it is literally what Studio's connection form writes + * into a datasource row. After the rename the form emits `mongodb`, while every + * row written before it carries `mongo`. Left alone, one deployment's datasource + * list holds two spellings of one driver, and any surface that matches a stored + * `driver` against the published catalog id (a form pre-selecting the current + * driver, a grouped list, an equality filter) silently fails to match the older + * rows. So the stored value converges here rather than each reader learning to + * accept both. + * + * ## Why the data repair is D2 + * + * There is a concrete stored value with a lossless, behaviour-preserving + * rewrite, which is the D2 test exactly. `mongo` and `mongodb` resolve to the + * same contract and build the same driver, before and after, so replaying this + * cannot change where any data lives — contrast the retired + * `datasource-config-driver-key-aliases` (#25767), whose scope guard existed + * because rewriting a sqlite `path:` WOULD have moved a database. + * + * Losslessness decides only that the data repair is D2. It does not decide + * whether the family ALSO owes a D3 entry — every retirement family does + * (`SemanticMigration`, `migrations/types.ts`). The rename into protocol 17 + * shipped before that rule and was not back-filled; the value's retirement at + * 18 is a family of its own, and its D3 entry is + * `datasource-driver-mongo-alias-retired`. + * + * ## Retired from the load path at 18 (#23543, ruling F1 A) + * + * Through 17 it stayed live because `mongo` was still a legal alias, so there + * was no refusal for a retirement to hand the author to (the verdict #23487 + * recorded). The ruling retired the value: `mongo` left the selection face, and + * `DriverType` refuses an authored `datasource.driver: 'mongo'` by name with + * the prescription naming `mongodb` (`DriverVocabularyEntry.retiredAliases`, + * `data/driver/config-registry.zod.ts`). So the authoring funnel no longer + * replays this entry, and an author is taught the canonical id instead of + * being rewritten. Stored rows are the `includeRetired` seams: stored-row + * rehydration (`applyConversionsToStoredItem`) and the artifact door still + * rewrite them, and `os migrate meta --from 16` (a step the support floor has since retired, #25767) lists the edit for existing + * sources, as step 17's mechanical member. The spelling keeps resolving the + * mongodb CONTRACT, which is what let the retired + * `datasource-config-driver-key-aliases` — ordered ahead of this entry while + * it existed (#25767) — reach a stored `driver: 'mongo'` row's legacy config + * keys. That D3 + * entry names what the chain cannot reach: an environment variable, a REST + * caller, a script. */ -const permissionRlsPriorityRemoved: MetadataConversion = { - id: 'permission-rls-priority-removed', +const datasourceDriverMongoToMongodb: MetadataConversion = { + id: 'datasource-driver-mongo-to-mongodb', toMajor: 17, retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'permission.rowLevelSecurity.priority', - summary: "RLS-policy key 'priority' removed (a security audit found no reader: policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome)", + retiredAfter: '17.7.0', + surface: 'datasource.driver', + summary: + "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver " + + 'package and the published DRIVER_CATALOG already used, so the id that selects a driver ' + + 'and the id that selects its config contract are one string with no mapping between them', apply(stack, emit) { - return mapCollection(stack, 'permissions', (ps, path) => { - const rls = (ps as { rowLevelSecurity?: unknown }).rowLevelSecurity; - if (!Array.isArray(rls)) return ps; - let touched = false; - const next = rls.map((policy, i) => { - if (!isDict(policy) || !('priority' in policy)) return policy; - const { priority: _dropped, ...rest } = policy; - emit({ from: 'priority', to: '(removed)', path: `${path}.rowLevelSecurity[${i}].priority` }); - touched = true; - return rest; - }); - return touched ? { ...ps, rowLevelSecurity: next } : ps; + return mapDatasources(stack, (ds, path) => { + // Only the exact legacy canon, trimmed and lower-cased the same way + // `resolveDriverId` reads it. `mongodb` is already canonical, and any other + // spelling (a plugin driver, a typo) is not this conversion's business. + if (typeof ds.driver !== 'string' || ds.driver.trim().toLowerCase() !== 'mongo') return ds; + emit({ from: 'mongo', to: 'mongodb', path: `${path}.driver` }); + return { ...ds, driver: 'mongodb' }; }); }, fixture: { before: { - permissions: [{ - name: 'contributor', - label: 'Contributor', - rowLevelSecurity: [{ - name: 'own_tasks', - object: 'crm_task', - operation: 'select', - using: 'assignee == current_user.email', - enabled: true, - priority: 10, - }], - }], + datasources: [ + { name: 'events', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/events' } }, + // Already canonical — untouched, and emits nothing. + { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, + // A different driver whose id merely CONTAINS the string: never rewritten. + { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, + ], }, after: { - permissions: [{ - name: 'contributor', - label: 'Contributor', - rowLevelSecurity: [{ - name: 'own_tasks', - object: 'crm_task', - operation: 'select', - using: 'assignee == current_user.email', - enabled: true, - }], - }], + datasources: [ + { name: 'events', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/events' } }, + { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, + { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, + ], }, expectedNotices: 1, }, }; /** - * Tool inert authoring keys removed (protocol 17, #3896 audit close-out). - * - * `category`, `permissions`, `active` and `builtIn` were authorable and inert — - * none is part of `AIToolDefinition` and no execution path read them. Two were - * misleading in the dangerous direction: `permissions` promised a capability - * gate on invocation that nothing enforced (a tool "requiring" capabilities ran - * for everyone), and `active: false` read as "withdrawn" while the tool kept - * reaching the LLM tool set and `POST /ai/tools/:name/execute` kept running it. - * A pure lossless delete: dropping the keys changes no runtime behaviour, - * because they never had any. - * - * `retiredFromLoadPath`: ToolSchema is `.strict()` and rejects each key with - * its prescription (`TOOL_RETIRED_KEY_GUIDANCE`), the #3715 pattern. + * The retry policy converges to one declaration (protocol 17, #4661 — the + * #4535 C8 dual-source cluster). + * + * `@objectstack/spec/automation` and `@objectstack/spec/system` both exported a + * `RetryPolicy` / `RetryPolicySchema`, resolving to DIFFERENT declarations — so + * which shape a consumer got depended only on the import path (#4411). They + * were never two concepts: `try_catch`'s `retry` region and `job.retryPolicy` + * both compute `delay = base * multiplier^(retry-1)`, and the two executors + * implemented that identical formula. What differed was cosmetic and + * accidental, and this conversion pays for both halves of the merge: + * + * 1. **The base delay had two spellings.** automation said `retryDelayMs`, + * system said `backoffMs`. `backoffMs` wins — it is what the *enforced* + * retry policies already spell it (`job.retryPolicy` and `hook.retryPolicy`; + * see the `datasource-inert-blocks-removed` note above, which leans on + * exactly that distinction), so the platform drops from three spellings to + * two rather than four. `retryDelayMs` is tombstoned (`retiredKey`) — NOT + * deleted — because two of the three owning shapes are not `.strict()`: a + * plain deletion would have Zod silently swallow the authored number and + * fall back to the 1000ms default, which is the quiet-failure class + * ADR-0049 removes. + * + * 2. **The defaults were opposite, and no gate can see a default.** Pre-17, + * `job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` + * while the automation shape defaulted 0 / 1. The merged declaration takes + * 0 / 1 (retry is opt-in: a retry replays whatever the attempt already did, + * and an implicit replay of side effects is the failure mode hardest to + * catch in tests — the same reading already recorded for flow-level retry + * in `flow-retry-max-retries-required`, #4247). Taken alone that would + * SILENTLY stop existing jobs from retrying, and the authorable-surface + * gate would never notice: it compares key sets, and a default is not a key. + * So this conversion writes the pre-17 numbers into every existing + * `job.retryPolicy` that omitted them. Deployed stacks keep their exact + * behaviour; only a newly authored omission means "no retry". + * + * Jobs with no `retryPolicy` block at all are left alone — absence already + * meant a single attempt on both sides of the change. + * + * ## The ONE further surface this entry grew to cover (#4964 / #4962) + * + * The convergence above was driven by the dual-source instrument, whose + * question is "how many declarations share one exported NAME?". Two further + * encodings of the identical policy were invisible to it because they were + * anonymous inline `z.object`s with no exported name at all — and after a + * convergence lands, a surviving dialect reads as reviewed-and-kept rather + * than missed. One of the two is still a surface; the other went with its + * layer: + * + * - **`flow.errorHandling`** (#4964) spelled the base delay `retryDelayMs`; + * every other key, bound and default already matched. Step 0 below renames + * it, so the ONE authorable casualty of the whole convergence is still just + * that word — now retired everywhere it was ever legal rather than only on + * the two shapes #4661's instrument could see. + * - **`ETLPipeline.retry`** (#4962) spelled the count `maxAttempts` and + * defaulted it to 3. It got **no step here, deliberately** — and never will. + * An ETL pipeline was not a `defineStack` collection and `etl.zod.ts` had no + * parse site in objectstack / objectui / cloud (批 12's measurement), so + * there was no stored or authored document a walker could reach: a branch + * for it would have been dead code claiming migration coverage that does not + * exist, which is the ADR-0049 failure this registry is supposed to prevent, + * not commit. #6414 then retired the whole L2 layer, and #4962's own entry + * (`etl-retry-converged-onto-retry-policy`) was ABSORBED into + * `etl-pipeline-layer-retired` inside the same unreleased major. There is no + * `maxAttempts` tombstone left to carry the rename or the default change: it + * went with the shape that carried it, which is strictly stronger, because + * no `retry` block survives to author the key into. `tsc` (TS2724/TS2305 on + * the removed ETL names) is the whole channel. That is also why the ETL + * default flip 3 → 0 never needed a materialization step while the job one + * did: nothing was ever deployed under the old reading. + * + * ## Retired from the load path at 18 (#23543, ruling F2 A) + * + * The base-delay rename never needed the load path: every surface that carried + * `retryDelayMs` refuses it with the rename to `backoffMs` (the + * `RetryPolicySchema` tombstone, which a `try_catch` node's `config.retry` is, + * and `flow.errorHandling`'s own). It stayed for the job half alone (the + * verdict #23487 recorded): retired, the authoring funnel would stop writing + * `maxRetries: 3` into a source block that omits it, and such a job would + * silently drop from three retries to none — a default flip no gate can see. + * The ruling made that drop loud instead: the job parse refuses a + * `retryPolicy` whose `maxRetries` is omitted or 0, naming the fix + * (`JOB_RETRY_POLICY_NO_ATTEMPTS`, `system/job.zod.ts`), so no authored job + * changes its retry count without its author being told. An omitted + * `backoffMultiplier` is not refused and now keeps its declared default (1): + * it shapes the delay curve, not whether the job retries. + * + * The authoring funnel (`defineStack`, `os validate` / `os build` / `os lint`) + * no longer replays it. Stored documents are the `includeRetired` seams — + * stored-row rehydration (`applyConversionsToStoredItem`), the automation + * engine's flow rehydration, the metadata save door's flow fallback, the + * artifact-ingestion door inside its window — and `os migrate meta --from 16` (a step the support floor has since retired, #25767) + * (a step-17 member, carried by the D3 entry `job-retry-policy-without-attempts-refused`), + * so a job stored or written before 17 keeps the three retries it ran with. */ -const toolInertAuthoringKeysRemoved: MetadataConversion = { - id: 'tool-inert-authoring-keys-removed', +const retryPolicyConverged: MetadataConversion = { + id: 'retry-policy-converged', toMajor: 17, retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'tool.category / tool.permissions / tool.active / tool.builtIn', - summary: "tool keys 'category'/'permissions'/'active'/'builtIn' removed (authorable and inert, so removed under ADR-0049 enforce-or-remove; permissions gated nothing, active:false withdrew nothing)", + retiredAfter: '17.7.0', + surface: 'flow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier', + summary: + "retry policy unified across job.retryPolicy, try_catch retry and flow.errorHandling: base delay 'retryDelayMs' → 'backoffMs', " + + "and the pre-17 job defaults (maxRetries 3, backoffMultiplier 2) written out explicitly now that the merged default is 0 / 1: two declarations that differed only by accident became one, and retry is opt-in because a retry replays whatever the attempt already did", apply(stack, emit) { - const RETIRED = ['category', 'permissions', 'active', 'builtIn'] as const; - return mapCollection(stack, 'tools', (tool, path) => { - let touched = false; - const next: Record = { ...tool }; - for (const key of RETIRED) { - if (!(key in next)) continue; - delete next[key]; - emit({ from: key, to: '(removed)', path: `${path}.${key}` }); - touched = true; - } - return touched ? next : tool; + // ── 0. flows: errorHandling.retryDelayMs → errorHandling.backoffMs ─ + // + // The FLOW-LEVEL retry policy (#4964). Same rename as the try_catch node + // below and for the same reason, reached one level up: `errorHandling` + // hangs off the flow document, not off a node, so `mapFlowNodes` walks + // straight past it — which is a small echo of why this divergence survived + // #4661 at all. `renameKey` leaves an already-canonical `backoffMs` alone; + // when BOTH spellings are present it now (#4923) deletes `retryDelayMs` + // only if it repeats the same number, and otherwise keeps both rather than + // guessing which delay the author meant — the strict block then rejects + // naming both. This entry relied on the shared helper's semantics rather + // than open-coding its own precisely so it would move with that ruling, + // and it did: no change was needed here. + const withErrorHandling = mapCollection(stack, 'flows', (flow, path) => { + const eh = flow.errorHandling; + if (!eh || typeof eh !== 'object' || Array.isArray(eh)) return flow; + const renamed = renameKey(eh as Record, 'retryDelayMs', 'backoffMs'); + if (renamed === null) return flow; + emit({ from: 'retryDelayMs', to: 'backoffMs', path: `${path}.errorHandling.backoffMs` }); + return { ...flow, errorHandling: renamed }; }); - }, - fixture: { - before: { - tools: [{ - name: 'create_case', - label: 'Create Case', - description: 'Creates a support case', - parameters: { type: 'object' }, - category: 'action', - permissions: ['case.create'], - active: true, - builtIn: false, - }], - }, - after: { - tools: [{ - name: 'create_case', - label: 'Create Case', - description: 'Creates a support case', - parameters: { type: 'object' }, - }], - }, - expectedNotices: 4, - }, -}; -/** - * ⛔ WITHDRAWN — there is deliberately NO `field-required-notnull-explicit` - * conversion in this registry, and re-adding one is the mistake this comment - * exists to stop (#16693; maintainer ruling, decision batch #85, 2026-09-08, - * option A). - * - * It read `required: true` and wrote `storage: { notNull: true }` beside it, - * on the argument that pre-17 `field.required` bound three meanings to one - * knob — the write contract, the physical NOT NULL, and the drift - * expectation — so writing the constraint down preserved an old source's - * full meaning. ADR-0113 split those axes, and two facts retired the argument: - * - * 1. Adding `storage.notNull` wherever `required: true` appears IS the - * implication ADR-0113 abolished. `required` is the write-time contract; - * `storage.notNull` alone binds the column. A conversion cannot be what - * decides a column constraint — that is the author's explicit act. - * 2. `retiredFromLoadPath: true` did NOT hold the transform to - * `os migrate meta`, whatever this entry's docblock used to claim. The - * artifact-ingestion door replays the whole chain with `includeRetired: - * true` (`applyArtifactForwardConversions`, `@objectstack/metadata-core`), - * keyed off the artifact's declared `engines.protocol` FLOOR rather than - * its age — and `^17.0.0` is the range `create-objectstack` stamps. So - * every scaffolded app was handed NOT NULL columns it never asked for, plus - * a boot warning telling its author to write the same tightening into the - * source. On a populated database that instruction is a `tighten_not_null` - * / `severity: error` / `category: destructive` migration, prescribed as - * the remedy for a deprecation notice. - * - * No migration is owed to anyone: genuinely pre-ADR-0113 artifacts are not - * measured to exist, and an app that wants NOT NULL columns declares - * `storage.notNull` deliberately. Existing columns are left exactly as they - * are. - * - * ⚠️ Fact 2 is about the MECHANISM, not about this entry, and it outlived the - * entry: `retiredFromLoadPath` still holds nothing back at three runtime seams - * (ADR-0087's 2026-09-13 addendum names all three). Before setting that flag on a DEFAULT FLIP — as opposed to a - * lossless delete or a rename — read that addendum, because the flag does not mean - * what its name says: its own docblock (`RetiredConversionState` in `types.ts`) has - * stated the authoring-only reach since commit 29dd1a6dd. - */ - -/** - * The #3896 close-out sweep, part 2 (protocol 17): the remaining inert - * authoring keys leave the surface, one conversion per metadata type. All are - * pure lossless deletes — none of these keys ever had a runtime effect to - * lose — and all are retired from the load path: each schema tombstones its - * keys with the prescription (`retiredKey`), so the loader rejects loudly and - * only `os migrate meta` lists the mechanical edits for existing sources. - */ -const stripKeys = ( - item: Record, - keys: readonly string[], - emit: (n: { from: string; to: string; path: string }) => void, - path: string, -): Record => { - let touched = false; - const next: Record = { ...item }; - for (const key of keys) { - if (!(key in next)) continue; - delete next[key]; - emit({ from: key, to: '(removed)', path: `${path}.${key}` }); - touched = true; - } - return touched ? next : item; -}; - -/** action.shortcut / action.bulkEnabled — capability claims nothing enforced. */ -const actionInertKeysRemoved: MetadataConversion = { - id: 'action-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'action.shortcut / action.bulkEnabled', - summary: "action keys 'shortcut'/'bulkEnabled' removed (inert, removed under ADR-0049 enforce-or-remove: no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions)", - apply(stack, emit) { - return mapCollection(stack, 'actions', (a, path) => stripKeys(a, ['shortcut', 'bulkEnabled'], emit, path)); - }, - fixture: { - before: { actions: [{ name: 'close_case', label: 'Close', type: 'script', shortcut: 'Ctrl+K', bulkEnabled: true }] }, - after: { actions: [{ name: 'close_case', label: 'Close', type: 'script' }] }, - expectedNotices: 2, - }, -}; + // ── 1. try_catch nodes: retry.retryDelayMs → retry.backoffMs ────── + const withFlows = mapFlowNodes(withErrorHandling, (node, path) => { + if (node.type !== 'try_catch') return node; + const config = node.config; + if (!config || typeof config !== 'object' || Array.isArray(config)) return node; + const configDict = config as Record; + const retry = configDict.retry; + if (!retry || typeof retry !== 'object' || Array.isArray(retry)) return node; + const renamed = renameKey(retry as Record, 'retryDelayMs', 'backoffMs'); + if (renamed === null) return node; + emit({ from: 'retryDelayMs', to: 'backoffMs', path: `${path}.config.retry.backoffMs` }); + return { ...node, config: { ...configDict, retry: renamed } }; + }); -/** - * flow.active / flow.template / nodes[].outputSchema / - * errorHandling.fallbackNodeId — `active` is the rls.enabled shape (writing - * `active: false` never stopped a flow; `status` is the enforced lifecycle), - * the rest were never read. Deleting `active` preserves behavior exactly — - * the flow was governed by `status` all along; the notice tells the author - * where the real switch is. - */ -const flowInertKeysRemoved: MetadataConversion = { - id: 'flow-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId', - summary: "flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (inert, removed under ADR-0049 enforce-or-remove: active:false never stopped a flow; status is the enforced lifecycle)", - apply(stack, emit) { - let out = mapCollection(stack, 'flows', (f, path) => { - let next = stripKeys(f, ['active', 'template'], emit, path); - const eh = next.errorHandling; - if (eh && typeof eh === 'object' && !Array.isArray(eh) && 'fallbackNodeId' in (eh as Record)) { - const nextEh = { ...(eh as Record) }; - delete nextEh.fallbackNodeId; - emit({ from: 'fallbackNodeId', to: '(removed)', path: `${path}.errorHandling.fallbackNodeId` }); - next = next === f ? { ...f } : next; - next.errorHandling = nextEh; + // ── 2. jobs: materialize the pre-17 implicit defaults ───────────── + return mapCollection(withFlows, 'jobs', (job, path) => { + const policy = job.retryPolicy; + if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return job; + const policyDict = policy as Record; + let next = policyDict; + if (next.maxRetries === undefined) { + next = { ...next, maxRetries: 3 }; + emit({ + from: 'maxRetries unset (implied 3)', + to: 'maxRetries: 3', + path: `${path}.retryPolicy.maxRetries`, + }); } - return next; - }); - out = mapFlowNodes(out, (node, path) => { - if (!('outputSchema' in node)) return node; - const next = { ...node }; - delete next.outputSchema; - emit({ from: 'outputSchema', to: '(removed)', path: `${path}.outputSchema` }); - return next; + if (next.backoffMultiplier === undefined) { + next = { ...next, backoffMultiplier: 2 }; + emit({ + from: 'backoffMultiplier unset (implied 2)', + to: 'backoffMultiplier: 2', + path: `${path}.retryPolicy.backoffMultiplier`, + }); + } + return next === policyDict ? job : { ...job, retryPolicy: next }; }); - return out; }, fixture: { before: { flows: [{ - name: 'escalate', - type: 'autolaunched', - active: false, - template: true, - errorHandling: { strategy: 'retry', fallbackNodeId: 'n9' }, - nodes: [{ id: 'n1', type: 'start', outputSchema: { ok: { type: 'boolean' } } }], - edges: [], + name: 'sync_orders', + // Flow-LEVEL policy (#4964) — one level up from the nodes, so the node + // walk below never sees it. + errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, + nodes: [ + { id: 'n1', type: 'start' }, + { + id: 'n2', + type: 'try_catch', + config: { + try: { nodes: [], edges: [] }, + retry: { maxRetries: 3, retryDelayMs: 500, jitter: true }, + }, + }, + // Already canonical — left alone, contributes no notice. + { + id: 'n3', + type: 'try_catch', + config: { try: { nodes: [], edges: [] }, retry: { maxRetries: 2, backoffMs: 250 } }, + }, + ], + }, { + // Flow-level block already canonical — left alone, no notice. + name: 'roll_up', + errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 100 }, + nodes: [{ id: 'n1', type: 'start' }], }], + jobs: [ + // Omits both defaults — both get written out. + { name: 'nightly_sync', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'jobs.ts:sync', retryPolicy: { backoffMs: 5000 } }, + // States both — untouched. + { name: 'hourly_roll', schedule: { type: 'cron', expression: '0 * * * *' }, handler: 'jobs.ts:roll', retryPolicy: { maxRetries: 1, backoffMultiplier: 3 } }, + // No policy block at all — absence already meant one attempt. + { name: 'weekly_purge', schedule: { type: 'cron', expression: '0 0 * * 0' }, handler: 'jobs.ts:purge' }, + ], }, after: { flows: [{ - name: 'escalate', - type: 'autolaunched', - errorHandling: { strategy: 'retry' }, + name: 'sync_orders', + errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }, + nodes: [ + { id: 'n1', type: 'start' }, + { + id: 'n2', + type: 'try_catch', + config: { + try: { nodes: [], edges: [] }, + retry: { maxRetries: 3, jitter: true, backoffMs: 500 }, + }, + }, + { + id: 'n3', + type: 'try_catch', + config: { try: { nodes: [], edges: [] }, retry: { maxRetries: 2, backoffMs: 250 } }, + }, + ], + }, { + name: 'roll_up', + errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 100 }, nodes: [{ id: 'n1', type: 'start' }], - edges: [], - }], - }, - expectedNotices: 4, - }, -}; - -/** - * View container: list-shaped entries lose `responsive`/`performance`, - * form-shaped entries lose `data`/`defaultSort`/`aria`. Deliberately - * SHAPE-SCOPED — the LIST view's `aria` and `data` are live and untouched. - */ -const viewInertKeysRemoved: MetadataConversion = { - id: 'view-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria', - summary: "view keys removed as inert (ADR-0049 enforce-or-remove): list 'responsive'/'performance', form 'defaultSort'/'aria' — no renderer read them (list aria/data and form data stay live)", - apply(stack, emit) { - const LIST_KEYS = ['responsive', 'performance'] as const; - // NOT 'data': the sweep's removal attempt was refuted by the build — - // defineForm writes data.provider='schema' on every metadata form. - const FORM_KEYS = ['defaultSort', 'aria'] as const; - // The per-family key sets are why {@link mapViewPayloads} labels every - // payload: `aria` is retired on a form and LIVE on a list, `data` the - // reverse, so a walk that could not tell the two apart would delete a live - // key on whichever family it guessed wrong. - return mapViewPayloads(stack, (payload, kind, path) => - stripKeys(payload, kind === 'list' ? LIST_KEYS : FORM_KEYS, emit, path)); - }, - fixture: { - before: { - views: [{ - object: 'crm_lead', - list: { type: 'grid', columns: ['name'], responsive: { sm: {} }, performance: { lazyLoad: true }, aria: { label: 'Leads' } }, - form: { type: 'simple', data: { provider: 'object', object: 'crm_lead' }, defaultSort: [{ field: 'created_at', order: 'desc' }], aria: { label: 'Lead form' } }, // data survives - }], - }, - after: { - views: [{ - object: 'crm_lead', - list: { type: 'grid', columns: ['name'], aria: { label: 'Leads' } }, - form: { type: 'simple', data: { provider: 'object', object: 'crm_lead' } }, }], + jobs: [ + { name: 'nightly_sync', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'jobs.ts:sync', retryPolicy: { backoffMs: 5000, maxRetries: 3, backoffMultiplier: 2 } }, + { name: 'hourly_roll', schedule: { type: 'cron', expression: '0 * * * *' }, handler: 'jobs.ts:roll', retryPolicy: { maxRetries: 1, backoffMultiplier: 3 } }, + { name: 'weekly_purge', schedule: { type: 'cron', expression: '0 0 * * 0' }, handler: 'jobs.ts:purge' }, + ], }, + // sync_orders' flow-level rename, n2's node-level rename, plus + // nightly_sync's two materialized defaults. expectedNotices: 4, }, }; /** - * View container: list-shaped entries lose `striped`/`bordered`/`virtualScroll` - * (#7176, ADR-0049 enforce-or-remove, maintainer ruling 2026-08-10). Every - * measured reader was a forwarding copy — react spec-bridge → plugin-list → - * plugin-view/app-shell — and the chains end at ObjectGrid, which never spells - * any of the three: copy-without-apply is dead in effect. List-shaped slots - * only (`list` + named `listViews`); form-shaped entries never declared them. + * The two spellings of the page-header node, both converted by + * {@link pageHeaderSubtitleAlias}. + * + * `page:header` is the protocol type (`PageComponentType`, `ComponentPropsMap`); + * `page-header` is objectui's kebab legacy alias (`@object-ui/layout`), which is + * authorable here because `PageComponentSchema.type` is deliberately + * `z.union([PageComponentType, z.string()])` — an open namespace for custom + * components. */ -const viewListPassthroughKeysRemoved: MetadataConversion = { - id: 'view-list-passthrough-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'view.list.striped / view.list.bordered / view.list.virtualScroll', - summary: "view list keys removed: 'striped'/'bordered'/'virtualScroll' — every measured reader copied the key forward and none applied it (a key that is only passed through is dead in effect; ADR-0049 enforce-or-remove)", - apply(stack, emit) { - const LIST_KEYS = ['striped', 'bordered', 'virtualScroll'] as const; - return mapViewPayloads(stack, (payload, kind, path) => - kind === 'list' ? stripKeys(payload, LIST_KEYS, emit, path) : payload); - }, - fixture: { - before: { - views: [{ - object: 'crm_lead', - list: { type: 'grid', columns: ['name'], resizable: true, striped: true, bordered: true, virtualScroll: true }, // resizable survives - listViews: { - all: { type: 'grid', columns: ['name'], striped: false }, - }, - }], - }, - after: { - views: [{ - object: 'crm_lead', - list: { type: 'grid', columns: ['name'], resizable: true }, - listViews: { - all: { type: 'grid', columns: ['name'] }, - }, - }], - }, - expectedNotices: 4, - }, -}; +const PAGE_HEADER_COMPONENT_TYPES = new Set(['page:header', 'page-header']); /** - * The `'pdf'` export format leaves `view.exportOptions` (protocol 17, #8010 — - * maintainer ruling 2026-08-12). - * - * PDF export was declined platform-side (#1301 NOT_PLANNED), so the enum - * member was declared-but-unrenderable: ObjectGrid dropped the format from the - * export menu with only a runtime `console.warn`, so `exportOptions: - * ['xlsx', 'pdf']` type-checked, validated, and silently rendered a menu - * without PDF. The same ruling adopted the OBJECT form for `exportOptions` - * (`{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }` — - * the key set the renderer actually reads); the legacy bare array stays - * accepted and lifts to `{ formats: [...] }` at parse, so this conversion does - * NOT rewrite the array form to the object form — the array is back-compat, - * not retired. - * - * This is an enum-VALUE retirement, so there is no `retiredKey()` tombstone to - * hang the prescription on — the format enum's own error map carries it - * (`LIST_VIEW_EXPORT_PDF_RETIRED`, ui/view.zod.ts), keyed on `issue.input` so - * only the value which used to be legal gets the "was removed" message, plus a - * union-level dispatch so the refusal is the TOP-level message in either - * authored spelling (the `hook-body-crypto-hash-removed` precedent, one level - * deeper again: the value sits inside either a bare array or an object's - * `formats`, so `stripKeys` cannot reach it). - * - * `retiredFromLoadPath`: the enum rejects the value outright, so a live author - * is taught at parse rather than silently rewritten. The entry exists so - * stored 16.x/17-rc rows replay clean (`applyConversionsToStoredItem`) and so - * `os migrate meta --from 16` lists the mechanical edits for author sources. As with `crypto.hash`, - * the strip keeps the surrounding declaration — an emptied `formats` array - * stays (an export menu configured to offer nothing is a declaration, not an - * accident this conversion may invent an answer for). - */ -const viewExportOptionsPdfRemoved: MetadataConversion = { - id: 'view-export-options-pdf-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'view.list.exportOptions / view.listViews.*.exportOptions', - summary: - "list-view export format 'pdf' removed (PDF export was declined as not planned, and ObjectGrid " - + 'dropped the declared format from the menu with only a runtime console.warn; an honest enum replaces that warning)', - apply(stack, emit) { - const stripPdf = (slot: unknown, path: string): unknown => { - if (!slot || typeof slot !== 'object' || Array.isArray(slot)) return slot; - const eo = (slot as Dict).exportOptions; - if (Array.isArray(eo) && eo.includes('pdf')) { - emit({ from: 'pdf', to: '(removed)', path: `${path}.exportOptions` }); - return { ...(slot as Dict), exportOptions: eo.filter((f) => f !== 'pdf') }; - } - if (eo && typeof eo === 'object' && !Array.isArray(eo)) { - const formats = (eo as Dict).formats; - if (Array.isArray(formats) && formats.includes('pdf')) { - emit({ from: 'pdf', to: '(removed)', path: `${path}.exportOptions.formats` }); - return { - ...(slot as Dict), - exportOptions: { ...(eo as Dict), formats: formats.filter((f) => f !== 'pdf') }, - }; - } - } - return slot; - }; - return mapViewPayloads(stack, (payload, kind, path) => - kind === 'list' ? (stripPdf(payload, path) as Dict) : payload); - }, - fixture: { - before: { - views: [{ - object: 'crm_contract', - // Legacy array spelling: 'pdf' is stripped, the survivors stay an array - // (the array form is back-compat, not retired — no lift here). - list: { type: 'grid', columns: ['name'], exportOptions: ['xlsx', 'pdf'] }, - listViews: { - // Object spelling: 'pdf' leaves `formats`; the sibling keys survive. - archive: { - type: 'grid', - columns: ['name'], - exportOptions: { formats: ['csv', 'pdf'], maxRecords: 100 }, - }, - // No 'pdf' declared → untouched. - all: { type: 'grid', columns: ['name'], exportOptions: ['csv', 'json'] }, - }, - }], - }, - after: { - views: [{ - object: 'crm_contract', - list: { type: 'grid', columns: ['name'], exportOptions: ['xlsx'] }, - listViews: { - archive: { - type: 'grid', - columns: ['name'], - exportOptions: { formats: ['csv'], maxRecords: 100 }, - }, - all: { type: 'grid', columns: ['name'], exportOptions: ['csv', 'json'] }, - }, - }], - }, - expectedNotices: 2, - }, -}; - -/** dashboard.aria / dashboard.performance / widgets[].performance. */ -const dashboardInertKeysRemoved: MetadataConversion = { - id: 'dashboard-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'dashboard.aria / dashboard.performance / dashboard.widgets[].performance', - summary: "dashboard keys 'aria'/'performance' and widget 'performance' removed (inert, removed under ADR-0049 enforce-or-remove: no renderer applied any of them)", - apply(stack, emit) { - return mapCollection(stack, 'dashboards', (d, path) => { - let next = stripKeys(d, ['aria', 'performance'], emit, path); - const widgets = next.widgets; - if (Array.isArray(widgets)) { - let subTouched = false; - const rebuilt = widgets.map((w, i) => { - if (w && typeof w === 'object' && !Array.isArray(w) && 'performance' in w) { - const cleaned = { ...(w as Record) }; - delete cleaned.performance; - emit({ from: 'performance', to: '(removed)', path: `${path}.widgets[${i}].performance` }); - subTouched = true; - return cleaned; - } - return w; - }); - if (subTouched) { - next = next === d ? { ...d } : next; - next.widgets = rebuilt; - } - } - return next; - }); - }, - fixture: { - before: { - dashboards: [{ - name: 'sales_kpis', - aria: { label: 'Sales' }, - performance: { lazyLoad: true }, - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], performance: { prefetch: true } }], - }], - }, - after: { - dashboards: [{ - name: 'sales_kpis', - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'] }], - }], - }, - expectedNotices: 3, - }, -}; - -/** - * dashboard.widgets[].responsive (#4876) — the same-named `view.responsive` - * went in the #3896 close-out above; this one escaped that sweep through a - * liveness drill gap rather than on evidence (`dashboard.json` declares no - * `children` on `widgets`, so no widget-level key was ever classified — filed - * as #4956). Re-measured 2026-08-03: no objectui code reads - * `widget.responsive`, and zero authored instances exist repo-wide. - * - * Deliberately a SEPARATE entry rather than another key on - * `dashboard-inert-keys-removed`: that entry's identity is the #3896 sweep, and - * folding a differently-evidenced removal into it would misattribute this one - * in `spec-changes.json` and the upgrade guide — the two places an upgrading - * author actually reads. Both are `toMajor: 17`, so a stored dashboard carrying - * both keys is cleaned by both in one replay. - * - * Strips ONLY the widget embed. At the time the shared `ResponsiveConfig` - * shape was believed to stay live on `page.components[].responsive`; #11027 - * measured that carrier equally unread and retired it and the shape at - * protocol 18 (`page-component-responsive-removed` below). - */ -const dashboardWidgetResponsiveRemoved: MetadataConversion = { - id: 'dashboard-widget-responsive-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'dashboard.widgets[].responsive', - summary: "dashboard widget key 'responsive' removed (no renderer ever applied per-widget breakpoint overrides; the page.components[].responsive key this entry once deferred to was measured equally unread and retired at protocol 18)", - apply(stack, emit) { - return mapCollection(stack, 'dashboards', (d, path) => { - const widgets = d.widgets; - if (!Array.isArray(widgets)) return d; - let touched = false; - const rebuilt = widgets.map((w, i) => { - if (!w || typeof w !== 'object' || Array.isArray(w)) return w; - const cleaned = stripKeys(w as Record, ['responsive'], emit, `${path}.widgets[${i}]`); - if (cleaned !== w) touched = true; - return cleaned; - }); - if (!touched) return d; - return { ...d, widgets: rebuilt }; - }); - }, - fixture: { - before: { - dashboards: [{ - name: 'ops_overview', - widgets: [{ - id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], - responsive: { columns: { xs: 12, lg: 4 }, order: { xs: 2, lg: 1 }, hiddenOn: ['xs'] }, - }], - }], - }, - after: { - dashboards: [{ - name: 'ops_overview', - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'] }], - }], - }, - expectedNotices: 1, - }, -}; - -/** - * dashboard.widgets[].actionUrl / actionType / actionIcon / aria (#5010) — the - * four widget-level keys the #4956 drill judged dead on a closed call graph. - * - * Two affordances, one removal, because both fail the same way and an upgrading - * source carries them together: - * - * - the action TRIO described a per-widget button. No renderer in either repo - * draws one; every action a dashboard dispatches comes from - * `header.actions[]`. Its only reader was `packages/lint`'s reference- - * integrity rule, which failed builds when a button that cannot render - * pointed at an action that did not exist — that widget branch is deleted in - * the same change. - * - `aria` promised ARIA attributes that never reached the DOM: the same false - * compliance the DASHBOARD-level `aria` was retired for above, one level - * down. `dashboard-inert-keys-removed` took the parent key in the #3896 - * sweep and left this one, because `widgets` had no ledger drill then. - * - * A SEPARATE entry rather than more keys on `dashboard-inert-keys-removed`, for - * the reason `dashboard-widget-responsive-removed` gives just above: that entry's - * identity is the #3896 sweep, and folding a differently-evidenced removal into - * it would misattribute this one in `spec-changes.json` and the upgrade guide — - * the two places an upgrading author actually reads. All are `toMajor: 17`, so a - * stored dashboard carrying keys from several of them is cleaned in one replay. - * - * `colorVariant`, the fifth key #5010 lists, is deliberately NOT here — and the - * reason has since been SETTLED, in the other direction. When this entry was - * written its disposition was open: the rewrite target its triage assumed, - * `options.colorVariant`, measured dead on the ADR-0021 dataset path too, and - * 16 authored sites depended on the answer. #5010 ruling B answered ENFORCE — - * the declaration stays and objectui implements it — which objectui#3359 / - * PR objectui#3799 did, absorbed here by the `.objectui-sha` pin `09987b68`. - * The key is `live` in `liveness/dashboard.json` as of #6774, so nothing is - * owed: no retirement, no later entry. Read this paragraph as "not being - * removed", not as "not removed yet". - */ -const dashboardWidgetActionAriaRemoved: MetadataConversion = { - id: 'dashboard-widget-action-aria-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: - 'dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / ' - + 'dashboard.widgets[].actionIcon / dashboard.widgets[].aria', - summary: - "dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed " - + '(no renderer ever drew a per-widget action button, and widget ARIA attributes never ' - + 'reached the DOM; use header.actions[] and the widget title/description)', - apply(stack, emit) { - return mapCollection(stack, 'dashboards', (d, path) => { - const widgets = d.widgets; - if (!Array.isArray(widgets)) return d; - let touched = false; - const rebuilt = widgets.map((w, i) => { - if (!w || typeof w !== 'object' || Array.isArray(w)) return w; - const cleaned = stripKeys( - w as Record, - ['actionUrl', 'actionType', 'actionIcon', 'aria'], - emit, - `${path}.widgets[${i}]`, - ); - if (cleaned !== w) touched = true; - return cleaned; - }); - if (!touched) return d; - return { ...d, widgets: rebuilt }; - }); - }, - fixture: { - before: { - dashboards: [{ - name: 'ops_overview', - widgets: [{ - id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], - actionUrl: 'export_dashboard_pdf', - actionType: 'script', - actionIcon: 'download', - aria: { ariaLabel: 'Total orders' }, - }], - }], - }, - after: { - dashboards: [{ - name: 'ops_overview', - widgets: [{ id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'] }], - }], - }, - // One notice per KEY, not per widget — four keys on one widget. - expectedNotices: 4, - }, -}; - -/** - * dashboard.widgets[].compareTo (#5011) — a VOCABULARY convergence, not a - * removal: the widget's three declared arms are replaced by the one shape the - * analytics executor implements, `{ kind, dimension? }` - * (`DatasetSelection.compareTo`). - * - * Why a conversion rather than a plain tombstone: two of the three arms have an - * exact target, so leaving them for the author to retype would be make-work on - * a rewrite a machine can prove. - * - * 'previousPeriod' → { kind: 'previousPeriod' } value verbatim, container changed - * 'previousYear' → { kind: 'previousYear' } value verbatim, container changed - * { offset: '1y' } → { kind: 'previousYear' } '1y' IS previousYear, by definition - * - * `dimension` is deliberately NOT synthesised. The conversion sees a stack, not - * a dataset — it cannot know which time dimension carries the window — and it - * does not need to: `dimension` is optional precisely so the executor can - * resolve it (one dated candidate) or refuse loudly (zero, or several). Writing - * a guess here would convert a loud runtime error into a wrong comparison. - * - * Every OTHER `{ offset }` (`'7d'`, `'1M'`, `'2w'`, …) is left UNTOUCHED, on - * purpose. There is no faithful target: `previousPeriod` shifts by the resolved - * window's own length, which equals `7d` only when the window happens to be - * seven days. Rewriting it would silently change which rows the comparison - * column counts — the failure class this issue exists to end. So the source - * keeps the key, the strict schema rejects it with `COMPARE_TO_OFFSET_RETIRED` - * (which prescribes kind + filter-window), and the residue is declared as the - * `dashboard-widget-compareto-offset` semantic migration. - * - * Measured before writing (#5011's adjudication asked for stored/authored - * instances first): repo-wide, four authored instances, all of them the string - * form, all in `examples/app-crm`; ZERO `{ offset }` instances in either repo. - * Expected — a canonical-path author who tried `{ offset }` got a thrown widget, - * so it could never accumulate there. - * - * `retiredFromLoadPath: true`: the old spellings get NO acceptance window. An - * auto-converting loader would let `compareTo: 'previousPeriod'` keep parsing - * clean, which is the lenient-consumer shape PD #12 forbids and the exact - * dynamic that let the widget and executor vocabularies drift apart unnoticed - * for a whole major. Stored `sys_metadata` rows are still covered — every - * rehydration seam replays retired entries via `applyConversionsToStoredItem` - * (#3903). - */ -const dashboardWidgetCompareToConverged: MetadataConversion = { - id: 'dashboard-widget-compareto-converged', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'dashboard.widgets[].compareTo', - summary: - "dashboard widget 'compareTo' converged on the executor's { kind, dimension? } contract " - + "(the shape the dataset executor implements; the bare strings and { offset: '1y' } rewrite mechanically; other { offset } " - + 'durations have no faithful target and are reported, not guessed)', - apply(stack, emit) { - return mapCollection(stack, 'dashboards', (d, path) => { - const widgets = d.widgets; - if (!Array.isArray(widgets)) return d; - let touched = false; - const rebuilt = widgets.map((w, i) => { - if (!w || typeof w !== 'object' || Array.isArray(w)) return w; - const widget = w as Record; - if (!('compareTo' in widget)) return w; - const cmp = widget.compareTo; - const at = `${path}.widgets[${i}].compareTo`; - - // Arm 1/2 — the bare string form. The value survives verbatim. - if (cmp === 'previousPeriod' || cmp === 'previousYear') { - emit({ from: `'${cmp}'`, to: `{ kind: '${cmp}' }`, path: at }); - touched = true; - return { ...widget, compareTo: { kind: cmp } }; - } - - // Arm 3 — `{ offset }`, and only the one duration with an exact target. - if (cmp && typeof cmp === 'object' && !Array.isArray(cmp)) { - const offset = (cmp as Record).offset; - if (offset === '1y') { - emit({ from: "{ offset: '1y' }", to: "{ kind: 'previousYear' }", path: at }); - touched = true; - return { ...widget, compareTo: { kind: 'previousYear' } }; - } - } - return w; - }); - if (!touched) return d; - return { ...d, widgets: rebuilt }; - }); - }, - fixture: { - before: { - dashboards: [{ - name: 'revenue_review', - widgets: [ - { id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: 'previousPeriod' }, - { id: 'w2', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: 'previousYear' }, - { id: 'w3', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { offset: '1y' } }, - ], - }], - }, - after: { - dashboards: [{ - name: 'revenue_review', - widgets: [ - { id: 'w1', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { kind: 'previousPeriod' } }, - { id: 'w2', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { kind: 'previousYear' } }, - { id: 'w3', type: 'kpi', dataset: 'orders', values: ['total'], compareTo: { kind: 'previousYear' } }, - ], - }], - }, - expectedNotices: 3, - }, -}; - -/** - * agent.knowledge — a grounding claim nothing enforced (the RAG path reads - * `sourceIds` from the LLM's tool-call arguments, never the agent record). - * Absorbs the former `agent-knowledge-topics-to-sources` rename (#3855): - * both spellings of the block end deleted, in one notice. - */ -const agentKnowledgeRemoved: MetadataConversion = { - id: 'agent-knowledge-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'agent.knowledge', - summary: "agent key 'knowledge' removed (inert, removed under ADR-0049 enforce-or-remove: declaring sources/indexes never scoped retrieval; restrict at the knowledge-service level)", - apply(stack, emit) { - return mapCollection(stack, 'agents', (a, path) => stripKeys(a, ['knowledge'], emit, path)); - }, - fixture: { - before: { agents: [{ name: 'support_agent', label: 'Support', knowledge: { sources: ['faq'], indexes: ['docs'] } }] }, - after: { agents: [{ name: 'support_agent', label: 'Support' }] }, - expectedNotices: 1, - }, -}; - -/** skill.triggerPhrases — phrases were never matched against the user's message. */ -const skillTriggerPhrasesRemoved: MetadataConversion = { - id: 'skill-trigger-phrases-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'skill.triggerPhrases', - summary: "skill key 'triggerPhrases' removed (inert, removed under ADR-0049 enforce-or-remove: activation is triggerConditions + the agent's skills[] allowlist; phrases were a dead-end projection)", - apply(stack, emit) { - return mapCollection(stack, 'skills', (sk, path) => stripKeys(sk, ['triggerPhrases'], emit, path)); - }, - fixture: { - before: { skills: [{ name: 'case_mgmt', label: 'Cases', triggerPhrases: ['open a ticket'] }] }, - after: { skills: [{ name: 'case_mgmt', label: 'Cases' }] }, - expectedNotices: 1, - }, -}; - -/** - * All conversions, keyed by the protocol major that introduced the canonical - * shape. Newest majors last; ordering within a major is application order. - */ -/** - * Stack `api.requireAuth` → dropped (protocol 17, #3963). - * - * NOT a rename — there is no key to move the value to. The deployment-wide - * anonymous-access opt-out is retired: auth is a kernel concern, and anonymous - * access to object data is now always denied. A surface that legitimately - * serves a session-less caller derives its own narrow authorization from a - * declaration (a public form view, a share link, or `book.audience: 'public'`), - * so there is nothing for the old boolean to control. - * - * Dropping is safe at load time: the runtime no longer reads the key (its - * plumbing was removed in the same change), so a surviving `api.requireAuth` - * would otherwise be silently stripped by the non-strict schema — the exact - * quiet-failure this conversion + the `retiredKey` tombstone exist to prevent. - * The notice tells the author their intent was dropped and where to re-declare - * public access. - */ -const stackApiRequireAuthRemoved: MetadataConversion = { - id: 'stack-api-require-auth-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'stack.api.requireAuth', - summary: "stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (a public form, a share link or `book.audience: 'public'`), which replaced the deployment-wide opt-out", - apply(stack, emit) { - const api = stack.api; - if (!isDict(api) || !('requireAuth' in api)) return stack; - const nextApi: Dict = { ...api }; - delete nextApi.requireAuth; - emit({ from: 'requireAuth', to: '(removed)', path: 'api' }); - return { ...stack, api: nextApi }; - }, - fixture: { - before: { - api: { requireAuth: false, enableProjectScoping: false }, - }, - after: { - api: { enableProjectScoping: false }, - }, - expectedNotices: 1, - }, -}; - -/** - * `waitEventConfig.timeoutMs` / `.onTimeout` removed — `wait` never had a timeout - * (protocol 17, #4158). - * - * Both keys described a timeout and neither delivered one. `onTimeout` had **zero** - * readers: no path ever inspected it, so neither `'fail'` nor `'continue'` ever - * happened, and its `.default('fail')` stamped a decision nothing made onto every - * wait node. `timeoutMs` said "maximum wait time" while its only reader used it as - * the timer *duration* when `timerDuration` was absent — it did something, just not - * what it claimed. - * - * **Retired from the load path**, like every other key retired for lying rather than - * for being renamed (`api.requireAuth`, the tool/app/flow inert keys, RLS `priority`). - * The distinction the registry draws: a key that was merely *renamed* keeps a load - * window, because punishing an author for a spelling nobody warned them about is - * pointless. A key that **misdescribed itself** does not — silently absorbing it - * would let the author keep believing they configured a timeout. The chain converts - * it mechanically; the schema tombstone tells them what actually happened. - * - * `timeoutMs` moves to `timerDuration` rather than being dropped, because that IS - * what it did. It is stringified on the way: `timerDuration` is `z.string()` while - * `timeoutMs` was `z.number()`, and `parseIsoDuration` reads a bare numeric string as - * milliseconds — so `timeoutMs: 60000` and `timerDuration: '60000'` are the same - * wait. Moving the number unstringified would produce a block that no longer parses. - * With `timerDuration` already set it is dropped instead: the executor's `??` never - * looked past the duration, so it was already dead metadata. - */ -function removeWaitTimeoutKeys(stack: Dict, emit: Emit): Dict { - // Deliberately not filtered to `node.type === 'wait'`: the tombstones live on the - // block, so a non-wait node carrying one would fail to parse and never be cleaned. - return mapFlowNodes(stack, (node, path) => { - const wec = node.waitEventConfig; - if (!isDict(wec)) return node; - const next: Dict = { ...wec }; - let changed = false; - - if (next.timeoutMs != null) { - if (next.timerDuration == null) { - next.timerDuration = String(next.timeoutMs); - emit({ from: 'waitEventConfig.timeoutMs', to: 'waitEventConfig.timerDuration', path: `${path}.waitEventConfig.timerDuration` }); - } else { - emit({ from: 'waitEventConfig.timeoutMs', to: '(removed — `timerDuration` already set, so it was never read)', path: `${path}.waitEventConfig` }); - } - delete next.timeoutMs; - changed = true; - } - if (next.onTimeout != null) { - emit({ from: 'waitEventConfig.onTimeout', to: '(removed — no reader ever existed)', path: `${path}.waitEventConfig` }); - delete next.onTimeout; - changed = true; - } - return changed ? { ...node, waitEventConfig: next } : node; - }); -} - -const flowNodeWaitTimeoutKeysRemoved: MetadataConversion = { - id: 'flow-node-wait-timeout-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'flow.node.waitEventConfig', - summary: - "waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) " + - "and 'onTimeout' (removed — zero readers, so no timeout ever fired): wait never had a timeout, so its timeout contract is withdrawn rather than built", - apply(stack, emit) { - return removeWaitTimeoutKeys(stack, emit); - }, - fixture: { - before: { - flows: [ - { - name: 'settlement', - nodes: [ - { id: 'n1', type: 'start' }, - // The shape that actually did something: `timeoutMs` standing in for a - // duration. It must survive as a working wait, hence the move. - { id: 'n2', type: 'wait', waitEventConfig: { eventType: 'timer', timeoutMs: 60000, onTimeout: 'continue' } }, - // `timerDuration` already set → `timeoutMs` was dead; dropped, not moved. - { - id: 'n3', - type: 'wait', - waitEventConfig: { eventType: 'timer', timerDuration: 'PT5M', timeoutMs: 999 }, - }, - // Nothing retired here — left byte-identical. - { id: 'n4', type: 'wait', waitEventConfig: { eventType: 'signal', signalName: 'paid' } }, - ], - }, - ], - }, - after: { - flows: [ - { - name: 'settlement', - nodes: [ - { id: 'n1', type: 'start' }, - { id: 'n2', type: 'wait', waitEventConfig: { eventType: 'timer', timerDuration: '60000' } }, - { id: 'n3', type: 'wait', waitEventConfig: { eventType: 'timer', timerDuration: 'PT5M' } }, - { id: 'n4', type: 'wait', waitEventConfig: { eventType: 'signal', signalName: 'paid' } }, - ], - }, - ], - }, - // n2: `timeoutMs` moved + `onTimeout` dropped. n3: `timeoutMs` dropped (shadowed). - expectedNotices: 3, - }, -}; - -/** - * The remaining inert datasource blocks removed (protocol 17, #4583 B/C/D). - * - * Three clusters, one finding each — declared, `.strict()`-guarded, read by no - * runtime path: - * - * - `retryPolicy` (4 keys): no connect or query path ever retried on it. - * Connection failure is the boot policy in the datasource connection service - * (degraded boot / `bootCritical` fail-fast), which does not retry on a - * schedule. - * - `healthCheck` (3 keys): nothing scheduled a probe, so `enabled` enabled - * nothing and the timeouts bounded nothing. Liveness is probed ON DEMAND via - * the driver handle's `ping()` / `checkHealth()`. - * - `external.label` / `external.requirePermission`: the federation block's own - * label was never read (the top-level `label` is what Setup renders), and no - * authorization check ever consulted the permission — naming one gated - * nothing, which is the false-compliance shape ADR-0049 removes. - * - * `retryPolicy` is the one with a booby trap, and it is a NAME collision rather - * than a behaviour question: `hook.retryPolicy` and `job.retryPolicy` ARE - * enforced. They are different keys on different types and spell the delay - * `backoffMs`, not `baseDelayMs` — which is itself the evidence nothing read - * the datasource one, since no code reads both spellings. The conversion - * therefore touches ONLY `datasources`, and the schema's rejection message - * spells the distinction out rather than offering a rename. - * - * `retiredFromLoadPath`: every affected shape is `.strict()` and rejects with - * its prescription (`RETIRED_DATASOURCE_BLOCKS`). - */ -const datasourceInertBlocksRemoved: MetadataConversion = { - id: 'datasource-inert-blocks-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission', - summary: "datasource keys 'retryPolicy'/'healthCheck' and external 'label'/'requirePermission' removed (nothing retried, nothing probed on a schedule, and the federation label/permission were read by nobody; each of those jobs already has a live mechanism)", - apply(stack, emit) { - return mapCollection(stack, 'datasources', (ds, path) => { - const next = stripKeys(ds, ['retryPolicy', 'healthCheck'], emit, path); - // `external.*` sits one level down, so stripKeys (top-level only) cannot - // reach it — drill in, and copy-on-write so an untouched datasource keeps - // its identity for the caller's change detection. - const external = next.external; - if (!external || typeof external !== 'object' || Array.isArray(external)) return next; - const strippedExternal = stripKeys( - external as Record, - ['label', 'requirePermission'], - emit, - `${path}.external`, - ); - if (strippedExternal === external) return next; - return { ...next, external: strippedExternal }; - }); - }, - fixture: { - before: { - datasources: [{ - name: 'warehouse', - driver: 'postgres', - config: { host: 'db.internal', database: 'analytics' }, - healthCheck: { enabled: true, intervalMs: 30000, timeoutMs: 5000 }, - retryPolicy: { maxRetries: 3, baseDelayMs: 1000, maxDelayMs: 30000, backoffMultiplier: 2 }, - schemaMode: 'external', - external: { - label: 'Warehouse — ANALYTICS / PROD', - allowWrites: false, - requirePermission: 'analytics_admin', - }, - }], - }, - // Four notices: one per removed key, counting the two nested ones. - after: { - datasources: [{ - name: 'warehouse', - driver: 'postgres', - config: { host: 'db.internal', database: 'analytics' }, - schemaMode: 'external', - external: { allowWrites: false }, - }], - }, - expectedNotices: 4, - }, -}; - -/** - * `mapping.extractQuery` / `errorPolicy` / `batchSize` removed (protocol 17, - * #4509, ADR-0049). - * - * Three keys on the import/export mapping artifact that parsed, stored, and - * controlled nothing: - * - * - `extractQuery` — "Query to run for export only" against an export path that - * does not exist: no exporter reads a mapping artifact at all. A whole - * `QuerySchema` subtree hung off it, which is what made it read as a designed - * feature rather than an aspiration. - * - `errorPolicy` — `skip` / `abort` / `retry` selecting between three - * behaviours that were one behaviour. Error handling on the import path is - * the import REQUEST's own options; nothing consults the mapping. - * - `batchSize` — the write path sizes its own batches and never asked. - * - * Two of the three were **unwarnable**, which is why they went out now rather - * than after a deprecation window: `errorPolicy` and `batchSize` carried schema - * defaults, and a default materialises at parse time, so the liveness advisory - * lint could not distinguish an authored value from a supplied one - * (`_authorWarnSkipped` in `liveness/mapping.json`). For a key in that state, - * removal is not the escalation after a warning — it is the only channel that - * ever reaches the author. - * - * `batchSize` is also the name most likely to be "fixed" by relocation, so the - * schema prescription says outright that `bulkActionDef` / `connector` / `sync` - * / `offline` / seed-loader / NoSQL-cursor `batchSize` are live and are all - * different keys sizing different paths. Same shape as the `retryPolicy` / - * `backoffMs` trap #4583 had to defuse on `datasource`. - * - * `retiredFromLoadPath`: the schema is `.strict()`, so the keys are gone from - * the shape and rejected with a `guidance` prescription rather than tombstoned. - */ -const mappingInertKeysRemoved: MetadataConversion = { - id: 'mapping-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'mapping.extractQuery / mapping.errorPolicy / mapping.batchSize', - summary: "mapping keys 'extractQuery'/'errorPolicy'/'batchSize' removed (no exporter reads a mapping, error handling belongs to the import request, and the write path sizes its own batches)", - apply(stack, emit) { - const RETIRED = ['extractQuery', 'errorPolicy', 'batchSize']; - // Scoped to the `mappings` collection deliberately: `batchSize` is live on - // connector, sync, bulk-action and offline shapes, and a stack-wide strip - // would delete an enforced key from all of them. - return mapCollection(stack, 'mappings', (m, path) => stripKeys(m, RETIRED, emit, path)); - }, - fixture: { - before: { - mappings: [{ - name: 'csv_import_contacts', - targetObject: 'contact', - fieldMapping: [{ source: 'Email', target: 'email' }], - extractQuery: { object: 'contact', fields: ['email'] }, - errorPolicy: 'abort', - batchSize: 500, - }], - }, - after: { - mappings: [{ - name: 'csv_import_contacts', - targetObject: 'contact', - fieldMapping: [{ source: 'Email', target: 'email' }], - }], - }, - expectedNotices: 3, - }, -}; - -/** - * Inline book translation maps removed (protocol 17, #4667, ADR-0049). - * - * `book.translations` and `book.groups[].translations` were parsed, stored and - * round-tripped, and read by nothing: the tree endpoint and the docs portal - * render `label` / `description` verbatim, and the generic bundle translator - * covers view / action / object / app / dashboard / page only. A localized book - * therefore shipped its authoring-locale strings to every reader. - * - * The trap was PROXIMITY: `doc.translations` — same name, same shape, two files - * over — is live on every doc render path, so the book-level map read as the - * same feature one level up. - * - * Split routes, because the two schemas differ: `BookSchema` is `strictObject` - * (key deleted, `guidance` carries the prescription, ledger row deleted), while - * `BookGroupSchema` is a plain `z.object` with no `.strict()` — a bare delete - * there would have zod silently strip the key, so it is tombstoned with - * `retiredKey` and its ledger row STAYS. - */ -const bookTranslationsRemoved: MetadataConversion = { - id: 'book-translations-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'book.translations / book.groups.translations', - summary: "book keys 'translations' (book-level and group-level) removed (no resolver read them; the tree endpoint and portal render labels verbatim, so a localized book served its authoring locale to everyone). Localize the docs instead: `doc.translations` is live", - apply(stack, emit) { - return mapCollection(stack, 'books', (book, path) => { - const next = stripKeys(book, ['translations'], emit, path); - const groups = next.groups; - if (!Array.isArray(groups)) return next; - let touched = false; - const mapped = groups.map((g, i) => { - if (!g || typeof g !== 'object' || Array.isArray(g)) return g; - const stripped = stripKeys( - g as Record, - ['translations'], - emit, - `${path}.groups[${i}]`, - ); - if (stripped !== g) touched = true; - return stripped; - }); - return touched ? { ...next, groups: mapped } : next; - }); - }, - fixture: { - before: { - books: [{ - name: 'crm_guide', - label: 'CRM Guide', - translations: { 'zh-CN': { label: 'CRM 指南' } }, - groups: [{ - key: 'basics', - label: 'Basics', - translations: { 'zh-CN': { label: '基础' } }, - }], - }], - }, - after: { - books: [{ - name: 'crm_guide', - label: 'CRM Guide', - groups: [{ key: 'basics', label: 'Basics' }], - }], - }, - // Two notices: the book-level map and the one on its single group. - expectedNotices: 2, - }, -}; - -/** - * `job.id` removed (protocol 17, #4667, ADR-0049). - * - * Nothing read it, and its own `describe()` — "defaults to `name` when omitted" - * — advertised an identity override that never existed. `name` is the identity - * at every layer that has one: the scheduling key, the `sys_job` row key (the - * DB adapter upserts by `name` and mints its own row id), and the - * `JobExecution.jobId` stamp. Two jobs differing only in `id` were never two - * jobs — they were one job declared twice. - */ -const jobIdRemoved: MetadataConversion = { - id: 'job-id-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'job.id', - summary: "job key 'id' removed (nothing read it; `name` is the job's identity everywhere, so two jobs differing only in `id` were the same job, and the key's own description advertised an override that did not exist)", - apply(stack, emit) { - return mapCollection(stack, 'jobs', (job, path) => stripKeys(job, ['id'], emit, path)); - }, - fixture: { - before: { - jobs: [{ - name: 'nightly_sync', - id: 'job_nightly', - schedule: { type: 'cron', expression: '0 0 * * *' }, - handler: 'syncAll', - }], - }, - after: { - jobs: [{ - name: 'nightly_sync', - schedule: { type: 'cron', expression: '0 0 * * *' }, - handler: 'syncAll', - }], - }, - expectedNotices: 1, - }, -}; - -/** - * `translation.validationMessages` removed (protocol 17, #4667, ADR-0049). - * - * A translation group with no reader — objectui's spec-translations transform - * passed it through to the client tree and nothing downstream consumed it. The - * platform's own signature was on it twice: the schema example showed a - * concrete override, and #3778's legacy-key migration table steered retired - * `errors:` authors straight into it. Retiring one dead key by pointing authors - * at another is the failure this conversion also fixes — that guidance entry is - * rewritten in the same change to say rule messages are authored on the rule - * (`object.validations[].message`), not translated through a group. - * - * Since 17.3.0 (#14381, #14253) there is a group again and that same guidance - * entry names it: `objects.._validations..message`, - * which the write path resolves. It is not `validationMessages` returning — - * that one was keyed by rule name at the TOP level, so it could not tell two - * objects' rules apart, and nothing read it; this one is object-scoped and the - * rule evaluator reads it through the existing `i18nService` channel. The - * sentence above is what 17.0.0 said and it stays true of the retired key; an - * author arriving at this conversion needs both halves, so the summary below - * carries the route too. - * - * Removed from the shared `translationDataShape()`, so it retires at BOTH doors - * at once — the bundle entry and the registered item. #3778's original guard - * ran on the item door only, which is exactly how the key survived this long in - * file-authored bundles. - */ -const translationValidationMessagesRemoved: MetadataConversion = { - id: 'translation-validation-messages-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'translation.validationMessages', - summary: "translation key 'validationMessages' removed (no resolver read it, so a translated rule message was stored and never shown; the legacy-key table of the translation-bundle migration had been steering retired `errors:` authors into it). Author the message on the rule itself (`object.validations[].message`), and translate it under the object-scoped group `objects.._validations..message`, which the write path resolves (17.3.0, a translation key shipped together with its reader)", - apply(stack, emit) { - return mapCollection(stack, 'translations', (t, path) => - stripKeys(t, ['validationMessages'], emit, path)); - }, - fixture: { - before: { - translations: [{ - name: 'zh_cn', - locale: 'zh-CN', - validationMessages: { discount_limit: '折扣不能超过40%' }, - messages: { 'common.save': '保存' }, - }], - }, - after: { - translations: [{ - name: 'zh_cn', - locale: 'zh-CN', - messages: { 'common.save': '保存' }, - }], - }, - expectedNotices: 1, - }, -}; - -/** - * `datasource.capabilities` removed (protocol 17, #4583). - * - * Eleven boolean flags, declared and strict-guarded, read by nothing. Pushdown - * is decided by the runtime driver's own `supports.*` object — a different - * mechanism entirely — so a datasource declaring `queryAggregations: false` - * never once changed which engine path ran. - * - * `readOnly` is why this one is not merely tidy-up. It reads as a safety - * property and was authored as one: the shipped CRM example labelled a - * datasource "Read Replica" on the strength of it, while the datasource - * accepted writes exactly like the primary. The key had already been MOVED - * twice toward somewhere it might be enforced — out of `config` in #4410, into - * `capabilities` in #4465 — and was inert at every address. This removes it - * instead of moving it a third time. - * - * Deliberately NOT converted to `external.allowWrites: false`, which is the - * enforced gate and the obvious-looking target: it applies only to FEDERATED - * datasources (`schemaMode` other than `managed`), so rewriting a managed - * datasource that way would produce a key that is equally inert for that author - * — the exact defect being retired, laundered through a migration. A managed - * datasource has no read-only gate at all (#4584); the honest conversion is a - * delete plus a rejection message that says so. - * - * `retiredFromLoadPath`: both shapes are `.strict()` and reject the key with - * its prescription (`RETIRED_CAPABILITIES`). - */ -const datasourceCapabilitiesRemoved: MetadataConversion = { - id: 'datasource-capabilities-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'datasource.capabilities', - summary: "datasource key 'capabilities' removed (eleven flags no code read; pushdown comes from the driver's own supports.*, and `readOnly` never made anything read-only)", - apply(stack, emit) { - return mapCollection(stack, 'datasources', (ds, path) => stripKeys(ds, ['capabilities'], emit, path)); - }, - fixture: { - before: { - datasources: [{ - name: 'analytics', - driver: 'sqlite', - config: { filename: ':memory:' }, - capabilities: { readOnly: true, queryAggregations: true }, - }], - }, - // One notice per datasource, not per flag: the block is what was removed. - after: { - datasources: [{ - name: 'analytics', - driver: 'sqlite', - config: { filename: ':memory:' }, - }], - }, - expectedNotices: 1, - }, -}; - -/** - * `datasource.readReplicas` — replica connections nothing ever opened (#4468). - * - * A lossless delete, and an unusually clear one: read/write splitting does not - * exist anywhere in the platform. `ConnectableDatasource` and - * `DatasourceConnectionSpec` carry no replicas field, the driver factory never - * reads the key, and no query path distinguishes a read from a write — so every - * statement always went to the primary regardless of what was declared here. - * - * Retired from the load path like every other key retired for *lying* rather - * than for being renamed. The distinction the registry draws (see - * `flow-node-wait-timeout-keys-removed`): a merely renamed key keeps a load - * window, because punishing an author for a spelling nobody warned them about - * is pointless. A key that misdescribed itself does not — silently absorbing it - * would let the author keep believing they had configured replica reads. - * - * Worth recording *why* this needed a conversion at all rather than passing - * unnoticed: #4410 had just taught the schema to validate each entry against the - * declared driver's config contract. Sources written between #4410 and here - * carry replica blocks that were *checked* — precise host names, correct port - * types, no typos — which is exactly the shape an author trusts most. The - * notice is what tells them the well-formed thing they wrote was never wired to - * anything. - */ -const datasourceReadReplicasRemoved: MetadataConversion = { - id: 'datasource-read-replicas-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'datasource.readReplicas', - summary: "datasource key 'readReplicas' removed (no driver opened a replica connection and no query path splits reads from writes; front replicas behind one endpoint and point `config` at it)", - apply(stack, emit) { - return mapCollection(stack, 'datasources', (ds, path) => stripKeys(ds, ['readReplicas'], emit, path)); - }, - fixture: { - before: { - datasources: [{ - name: 'warehouse', - driver: 'postgres', - config: { host: 'primary.internal', port: 5432, database: 'analytics' }, - readReplicas: [ - { host: 'replica-a.internal', port: 5432, database: 'analytics' }, - { host: 'replica-b.internal', port: 5432, database: 'analytics' }, - ], - }], - }, - // One notice per datasource, not per replica: the key is what was removed. - after: { - datasources: [{ - name: 'warehouse', - driver: 'postgres', - config: { host: 'primary.internal', port: 5432, database: 'analytics' }, - }], - }, - expectedNotices: 1, - }, -}; - -/** - * The legacy `datasource.config` spellings the driver factory used to read via - * undeclared `??` fallbacks, per canonical driver id — exactly the set that - * HAPPENED TO WORK before #4456, nothing more. - * - * Deliberately narrower than the schemas' rename-hint alias tables: an alias - * that only ever produced a rejection hint (`path:`, `dsn:`, `hostname:`, …) - * never worked at run time, so "converting" it would CHANGE behaviour — e.g. a - * stored sqlite `path:` fell back to `:memory:`, and rewriting it to - * `filename` would silently move the database. D2 scope guard: lossless and - * behaviour-preserving only. - * - * Driver-awareness is load-bearing: `database` means "rename to `filename`" - * ONLY under sqlite — for postgres/mysql/mongo it is a canonical key and must - * not be touched. - */ -const DATASOURCE_CONFIG_KEY_ALIASES: Readonly< - Partial>> -> = { - // `filename ?? file ?? database` was the factory's precedence; pair order - // mirrors it, so with both aliases present `file` wins and `database` is - // left shadowed — exactly what the `??` chain resolved to. - sqlite: [['file', 'filename'], ['database', 'filename']], - 'sqlite-wasm': [['file', 'filename'], ['database', 'filename']], - postgres: [['connectionString', 'url'], ['user', 'username']], - mysql: [['connectionString', 'url'], ['user', 'username']], - // `mongodb` since commit e2798fab7 — the canonical id was renamed from `mongo` so the - // contract canon matches what both boot hosts, the driver package and every - // URL scheme already said. Keyed by the CANONICAL id `resolveDriverId` - // returns, so a stored `driver: 'mongo'` still lands here through the alias. - mongodb: [['uri', 'url'], ['user', 'username']], -}; - -/** - * Datasource `config` legacy key aliases → canonical, per driver (protocol 17, - * #4456 — the #4410 close-out). - * - * #4410 gave `datasource.config` its per-driver zod gate, so the AUTHORING - * surface has exactly one spelling per key and rejects the legacy ones with a - * rename hint. What the gate could not fix is data at rest: a runtime - * datasource stored in `sys_metadata` before the gate may carry `file:` - * (sqlite), `connectionString:`/`user:` (postgres/mysql), or `uri:`/`user:` - * (mongo), and until now those kept working only because - * `createDefaultDatasourceDriverFactory` carried undeclared read-side `??` - * fallbacks — the exact PD #12 debt {@link flowNodeFilterAlias} pioneered the - * retirement path for. Deleting the fallbacks without this entry would - * silently change where a stored datasource's data lives (a sqlite `file:` - * row would fall back to `:memory:`). - * - * So the tolerance graduates here: every stored-row rehydration seam replays - * the full chain (`applyConversionsToStoredItem`, #3903), hands the factory - * the canonical key, and the factory reads ONE spelling. Precedence follows - * {@link renameKey}: a canonical key already present wins, which is also what - * the factory's `??` chains resolved to — and since #4923 the alias beside it - * is deleted when it merely repeats that value, or kept (for the authoring - * gate to refuse naming both) when the two disagree. - * - * **Retired from the load path** — not because the keys misdescribed - * themselves (they were honest spellings, merely undeclared), but because the - * authoring gate ALREADY rejects each of them with a rename hint - * (`strictUnknownKeyError` alias tables), and a live-window entry at - * `normalizeStackInput` would run before that gate and silently absorb the - * spelling #4410 deliberately made loud. Stored rows and `migrate meta` are - * exactly the `includeRetired` seams. - */ -const datasourceConfigDriverKeyAliases: MetadataConversion = { - id: 'datasource-config-driver-key-aliases', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'datasource.config', - summary: - "datasource config keys → canonical per driver: sqlite 'file'/'database' → 'filename', " - + "postgres/mysql 'connectionString' → 'url' and 'user' → 'username', mongo 'uri' → 'url' " - + "and 'user' → 'username' (undeclared driver-factory `??` fallbacks, graduated into this layer and deleted from the reader)", - apply(stack, emit) { - return mapDatasources(stack, (ds, path) => { - const kind = resolveDriverId(ds.driver); - const pairs = kind ? DATASOURCE_CONFIG_KEY_ALIASES[kind] : undefined; - if (!pairs) return ds; - const config = ds.config; - if (!isDict(config)) return ds; - let nextConfig = config; - for (const [from, to] of pairs) { - const renamed = renameKey(nextConfig, from, to); - if (!renamed) continue; // absent, or an ambiguous pair `renameKey` refuses (#4923) - emit({ from, to, path: `${path}.config.${to}` }); - nextConfig = renamed; - } - return nextConfig === config ? ds : { ...ds, config: nextConfig }; - }); - }, - fixture: { - before: { - datasources: [ - { name: 'app_db', driver: 'sqlite', config: { file: './data/app.db' } }, - // driver-id aliases resolve too — `sqlite3` selects the sqlite contract - { name: 'archive_db', driver: 'sqlite3', config: { database: './data/archive.db' } }, - { - name: 'warehouse', - driver: 'pg', - config: { connectionString: 'postgresql://db.internal:5432/analytics', user: 'analyst' }, - }, - // `database` is CANONICAL for mysql — only `user` converts - { name: 'orders', driver: 'mysql', config: { host: 'db.internal', database: 'orders', user: 'svc_orders' } }, - { name: 'events', driver: 'mongodb', config: { uri: 'mongodb://mongo.internal:27017/events' } }, - // Both spellings, DIFFERENT files (#4923): kept, so the authoring gate - // can refuse naming both rather than the loader picking a database. - { name: 'scratch', driver: 'sqlite', config: { filename: ':memory:', file: './scratch.db' } }, - ], - }, - after: { - datasources: [ - { name: 'app_db', driver: 'sqlite', config: { filename: './data/app.db' } }, - { name: 'archive_db', driver: 'sqlite3', config: { filename: './data/archive.db' } }, - { - name: 'warehouse', - driver: 'pg', - config: { url: 'postgresql://db.internal:5432/analytics', username: 'analyst' }, - }, - { name: 'orders', driver: 'mysql', config: { host: 'db.internal', database: 'orders', username: 'svc_orders' } }, - { name: 'events', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/events' } }, - { name: 'scratch', driver: 'sqlite', config: { filename: ':memory:', file: './scratch.db' } }, - ], - }, - // app_db 1 + archive_db 1 + warehouse 2 + orders 1 + events 1 + scratch 0. - expectedNotices: 6, - }, -}; - -/** - * `datasource.driver: 'mongo'` → `'mongodb'` (protocol 17, commit e2798fab7; - * retired from the load path at 18, #23543 F1). - * - * ## Why a stored value has to move at all - * - * `mongo` and `mongodb` were both accepted spellings from #4410 through 17 — - * this conversion never rescued a broken boot, and a stored row it has not - * reached still resolves the same contract and builds the same driver. What moved is the - * CANONICAL id: commit e2798fab7 landed the ruling that renamed it to `mongodb`, the spelling both boot - * hosts, the driver package (`@objectstack/driver-mongodb`) and every URL scheme - * already used, so that the id which selects a driver and the id which selects - * its config contract are one string with no mapping layer between them. - * - * That rename is visible in data because the canonical id is PUBLISHED as - * `DRIVER_CATALOG.id` (`@objectstack/service-datasource`), documented as "used - * as `datasource.driver`" — it is literally what Studio's connection form writes - * into a datasource row. After the rename the form emits `mongodb`, while every - * row written before it carries `mongo`. Left alone, one deployment's datasource - * list holds two spellings of one driver, and any surface that matches a stored - * `driver` against the published catalog id (a form pre-selecting the current - * driver, a grouped list, an equality filter) silently fails to match the older - * rows. So the stored value converges here rather than each reader learning to - * accept both. - * - * ## Why the data repair is D2 - * - * There is a concrete stored value with a lossless, behaviour-preserving - * rewrite, which is the D2 test exactly. `mongo` and `mongodb` resolve to the - * same contract and build the same driver, before and after, so replaying this - * cannot change where any data lives — contrast - * {@link datasourceConfigDriverKeyAliases}, whose scope guard exists because - * rewriting a sqlite `path:` WOULD have moved a database. - * - * Losslessness decides only that the data repair is D2. It does not decide - * whether the family ALSO owes a D3 entry — every retirement family does - * (`SemanticMigration`, `migrations/types.ts`). The rename into protocol 17 - * shipped before that rule and was not back-filled; the value's retirement at - * 18 is a family of its own, and its D3 entry is - * `datasource-driver-mongo-alias-retired`. - * - * ## Retired from the load path at 18 (#23543, ruling F1 A) - * - * Through 17 it stayed live because `mongo` was still a legal alias, so there - * was no refusal for a retirement to hand the author to (the verdict #23487 - * recorded). The ruling retired the value: `mongo` left the selection face, and - * `DriverType` refuses an authored `datasource.driver: 'mongo'` by name with - * the prescription naming `mongodb` (`DriverVocabularyEntry.retiredAliases`, - * `data/driver/config-registry.zod.ts`). So the authoring funnel no longer - * replays this entry, and an author is taught the canonical id instead of - * being rewritten. Stored rows are the `includeRetired` seams: stored-row - * rehydration (`applyConversionsToStoredItem`) and the artifact door still - * rewrite them, and `os migrate meta --from 16` lists the edit for existing - * sources, as step 17's mechanical member. The spelling keeps resolving the - * mongodb CONTRACT, which is what lets - * {@link datasourceConfigDriverKeyAliases} — ordered ahead of this entry — - * still reach a stored `driver: 'mongo'` row's legacy config keys. That D3 - * entry names what the chain cannot reach: an environment variable, a REST - * caller, a script. - */ -const datasourceDriverMongoToMongodb: MetadataConversion = { - id: 'datasource-driver-mongo-to-mongodb', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '17.7.0', - surface: 'datasource.driver', - summary: - "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver " - + 'package and the published DRIVER_CATALOG already used, so the id that selects a driver ' - + 'and the id that selects its config contract are one string with no mapping between them', - apply(stack, emit) { - return mapDatasources(stack, (ds, path) => { - // Only the exact legacy canon, trimmed and lower-cased the same way - // `resolveDriverId` reads it. `mongodb` is already canonical, and any other - // spelling (a plugin driver, a typo) is not this conversion's business. - if (typeof ds.driver !== 'string' || ds.driver.trim().toLowerCase() !== 'mongo') return ds; - emit({ from: 'mongo', to: 'mongodb', path: `${path}.driver` }); - return { ...ds, driver: 'mongodb' }; - }); - }, - fixture: { - before: { - datasources: [ - { name: 'events', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/events' } }, - // Already canonical — untouched, and emits nothing. - { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, - // A different driver whose id merely CONTAINS the string: never rewritten. - { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, - ], - }, - after: { - datasources: [ - { name: 'events', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/events' } }, - { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, - { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, - ], - }, - expectedNotices: 1, - }, -}; - -/** - * `script` node config — the four retired dispatch branches (protocol 17, #4343). - * - * A `script` node had four ways to name what it ran and only one of them ran - * anything. `actionType: 'email' | 'slack'` were logger-backed stubs: they wrote - * a line and reported success, and `template` / `recipients` / `variables` fed a - * message no channel ever sent — under any configuration, with or without the - * messaging service installed. Inline `config.script` was recognized and never - * executed (the built-in runtime has no server-side JS sandbox), so the node - * warned and no-op'd. Every remaining `actionType` value was shorthand for a - * registered-function name — a second spelling of `config.function` — and the - * `invoke_function` marker named nothing on its own. - * - * So the node converges on its one real path (call the function named by - * `config.function`), and the five keys leave the surface. This is what let the - * contract be parsed at execute time at all: while the legal key set depended on - * `actionType`, a flat parse would either reject valid shapes or wave everything - * through — see the module header of `automation/schemaless-node-config.zod.ts`. - * - * **Retired from the load path**, like every other key retired for lying rather - * than for being renamed (see `flow-node-wait-timeout-keys-removed` for the - * distinction the registry draws): silently absorbing `actionType: 'email'` - * would let an author keep believing the flow sends mail. - * - * A **shorthand `actionType` moves into `function`** rather than being dropped, - * because that is what it meant (#1870) — the same reasoning that moves - * `timeoutMs` into `timerDuration`. It moves only when `function` is not already - * set: with both present the executor always took `function`, so the shorthand - * was already dead metadata. The built-in ids and the `invoke_function` marker - * are never function names, so they are dropped, not moved. - * - * The other four keys are dropped outright: no reader ever consumed them, so - * there is no value to preserve. Rebuilding the intent is an authoring decision - * the tombstones prescribe per branch (`notify` for mail, a `connector_action` - * with the Slack connector — or `http` to a webhook — for Slack, a registered - * function for an inline body), not something a mechanical rewrite can guess. - * - * Ordering note: this runs AFTER `flow-node-script-config-aliases`, so the - * `functionName` → `function` rename has already happened when the shorthand - * rule asks whether `function` is set. - */ -const SCRIPT_RETIRED_BUILTIN_ACTION_TYPES = new Set(['email', 'slack']); -const SCRIPT_RETIRED_INVOKE_FUNCTION_MARKER = 'invoke_function'; - -function removeScriptBranchKeys(stack: Dict, emit: Emit): Dict { - return mapFlowNodes(stack, (node, path) => { - // Filtered to `script`, unlike the wait retirement: these tombstones live on - // the script config contract, which no other node type is parsed against. - if (node.type !== 'script') return node; - const cfg = node.config; - if (!isDict(cfg)) return node; - - const next: Dict = { ...cfg }; - let changed = false; - - if (next.actionType != null) { - const actionType = typeof next.actionType === 'string' ? next.actionType.trim() : ''; - const hasFunction = typeof next.function === 'string' && next.function.trim() !== ''; - const isShorthand = - actionType !== '' - && actionType !== SCRIPT_RETIRED_INVOKE_FUNCTION_MARKER - && !SCRIPT_RETIRED_BUILTIN_ACTION_TYPES.has(actionType); - - if (isShorthand && !hasFunction) { - next.function = actionType; - emit({ from: 'config.actionType', to: 'config.function', path: `${path}.config.function` }); - } else if (isShorthand) { - emit({ from: 'config.actionType', to: '(removed — `config.function` already named the callable)', path: `${path}.config` }); - } else { - emit({ from: 'config.actionType', to: '(removed — logger-backed stub or bare marker; nothing was delivered)', path: `${path}.config` }); - } - delete next.actionType; - changed = true; - } - - for (const key of ['template', 'recipients', 'variables'] as const) { - if (next[key] == null) continue; - emit({ from: `config.${key}`, to: '(removed — fed a side effect that never delivered)', path: `${path}.config` }); - delete next[key]; - changed = true; - } - - if (next.script != null) { - emit({ from: 'config.script', to: '(removed — inline JS was never executed)', path: `${path}.config` }); - delete next.script; - changed = true; - } - - return changed ? { ...node, config: next } : node; - }); -} - -const flowNodeScriptBranchKeysRemoved: MetadataConversion = { - id: 'flow-node-script-branch-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: - 'flow.node.script.config.actionType / flow.node.script.config.template / ' - + 'flow.node.script.config.recipients / flow.node.script.config.variables / ' - + 'flow.node.script.config.script', - summary: - "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — " - + "'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / " - + "'variables' (fed those stubs) and 'script' (inline JS the runtime never executed); script is now a pure function-call node, the only path that ran real logic", - apply(stack, emit) { - return removeScriptBranchKeys(stack, emit); - }, - fixture: { - before: { - flows: [ - { - name: 'task_lifecycle', - nodes: [ - { id: 'n1', type: 'start' }, - // The logger-backed stub in full: nothing here was ever delivered. - { - id: 'n2', - type: 'script', - config: { - actionType: 'email', - template: 'task_done', - recipients: ['{record.owner}'], - variables: { taskName: '{record.name}' }, - }, - }, - // Shorthand for a registered function — the one value that MOVES. - { id: 'n3', type: 'script', config: { actionType: 'score_lead', outputVariable: 'score' } }, - // Inline body: recognized, never executed. Dropped; the node is left - // naming no callable, which the execute-time parse now says out loud. - { id: 'n4', type: 'script', config: { script: 'return { ok: true };' } }, - // The marker alongside the canonical key: marker dropped, key kept. - { id: 'n5', type: 'script', config: { actionType: 'invoke_function', function: 'score_lead' } }, - // Already converged — left byte-identical. - { id: 'n6', type: 'script', config: { function: 'notify_owner', inputs: { id: '{record.id}' } } }, - ], - }, - ], - }, - after: { - flows: [ - { - name: 'task_lifecycle', - nodes: [ - { id: 'n1', type: 'start' }, - { id: 'n2', type: 'script', config: {} }, - { id: 'n3', type: 'script', config: { outputVariable: 'score', function: 'score_lead' } }, - { id: 'n4', type: 'script', config: {} }, - { id: 'n5', type: 'script', config: { function: 'score_lead' } }, - { id: 'n6', type: 'script', config: { function: 'notify_owner', inputs: { id: '{record.id}' } } }, - ], - }, - ], - }, - // n2: actionType + template + recipients + variables. n3: the move. - // n4: script. n5: the marker. n6: nothing. - expectedNotices: 7, - }, -}; - -/** - * `object.managedBy: 'system'` → `'system-data'` (protocol 17, #3355 — the v17 - * close-out of ADR-0103's v16 enum split). - * - * v16 split the overloaded `system` bucket ADDITIVELY: the 20 engine-owned - * objects moved to the new explicit `engine-owned`, and the 8 admin/user-writable - * ones stayed on `system`. The value that remained therefore labelled the exact - * opposite of what its name said — writable platform DATA under the word - * "system" — and left an author choosing between `system` and `engine-owned` with - * nothing in the vocabulary to choose on. v17 renames the residue to - * `system-data` and retires the bare value. - * - * Because v16 already drained the engine side, this is a ONE-TO-ONE mechanical - * replacement with no judgement call: every remaining `system` declaration is, - * by construction, writable platform data. - * - * **Retired from the load path** — the enum rejects `'system'` with the - * {@link MANAGED_BY_SYSTEM_RETIRED} prescription, and that rejection is the - * whole point: a live-window entry at `normalizeStackInput` would run BEFORE the - * enum and silently absorb the value, so an author (or a model) would keep - * writing the name the rename exists to unteach. Stored `sys_metadata` rows and - * `os migrate meta --from 16` are exactly the `includeRetired` seams, so data at - * rest is CONVERTED rather than reinterpreted. - * - * Note the affordance side-effect, which is deliberate and is why this is a - * major-window change rather than a docs fix: `system` defaulted LOCKED and each - * object opened its writes via `userActions`, while `system-data` defaults - * WRITABLE. A converted row that carried no `userActions` therefore gains the - * generic affordances — which is the honest reading of the bucket it is being - * moved into, and changes no enforcement: the write guard, the delegated-admin - * gate, RLS and permission sets all adjudicate independently of the bucket name. - */ -const objectManagedBySystemToSystemData: MetadataConversion = { - id: 'object-managed-by-system-to-system-data', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'object.managedBy', - summary: - "object managedBy 'system' → 'system-data' (ADR-0103's residual bucket named the " - + 'engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the ' - + 'name describing what the bucket actually holds: admin/user-writable platform data)', - apply(stack, emit) { - return mapCollection(stack, 'objects', (obj, path) => { - if (obj.managedBy !== 'system') return obj; - emit({ from: 'system', to: 'system-data', path: `${path}.managedBy` }); - return { ...obj, managedBy: 'system-data' }; - }); - }, - fixture: { - before: { - objects: [ - { - name: 'sys_user_position', - label: 'User Position', - managedBy: 'system', - userActions: { create: true, edit: true, delete: true }, - }, - // every other bucket passes through untouched — including `engine-owned`, - // the value v16 already moved the engine side to - { name: 'sys_automation_run', label: 'Automation Run', managedBy: 'engine-owned' }, - { name: 'crm_deal', label: 'Deal' }, - ], - }, - after: { - objects: [ - { - name: 'sys_user_position', - label: 'User Position', - managedBy: 'system-data', - userActions: { create: true, edit: true, delete: true }, - }, - { name: 'sys_automation_run', label: 'Automation Run', managedBy: 'engine-owned' }, - { name: 'crm_deal', label: 'Deal' }, - ], - }, - expectedNotices: 1, - }, -}; - -/** - * The dead object capability flags leave the surface (protocol 17, #3207 — - * the #2377 ADR-0049 close-out slice PR #3414 removed from the schema). - * - * `enable.trash` promised a recycle bin and `enable.mru` promised - * Most-Recently-Used tracking; neither ever had a behavior-changing reader — - * every delete has always been a hard delete, and no MRU state was ever - * written. Both defaulted `true`, the ADR-0078 silent-failure shape: authors - * wrote `trash: false // never soft-delete audit logs` believing they were - * opting out of a soft-delete that never ran. Soft delete stays parked at - * #3146; if built it returns as a live enforced flag (ADR-0049). - * - * `retiredFromLoadPath`: the capabilities block is `.strict()` and rejects - * both keys with the prescription (`CAPABILITIES_RETIRED_KEY_GUIDANCE`), so a - * live author is taught at parse; this entry exists so stored 16.x rows - * replay clean (`applyConversionsToStoredItem` — without it a pre-removal row - * flags `metadata_spec_invalid` forever, mislabelling chain-owned history as - * a current-contract violation) and so `os migrate meta --from 16` lists the mechanical edits for existing sources. - */ -const objectEnableTrashMruRemoved: MetadataConversion = { - id: 'object-enable-trash-mru-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'object.enable.trash / object.enable.mru', - summary: - "object capability flags 'enable.trash'/'enable.mru' removed (the last slice of the dead author-facing property removals: no " - + 'recycle bin and no MRU tracking ever ran; both default-true flags gated nothing)', - apply(stack, emit) { - return mapCollection(stack, 'objects', (obj, path) => { - // `enable.*` sits one level down, so stripKeys (top-level only) cannot - // reach it — drill in, and copy-on-write so an untouched object keeps - // its identity (pattern of `datasource-inert-blocks-removed`). - const enable = obj.enable; - if (!enable || typeof enable !== 'object' || Array.isArray(enable)) return obj; - const stripped = stripKeys( - enable as Record, - ['trash', 'mru'], - emit, - `${path}.enable`, - ); - if (stripped === enable) return obj; - return { ...obj, enable: stripped }; - }); - }, - fixture: { - before: { - objects: [ - { - name: 'task', - label: 'Task', - enable: { trash: false, mru: true, searchable: true }, - }, - // an object without the retired keys passes through untouched - { name: 'crm_account', label: 'Account', enable: { searchable: true } }, - ], - }, - // Two notices: one per removed key; the surviving `searchable` proves the - // strip is surgical, not a block-level delete. - after: { - objects: [ - { - name: 'task', - label: 'Task', - enable: { searchable: true }, - }, - { name: 'crm_account', label: 'Account', enable: { searchable: true } }, - ], - }, - expectedNotices: 2, - }, -}; - -/** - * `indexes[].type` / `indexes[].partial` leave the surface (protocol 17, - * #5248 + #4943, ADR-0049 enforce-or-remove). - * - * Neither key had a single DDL consumer. `SqlDriver.syncDeclaredIndexes` builds - * every declared index through knex's `table.index(fields, name)` / - * `table.unique(fields, { indexName })`, and the differ's `DeclaredIndexInput` - * declares `name` / `fields` / `unique` / `nullSafeColumns` — so an authored - * `type` selected no access method and an authored `partial` produced a FULL - * index with the predicate silently discarded. `type` additionally carried - * `.default('btree')`, so it was materialized into *every* parse output: the - * ADR-0078 shape where an inert knob reads as live configuration. - * - * The maintainer chose remove over enforce (#5248, 2026-08-06): enforcing means - * per-dialect algorithm mapping, raw-SQL `CREATE INDEX … WHERE` (MySQL has no - * partial index), and reworking how `isSyncReproducibleIndex` excludes partial - * indexes from incremental sync — design cost for a capability with no demand. - * - * ⚠️ NOT the same `partial` as the driver's: `schema-drift.ts` carries a - * `partial: boolean` parsed back out of the DATABASE's own `CREATE INDEX` DDL - * (`parseIndexDdl`), consumed by drift detection. That is untouched here, and - * the exemption it grants DB-authored partial indexes still stands. - * - * `retiredFromLoadPath`: both keys are tombstoned at `IndexSchema`, so a live - * author is taught by the parse error. This entry exists so stored ≤16 rows - * replay clean through `applyConversionsToStoredItem` (without it a - * pre-removal row would flag `metadata_spec_invalid` forever) and so - * `os migrate meta --from 16` lists the mechanical edits for existing sources. - */ -const objectIndexTypePartialRemoved: MetadataConversion = { - id: 'object-index-type-partial-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'object.indexes[].type / object.indexes[].partial', - summary: - "object index keys 'indexes[].type'/'indexes[].partial' removed (no driver " - + 'ever read either: the index method is the dialect\'s choice and a partial index is built ' - + 'by a database-layer migration, not declared)', - apply(stack, emit) { - // `indexes` is an ARRAY one level down, so `stripKeys` (top-level only) - // cannot reach it — drill in and copy-on-write, so an object whose indexes - // carry neither key keeps its identity. Both `objects[]` and - // `objectExtensions[]` embed `IndexSchema`, so both are walked. - const stripIndexes = (owner: Dict, path: string): Dict => { - const indexes = owner.indexes; - if (!Array.isArray(indexes)) return owner; - let changed = false; - const next = indexes.map((idx, i) => { - if (!isDict(idx)) return idx; - const stripped = stripKeys(idx, ['type', 'partial'], emit, `${path}.indexes[${i}]`); - if (stripped !== idx) changed = true; - return stripped; - }); - return changed ? { ...owner, indexes: next } : owner; - }; - let out = mapCollection(stack, 'objects', stripIndexes); - out = mapCollection(out, 'objectExtensions', stripIndexes); - return out; - }, - fixture: { - before: { - objects: [ - { - name: 'crm_invoice', - label: 'Invoice', - indexes: [ - // both retired keys on one index, alongside surviving ones - { - name: 'idx_invoice_active_no', - fields: ['invoice_no'], - unique: 'global', - type: 'btree', - partial: "state = 'active'", - }, - // an index carrying neither key passes through untouched - { name: 'idx_invoice_customer', fields: ['customer'] }, - ], - }, - ], - objectExtensions: [ - { - extend: 'crm_invoice', - indexes: [{ fields: ['tags'], type: 'gin' }], - }, - ], - }, - // Three notices: two keys on the object's first index, one on the - // extension's. The surviving `name`/`fields`/`unique` prove the strip is - // surgical rather than an index-level delete. - after: { - objects: [ - { - name: 'crm_invoice', - label: 'Invoice', - indexes: [ - { name: 'idx_invoice_active_no', fields: ['invoice_no'], unique: 'global' }, - { name: 'idx_invoice_customer', fields: ['customer'] }, - ], - }, - ], - objectExtensions: [ - { - extend: 'crm_invoice', - indexes: [{ fields: ['tags'] }], - }, - ], - }, - expectedNotices: 3, - }, -}; - -/** - * The retry policy converges to one declaration (protocol 17, #4661 — the - * #4535 C8 dual-source cluster). - * - * `@objectstack/spec/automation` and `@objectstack/spec/system` both exported a - * `RetryPolicy` / `RetryPolicySchema`, resolving to DIFFERENT declarations — so - * which shape a consumer got depended only on the import path (#4411). They - * were never two concepts: `try_catch`'s `retry` region and `job.retryPolicy` - * both compute `delay = base * multiplier^(retry-1)`, and the two executors - * implemented that identical formula. What differed was cosmetic and - * accidental, and this conversion pays for both halves of the merge: - * - * 1. **The base delay had two spellings.** automation said `retryDelayMs`, - * system said `backoffMs`. `backoffMs` wins — it is what the *enforced* - * retry policies already spell it (`job.retryPolicy` and `hook.retryPolicy`; - * see the `datasource-inert-blocks-removed` note above, which leans on - * exactly that distinction), so the platform drops from three spellings to - * two rather than four. `retryDelayMs` is tombstoned (`retiredKey`) — NOT - * deleted — because two of the three owning shapes are not `.strict()`: a - * plain deletion would have Zod silently swallow the authored number and - * fall back to the 1000ms default, which is the quiet-failure class - * ADR-0049 removes. - * - * 2. **The defaults were opposite, and no gate can see a default.** Pre-17, - * `job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` - * while the automation shape defaulted 0 / 1. The merged declaration takes - * 0 / 1 (retry is opt-in: a retry replays whatever the attempt already did, - * and an implicit replay of side effects is the failure mode hardest to - * catch in tests — the same reading already recorded for flow-level retry - * in `flow-retry-max-retries-required`, #4247). Taken alone that would - * SILENTLY stop existing jobs from retrying, and the authorable-surface - * gate would never notice: it compares key sets, and a default is not a key. - * So this conversion writes the pre-17 numbers into every existing - * `job.retryPolicy` that omitted them. Deployed stacks keep their exact - * behaviour; only a newly authored omission means "no retry". - * - * Jobs with no `retryPolicy` block at all are left alone — absence already - * meant a single attempt on both sides of the change. - * - * ## The ONE further surface this entry grew to cover (#4964 / #4962) - * - * The convergence above was driven by the dual-source instrument, whose - * question is "how many declarations share one exported NAME?". Two further - * encodings of the identical policy were invisible to it because they were - * anonymous inline `z.object`s with no exported name at all — and after a - * convergence lands, a surviving dialect reads as reviewed-and-kept rather - * than missed. One of the two is still a surface; the other went with its - * layer: - * - * - **`flow.errorHandling`** (#4964) spelled the base delay `retryDelayMs`; - * every other key, bound and default already matched. Step 0 below renames - * it, so the ONE authorable casualty of the whole convergence is still just - * that word — now retired everywhere it was ever legal rather than only on - * the two shapes #4661's instrument could see. - * - **`ETLPipeline.retry`** (#4962) spelled the count `maxAttempts` and - * defaulted it to 3. It got **no step here, deliberately** — and never will. - * An ETL pipeline was not a `defineStack` collection and `etl.zod.ts` had no - * parse site in objectstack / objectui / cloud (批 12's measurement), so - * there was no stored or authored document a walker could reach: a branch - * for it would have been dead code claiming migration coverage that does not - * exist, which is the ADR-0049 failure this registry is supposed to prevent, - * not commit. #6414 then retired the whole L2 layer, and #4962's own entry - * (`etl-retry-converged-onto-retry-policy`) was ABSORBED into - * `etl-pipeline-layer-retired` inside the same unreleased major. There is no - * `maxAttempts` tombstone left to carry the rename or the default change: it - * went with the shape that carried it, which is strictly stronger, because - * no `retry` block survives to author the key into. `tsc` (TS2724/TS2305 on - * the removed ETL names) is the whole channel. That is also why the ETL - * default flip 3 → 0 never needed a materialization step while the job one - * did: nothing was ever deployed under the old reading. - * - * ## Retired from the load path at 18 (#23543, ruling F2 A) - * - * The base-delay rename never needed the load path: every surface that carried - * `retryDelayMs` refuses it with the rename to `backoffMs` (the - * `RetryPolicySchema` tombstone, which a `try_catch` node's `config.retry` is, - * and `flow.errorHandling`'s own). It stayed for the job half alone (the - * verdict #23487 recorded): retired, the authoring funnel would stop writing - * `maxRetries: 3` into a source block that omits it, and such a job would - * silently drop from three retries to none — a default flip no gate can see. - * The ruling made that drop loud instead: the job parse refuses a - * `retryPolicy` whose `maxRetries` is omitted or 0, naming the fix - * (`JOB_RETRY_POLICY_NO_ATTEMPTS`, `system/job.zod.ts`), so no authored job - * changes its retry count without its author being told. An omitted - * `backoffMultiplier` is not refused and now keeps its declared default (1): - * it shapes the delay curve, not whether the job retries. - * - * The authoring funnel (`defineStack`, `os validate` / `os build` / `os lint`) - * no longer replays it. Stored documents are the `includeRetired` seams — - * stored-row rehydration (`applyConversionsToStoredItem`), the automation - * engine's flow rehydration, the metadata save door's flow fallback, the - * artifact-ingestion door inside its window — and `os migrate meta --from 16` - * (a step-17 member, carried by the D3 entry `job-retry-policy-without-attempts-refused`), - * so a job stored or written before 17 keeps the three retries it ran with. - */ -const retryPolicyConverged: MetadataConversion = { - id: 'retry-policy-converged', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '17.7.0', - surface: 'flow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier', - summary: - "retry policy unified across job.retryPolicy, try_catch retry and flow.errorHandling: base delay 'retryDelayMs' → 'backoffMs', " + - "and the pre-17 job defaults (maxRetries 3, backoffMultiplier 2) written out explicitly now that the merged default is 0 / 1: two declarations that differed only by accident became one, and retry is opt-in because a retry replays whatever the attempt already did", - apply(stack, emit) { - // ── 0. flows: errorHandling.retryDelayMs → errorHandling.backoffMs ─ - // - // The FLOW-LEVEL retry policy (#4964). Same rename as the try_catch node - // below and for the same reason, reached one level up: `errorHandling` - // hangs off the flow document, not off a node, so `mapFlowNodes` walks - // straight past it — which is a small echo of why this divergence survived - // #4661 at all. `renameKey` leaves an already-canonical `backoffMs` alone; - // when BOTH spellings are present it now (#4923) deletes `retryDelayMs` - // only if it repeats the same number, and otherwise keeps both rather than - // guessing which delay the author meant — the strict block then rejects - // naming both. This entry relied on the shared helper's semantics rather - // than open-coding its own precisely so it would move with that ruling, - // and it did: no change was needed here. - const withErrorHandling = mapCollection(stack, 'flows', (flow, path) => { - const eh = flow.errorHandling; - if (!eh || typeof eh !== 'object' || Array.isArray(eh)) return flow; - const renamed = renameKey(eh as Record, 'retryDelayMs', 'backoffMs'); - if (renamed === null) return flow; - emit({ from: 'retryDelayMs', to: 'backoffMs', path: `${path}.errorHandling.backoffMs` }); - return { ...flow, errorHandling: renamed }; - }); - - // ── 1. try_catch nodes: retry.retryDelayMs → retry.backoffMs ────── - const withFlows = mapFlowNodes(withErrorHandling, (node, path) => { - if (node.type !== 'try_catch') return node; - const config = node.config; - if (!config || typeof config !== 'object' || Array.isArray(config)) return node; - const configDict = config as Record; - const retry = configDict.retry; - if (!retry || typeof retry !== 'object' || Array.isArray(retry)) return node; - const renamed = renameKey(retry as Record, 'retryDelayMs', 'backoffMs'); - if (renamed === null) return node; - emit({ from: 'retryDelayMs', to: 'backoffMs', path: `${path}.config.retry.backoffMs` }); - return { ...node, config: { ...configDict, retry: renamed } }; - }); - - // ── 2. jobs: materialize the pre-17 implicit defaults ───────────── - return mapCollection(withFlows, 'jobs', (job, path) => { - const policy = job.retryPolicy; - if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return job; - const policyDict = policy as Record; - let next = policyDict; - if (next.maxRetries === undefined) { - next = { ...next, maxRetries: 3 }; - emit({ - from: 'maxRetries unset (implied 3)', - to: 'maxRetries: 3', - path: `${path}.retryPolicy.maxRetries`, - }); - } - if (next.backoffMultiplier === undefined) { - next = { ...next, backoffMultiplier: 2 }; - emit({ - from: 'backoffMultiplier unset (implied 2)', - to: 'backoffMultiplier: 2', - path: `${path}.retryPolicy.backoffMultiplier`, - }); - } - return next === policyDict ? job : { ...job, retryPolicy: next }; - }); - }, - fixture: { - before: { - flows: [{ - name: 'sync_orders', - // Flow-LEVEL policy (#4964) — one level up from the nodes, so the node - // walk below never sees it. - errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, - nodes: [ - { id: 'n1', type: 'start' }, - { - id: 'n2', - type: 'try_catch', - config: { - try: { nodes: [], edges: [] }, - retry: { maxRetries: 3, retryDelayMs: 500, jitter: true }, - }, - }, - // Already canonical — left alone, contributes no notice. - { - id: 'n3', - type: 'try_catch', - config: { try: { nodes: [], edges: [] }, retry: { maxRetries: 2, backoffMs: 250 } }, - }, - ], - }, { - // Flow-level block already canonical — left alone, no notice. - name: 'roll_up', - errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 100 }, - nodes: [{ id: 'n1', type: 'start' }], - }], - jobs: [ - // Omits both defaults — both get written out. - { name: 'nightly_sync', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'jobs.ts:sync', retryPolicy: { backoffMs: 5000 } }, - // States both — untouched. - { name: 'hourly_roll', schedule: { type: 'cron', expression: '0 * * * *' }, handler: 'jobs.ts:roll', retryPolicy: { maxRetries: 1, backoffMultiplier: 3 } }, - // No policy block at all — absence already meant one attempt. - { name: 'weekly_purge', schedule: { type: 'cron', expression: '0 0 * * 0' }, handler: 'jobs.ts:purge' }, - ], - }, - after: { - flows: [{ - name: 'sync_orders', - errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }, - nodes: [ - { id: 'n1', type: 'start' }, - { - id: 'n2', - type: 'try_catch', - config: { - try: { nodes: [], edges: [] }, - retry: { maxRetries: 3, jitter: true, backoffMs: 500 }, - }, - }, - { - id: 'n3', - type: 'try_catch', - config: { try: { nodes: [], edges: [] }, retry: { maxRetries: 2, backoffMs: 250 } }, - }, - ], - }, { - name: 'roll_up', - errorHandling: { strategy: 'retry', maxRetries: 1, backoffMs: 100 }, - nodes: [{ id: 'n1', type: 'start' }], - }], - jobs: [ - { name: 'nightly_sync', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'jobs.ts:sync', retryPolicy: { backoffMs: 5000, maxRetries: 3, backoffMultiplier: 2 } }, - { name: 'hourly_roll', schedule: { type: 'cron', expression: '0 * * * *' }, handler: 'jobs.ts:roll', retryPolicy: { maxRetries: 1, backoffMultiplier: 3 } }, - { name: 'weekly_purge', schedule: { type: 'cron', expression: '0 0 * * 0' }, handler: 'jobs.ts:purge' }, - ], - }, - // sync_orders' flow-level rename, n2's node-level rename, plus - // nightly_sync's two materialized defaults. - expectedNotices: 4, - }, -}; - -/** - * The `crypto.hash` capability token leaves `HookBodyCapability` (protocol 17, - * #4391 — ADR-0049 enforce-or-remove). - * - * Four layers advertised the token and none implemented it: the spec enum, the - * doc table beside it, the CLI's build-time extractor (which INFERRED the token - * from a `ctx.crypto.hash` call, so `os build` blessed the very body that was - * about to fail) and `ScriptContext.crypto.hash`. `installCtx` wired only - * `randomUUID`, so the single call the token authorised threw inside the VM. - * Removed rather than implemented: crypto in the sandbox widens its capability - * and security-review surface, and zero complaints against a capability that - * throws on every use is the strongest liveness evidence there is. - * - * This is an enum-VALUE retirement, so there is no `retiredKey()` tombstone to - * hang the prescription on — the enum's own error map carries it - * (`CRYPTO_HASH_RETIRED`, data/hook-body.zod.ts), keyed on `issue.input` so that - * only the value which used to be legal gets the "was removed" message. - * - * `retiredFromLoadPath`: the enum rejects the token outright, so a live author - * is taught at parse rather than silently rewritten. The entry exists so stored - * 16.x/17-rc rows replay clean (`applyConversionsToStoredItem` — without it a - * pre-removal row flags `metadata_spec_invalid` forever, mislabelling - * chain-owned history as a current-contract violation) and so - * `os migrate meta --from 16` lists the mechanical edits for author sources. The - * `object-enable-trash-mru-removed` precedent, one level deeper: the token is a - * VALUE inside `body.capabilities`, not a key, so `stripKeys` cannot reach it. - * - * Note what the strip deliberately does NOT do: it removes the dead grant, not - * the `ctx.crypto.hash(...)` call the body made under it. That call has never - * returned a value, so nothing regresses — but the author is still left a dead - * line to delete, which is exactly what the prescription tells them to do. - */ -const hookBodyCryptoHashRemoved: MetadataConversion = { - id: 'hook-body-crypto-hash-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'hook.body.capabilities / action.body.capabilities', - summary: - "script-body capability token 'crypto.hash' removed (the sandbox never installed " - + 'ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too)', - apply(stack, emit) { - const stripToken = (item: Dict, path: string): Dict => { - const body = item.body; - if (!body || typeof body !== 'object' || Array.isArray(body)) return item; - const caps = (body as Dict).capabilities; - if (!Array.isArray(caps) || !caps.includes('crypto.hash')) return item; - emit({ from: 'crypto.hash', to: '(removed)', path: `${path}.body.capabilities` }); - return { - ...item, - body: { ...(body as Dict), capabilities: caps.filter((c) => c !== 'crypto.hash') }, - }; - }; - const withHooks = mapCollection(stack, 'hooks', stripToken); - return mapCollection(withHooks, 'actions', stripToken); - }, - fixture: { - before: { - hooks: [ - { - name: 'fingerprint_lead', - object: 'crm_lead', - events: ['beforeInsert'], - body: { - language: 'js', - source: "ctx.input.fp = await ctx.crypto.hash('sha256', ctx.input.email);", - capabilities: ['crypto.hash', 'crypto.uuid'], - }, - }, - // a body without the retired token passes through untouched - { - name: 'stamp_lead', - object: 'crm_lead', - events: ['beforeInsert'], - body: { - language: 'js', - source: 'ctx.input.trace = ctx.crypto.randomUUID();', - capabilities: ['crypto.uuid'], - }, - }, - ], - actions: [ - { - name: 'digest_deal', - type: 'script', - body: { - language: 'js', - source: "return ctx.crypto.hash('sha256', ctx.record.id);", - capabilities: ['crypto.hash'], - }, - }, - ], - }, - // Surgical: `crypto.uuid` survives beside the stripped token, and the - // `capabilities` key itself stays (an empty grant set is legal). - after: { - hooks: [ - { - name: 'fingerprint_lead', - object: 'crm_lead', - events: ['beforeInsert'], - body: { - language: 'js', - source: "ctx.input.fp = await ctx.crypto.hash('sha256', ctx.input.email);", - capabilities: ['crypto.uuid'], - }, - }, - { - name: 'stamp_lead', - object: 'crm_lead', - events: ['beforeInsert'], - body: { - language: 'js', - source: 'ctx.input.trace = ctx.crypto.randomUUID();', - capabilities: ['crypto.uuid'], - }, - }, - ], - actions: [ - { - name: 'digest_deal', - type: 'script', - body: { - language: 'js', - source: "return ctx.crypto.hash('sha256', ctx.record.id);", - capabilities: [], - }, - }, - ], - }, - // One per rewritten body — the hook and the action; `stamp_lead` is untouched. - expectedNotices: 2, - }, -}; - -/** - * `array_agg` / `string_agg` leave `AggregationFunction` (protocol 17, #6188 — - * ADR-0049 enforce-or-remove). - * - * The enum declared eight functions; the SQL family compiles five. - * `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each - * lower `count`/`sum`/`avg`/`min`/`max` and route everything else to the same - * refusal, and `service-analytics` carried a hand-written `UNSUPPORTED_AGGREGATES` - * list naming exactly these two — a subtraction that existed to stop them - * reaching the Cube strategy's `default`, which returned `COUNT(*)`: a row count - * in place of the number the author asked for. So the declaration was not merely - * unenforced, it was the reason another package had to carry a denylist. - * - * The maintainer's 2026-08-07 ruling SPLIT the three unlowered functions rather - * than retiring them as a block, and the split is the substance of this entry. - * `count_distinct` stays: one portable lowering (`COUNT(DISTINCT x)`), a - * dashboard staple, and `service-analytics` already lowers it — so it takes - * ADR-0049's ENFORCE leg and the SQL implementation follows on its own card. - * These two take the REMOVE leg: display conveniences with no measured pull, - * and `string_agg` has no single shape to lower to at all (the delimiter is a - * second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently - * named function in SQL Server). - * - * Like `hook-body-crypto-hash-removed` above, this is an enum-VALUE retirement: - * there is no `retiredKey()` tombstone to hang the prescription on, so the enum's - * own error map carries it (`ARRAY_AGG_RETIRED` / `STRING_AGG_RETIRED`, - * `data/query.zod.ts`), keyed on `issue.input` so only the two spellings that - * used to be legal are told they "were removed". For the same reason nothing - * lands in `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are expected to - * be byte-identical — no def and no authorable KEY changed. - * - * ## What this rewrites, and what it deliberately does not - * - * The retired values are authorable in two places and only one of them is stored - * metadata: - * - * - **`dataset.measures[].aggregate`** (`ui/dataset.zod.ts`, reusing this enum) - * is carried in the stack, so it is what this conversion walks. - * - **`QueryAST.aggregations[].function`** is a REQUEST surface — the client - * SDK's builder output and the `POST /data/:object/query` body, never stored - * (`liveness/query.json` records the same fact for `joins`/`cursor`/`distinct`). - * There is no source for the chain to rewrite, so it is a semantic TODO on the - * D3 step instead. - * - * The measure is DROPPED rather than stripped down to a bare `{ name, field }`. - * A measure with no `aggregate` and no `derived` fails the dataset's own - * `superRefine`, so stripping the key alone would hand back an item that cannot - * parse — a conversion whose output is invalid is worse than no conversion. And - * nothing is lost by dropping it: `compileDataset` has always refused these two - * by name with `datasetInvalidError`, so a stored dataset carrying one never - * produced a number on any backend. Every drop emits its own notice, so - * `os migrate meta` names the measure it removed rather than quietly shrinking - * the dataset. - * - * The cascade is part of that correctness, not extra: a `derived` measure whose - * `of` names a dropped measure would leave the dataset failing the "derived - * measures may only reference OTHER measures declared in this dataset" refinement. - * It is applied to a fixpoint because a derived measure may combine other derived - * measures. - * - * `retiredFromLoadPath`: the enum rejects both values outright, so a live author - * is taught at parse rather than silently rewritten. The entry exists so stored - * 16.x/17-rc rows replay clean (`applyConversionsToStoredItem` — without it a - * pre-removal row flags `metadata_spec_invalid` forever, mislabelling - * chain-owned history as a current-contract violation) and so - * `os migrate meta --from 16` lists the mechanical edits for author sources. - */ -const datasetMeasureAggRemoved: MetadataConversion = { - id: 'dataset-measure-array-string-agg-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'dataset.measures[].aggregate', - summary: - "dataset measure aggregates 'array_agg' / 'string_agg' removed (no SQL backend " - + 'compiled them and the v1 dataset runtime refused them by name, so a measure declaring ' - + 'one never produced a value; the measure is dropped, and with it any derived measure ' - + 'left referencing it)', - apply(stack, emit) { - const RETIRED = new Set(['array_agg', 'string_agg']); - return mapCollection(stack, 'datasets', (dataset, path) => { - const measures = dataset.measures; - if (!Array.isArray(measures)) return dataset; - - const dropped = new Set(); - const kept = measures.filter((m, i) => { - if (!isDict(m)) return true; - const aggregate = m.aggregate; - if (typeof aggregate !== 'string' || !RETIRED.has(aggregate)) return true; - emit({ from: aggregate, to: '(removed)', path: `${path}.measures[${i}].aggregate` }); - if (typeof m.name === 'string') dropped.add(m.name); - return false; - }); - if (kept.length === measures.length) return dataset; - - // Fixpoint: a derived measure may be built from another derived measure, - // so one pass can strand a reference the next pass has to answer for. - let survivors = kept; - for (;;) { - const next = survivors.filter((m) => { - if (!isDict(m)) return true; - const derived = m.derived; - if (!isDict(derived) || !Array.isArray(derived.of)) return true; - if (!derived.of.some((name) => typeof name === 'string' && dropped.has(name))) return true; - emit({ - from: `derived measure "${String(m.name)}"`, - to: '(removed)', - path: `${path}.measures`, - }); - if (typeof m.name === 'string') dropped.add(m.name); - return false; - }); - if (next.length === survivors.length) break; - survivors = next; - } - - return { ...dataset, measures: survivors }; - }); - }, - fixture: { - before: { - datasets: [{ - name: 'order_lines', - object: 'order_line', - dimensions: [{ name: 'status', field: 'status', type: 'string' }], - measures: [ - { name: 'total_amount', aggregate: 'sum', field: 'amount' }, - { name: 'product_ids', aggregate: 'array_agg', field: 'product_id' }, - { name: 'product_names', aggregate: 'string_agg', field: 'product_name' }, - // Derived measures: the first is stranded by the drop above, the - // second is stranded by the first — the reason the sweep runs to a - // fixpoint rather than once. - { name: 'name_list', derived: { op: 'sum', of: ['product_names'] } }, - { name: 'name_list_ratio', derived: { op: 'ratio', of: ['name_list', 'total_amount'] } }, - // Survives: derived from measures that are all still here. - { name: 'amount_share', derived: { op: 'ratio', of: ['total_amount', 'total_amount'] } }, - ], - }], - }, - after: { - datasets: [{ - name: 'order_lines', - object: 'order_line', - dimensions: [{ name: 'status', field: 'status', type: 'string' }], - measures: [ - { name: 'total_amount', aggregate: 'sum', field: 'amount' }, - { name: 'amount_share', derived: { op: 'ratio', of: ['total_amount', 'total_amount'] } }, - ], - }], - }, - // Two retired aggregates, plus the two derived measures the drops stranded. - expectedNotices: 4, - }, -}; - -/** - * `connector.rateLimitConfig` — OUTBOUND throttling for an engine that does not - * exist (#4911, ADR-0049). - * - * Not "declared but unread" — *declared but unimplemented*, which is a step - * worse. The platform's only token bucket is `packages/runtime/src/security/ - * rate-limit.ts`, and it is INBOUND: the dispatcher calls `consume(key)` on a - * request fingerprint and short-circuits with 429. There is no counterpart on - * the way out; no connector provider (`connector-rest`, `connector-openapi`, - * `connector-mcp`, `connector-slack`) reads the key, and no seam exists that - * could. So `strategy` / `maxRequests` / `windowSeconds` / `burstCapacity` / - * `respectUpstreamLimits` / `rateLimitHeaders` were six precise knobs an author - * could set — and would then believe capped their outbound call rate against a - * third-party API's quota. That belief is the defect: this is the false- - * compliance class ADR-0049 exists for, not cosmetic debt. - * - * The whole SHAPE goes, not just the key: `ConnectorRateLimitConfigSchema` and - * the `RateLimitStrategySchema` enum it embedded had no other consumer, and an - * exported schema with no consumer reads as a capability to whoever finds it - * (#3950). The vocabulary returns *with* an implementation, in one change — - * implementation-first, the ruling #4834 / PR #4878 set for the plugin-runtime - * family. - * - * Deliberately NOT converted to `shared/RateLimitConfig` (inbound API limiting, - * `windowMs`), the obvious-looking target: it limits the calls *others* make to - * us. Rewriting an outbound cap into an inbound one would throttle the wrong - * direction — a migration that silently changes behaviour, well outside D2's - * lossless scope. #4684 split their NAMES for this exact confusion; the honest - * conversion is a delete plus a prescription that says where throttling really - * lives (the connector provider / upstream gateway). - * - * `retiredFromLoadPath`: the key lies rather than being misspelled — the - * `flow-node-wait-timeout-keys-removed` distinction. Silently absorbing it at - * load would let the author keep believing they had configured a cap. The entry - * exists so stored 16.x/17-rc rows replay clean - * (`applyConversionsToStoredItem`) and `os migrate meta --from 16` lists the mechanical edits for author sources; live parses hit the `retiredKey()` tombstone instead. - */ -const connectorRateLimitConfigRemoved: MetadataConversion = { - id: 'connector-rate-limit-config-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'connector.rateLimitConfig', - summary: - "connector key 'rateLimitConfig' removed (no outbound rate-limiting engine exists; " - + "the runtime's only token bucket limits INBOUND requests, so every knob here was inert " - + 'while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it)', - apply(stack, emit) { - return mapCollection(stack, 'connectors', (c, path) => - stripKeys(c, ['rateLimitConfig'], emit, path)); - }, - fixture: { - before: { - connectors: [ - { - name: 'billing_api', - label: 'Billing API', - type: 'api', - rateLimitConfig: { - strategy: 'token_bucket', - maxRequests: 100, - windowSeconds: 60, - burstCapacity: 150, - respectUpstreamLimits: true, - }, - }, - // A connector that never authored the key keeps its identity — the - // copy-on-write contract `stripKeys` / `mapCollection` are built on. - { name: 'crm_sync', label: 'CRM Sync', type: 'saas' }, - ], - }, - // One notice per connector, not per knob: the block is what was removed. - // `retryConfig` and the timeouts beside it are untouched by THIS conversion - // — a statement about its scope, not a liveness verdict. They are not live: - // declared, defaulted and documented, and read by nothing. No retry loop - // reads a strategy or a backoff; every occurrence of the timeouts outside - // `packages/spec` is a write of the literal `30000` so a def satisfies the - // post-parse type; and `ConnectorProviderContext` carries neither, so a - // provider factory cannot see them. `liveness/connector.json` classifies - // all of them `dead` and is the authority here — ADR-0049 owes them a - // decision, which this entry does not pre-empt. - after: { - connectors: [ - { name: 'billing_api', label: 'Billing API', type: 'api' }, - { name: 'crm_sync', label: 'CRM Sync', type: 'saas' }, - ], - }, - expectedNotices: 1, - }, -}; - -/** - * `fieldMappings[].transform` — five declared transforms, zero engines (#5552, - * ADR-0049). - * - * `shared/FieldMappingSchema.transform` was typed by a five-member - * discriminated union (`FieldMappingTransformSchema`: `constant` / `cast` / - * `lookup` / `javascript` / `map`), inherited by both schemas that extend it — - * `integration/ConnectorFieldMapping` and `data/ExternalFieldMapping`. The - * whole union goes; the key is tombstoned on the base, which retires all three - * authorable spellings in one edit. - * - * What was measured, and against what (2026-08-06, `origin/main` @ efedd28): - * - * - **Declarative**: `transform: { type: … }` in a field-mapping position is - * authored NOWHERE outside `packages/spec`'s own tests — not in `examples/`, - * not in `skills/`, not in objectui. The showcase's one mapping - * (`examples/app-showcase/src/data/mappings/index.ts`) writes `transform: - * 'map'` — a bare STRING, which is the *other* schema (see below). - * - **Parse**: reachable. `AutomationEngine.registerConnector` / - * `registerDegradedConnector` run `ConnectorSchema.parse`, which walks - * `fieldMappings[]`. So the tombstone's prescription has a live receiver, - * which is why this is a `retiredKey()` and not a bare deletion. - * - **Execution**: none. `fieldMappings` is spelled only inside - * `packages/spec` — the four connector packages, the automation engine, REST - * and objectui never read it, and nothing anywhere switches on - * `transform.type`. Falsification control for the scan: the member-specific - * words are findable in the tree (`targetType` 70 hits, `keyField` 66, - * `valueField` 260) — the scanner works, the consumers are absent. - * - **cloud**: NOT verified. This session has no read access to - * `objectstack-ai/cloud`, and GitHub code search does not index it (a control - * query scoped to that repo returns zero with `incomplete_results`). Recorded - * as unverified rather than claimed clean — the #5540 disposition. - * - * The `javascript` member is the one that got the defect filed (#5552): its - * `.describe()` recommended `dialect="js"` while `ExpressionDialect` retired - * `js` in #3278 (ADR-0058 addendum), so the envelope the doc taught was - * rejected by the enum, the only spelling that parsed was the bare string — - * which `ExpressionInputSchema` wraps as `dialect: 'cel'` — and the example it - * offered (`value.toUpperCase()`) is not valid CEL. The maintainer ruling - * (2026-08-06) rejected fixing the sentence alone as "gilding a member that - * cannot run" and ordered enforce-or-remove on the measurement; the - * measurement came back dead for all five. - * - * NOT touched, and the reason the word `transform` survives elsewhere: - * `data/mapping.zod.ts`'s `ImportFieldMappingSchema.transform` is a different - * declaration under the same name — a flat string enum steering `params`, - * applied row by row by `packages/rest/src/import-mapping.ts:115-167` and - * recorded live in `packages/spec/liveness/mapping.json`. It is also the - * counter-example that makes this retirement's shape clear: its own - * `javascript` value is rejected with a 400 because no server sandbox exists — - * implement-or-reject-loudly, rather than a member that parses and evaporates. - * - * Scope of the rewrite: `connectors[].fieldMappings[]` is the only stored - * source that can carry the key. `ExternalLookupSchema` is not referenced by - * any stack collection or metadata type, so there is no external-lookup - * document for the walker to visit; its authorable key is retired by the same - * tombstone and needs no transform. (That zero-consumer finding then became - * the whole family's verdict: #8075 retired `data/external-lookup.zod.ts` - * outright — route 3, D3 `external-lookup-message-queue-families-retired` — - * and the `data/ExternalFieldMapping:transform` retired-keys entry was - * subsumed by the def retirement, the WidgetManifest.performance way. The - * `externalLookup.…` clause in this entry's `surface` stays: it correctly - * documents what the v16→17 change list removed, and it composes with the def - * retirement into "delete the whole value".) - * - * `retiredFromLoadPath`: the key claims a transformation that never ran, the - * `connector-rate-limit-config-removed` shape exactly. Absorbing it silently at - * load would let an author keep believing their values were being cast, mapped - * or looked up. The entry exists so stored rows replay clean - * (`applyConversionsToStoredItem`) and `os migrate meta --from 16` lists the mechanical edits for author sources; live parses hit the tombstone. - */ -const fieldMappingTransformRemoved: MetadataConversion = { - id: 'field-mapping-transform-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'connector.fieldMappings[].transform / externalLookup.fieldMappings[].transform', - summary: - "field-mapping key 'transform' removed (the whole five-member " - + 'FieldMappingTransform union went with it: no runtime ever executed constant/cast/' - + 'lookup/javascript/map, and the javascript member advertised dialect="js", a dialect ' - + "already retired because JavaScript belongs in a script body. The enforced transform pipeline is the import mapping's string-enum " - + '`mapping.fieldMapping[].transform`, which is unaffected)', - apply(stack, emit) { - return mapCollection(stack, 'connectors', (c, path) => { - const mappings = c.fieldMappings; - if (!Array.isArray(mappings)) return c; - let changed = false; - const next = mappings.map((m, i) => { - if (!m || typeof m !== 'object' || Array.isArray(m)) return m; - const stripped = stripKeys( - m as Record, - ['transform'], - emit, - `${path}.fieldMappings[${i}]`, - ); - if (stripped !== m) changed = true; - return stripped; - }); - return changed ? { ...c, fieldMappings: next } : c; - }); - }, - fixture: { - before: { - connectors: [ - { - name: 'sap_erp', - label: 'SAP ERP', - type: 'saas', - fieldMappings: [ - // The member #5552 was filed about: `dialect: 'js'` was never - // parseable, so the only authored form is the bare string — which - // silently meant CEL. - { source: 'order_value', target: 'order_total', transform: { type: 'javascript', expression: 'value / 100' } }, - // A second member, to show the notice is per mapping entry and not - // per union member. - { source: 'Status', target: 'status', transform: { type: 'map', mappings: { Active: 'active' } } }, - // A mapping that never authored the key keeps its identity — the - // copy-on-write contract `stripKeys` / `mapCollection` are built on. - { source: 'E-mail', target: 'email', defaultValue: '' }, - ], - }, - // A connector with no field mappings at all is untouched. - { name: 'crm_sync', label: 'CRM Sync', type: 'saas' }, - ], - }, - after: { - connectors: [ - { - name: 'sap_erp', - label: 'SAP ERP', - type: 'saas', - fieldMappings: [ - { source: 'order_value', target: 'order_total' }, - { source: 'Status', target: 'status' }, - { source: 'E-mail', target: 'email', defaultValue: '' }, - ], - }, - { name: 'crm_sync', label: 'CRM Sync', type: 'saas' }, - ], - }, - expectedNotices: 2, - }, -}; - -/** - * The nine theme token groups that were EMITTED and read by nobody (#5021, - * ADR-0049). - * - * The distinguishing fact, and the reason #3494 could not reach these: that - * round's criterion was "the theme engine never emits a variable for this key", - * which retired `spacing` / `breakpoints` / `density` / `wcagContrast` and five - * more. These eight keys pass that test — objectui's `generateThemeVars` walks - * every one of them and puts `--font-size-*`, `--font-weight-*`, - * `--line-height-*`, `--letter-spacing-*`, `--duration-*`, `--timing-*`, - * `--z-*`, `--font-heading` and `--font-mono` on the document, exactly as - * declared. What does not exist is a READER: measured against objectui `main` - * on 2026-08-04, all nine groups have zero consumers across `packages/**`, - * while `--font-sans`, `--radius*`, `--shadow*` and the colour variables come - * back live in the same run. So the author's type scale was real CSS that - * styled nothing. - * - * A CSS custom property is genuinely weaker evidence than a spec key here — a - * tenant's own stylesheet CAN read a variable the platform ignores — and that - * is precisely why the prescription is `customVars` rather than a bare "sorry". - * `customVars` emits `--: ` verbatim, so every one of these - * variables is reproducible byte for byte. The retirement removes a semantic - * vocabulary the platform never honoured; it removes no capability. - * - * ⚠️ This STRIPS rather than rewriting into `customVars`, and that is the - * deliberate half of the design. A mechanical rewrite is possible (stringify - * the numbers, reconstruct `--z-` / `--timing-` names) and was - * rejected: it would hand back ~25 `customVars` entries that still nothing - * reads, converting a dead semantic slot into a dead literal one and making the - * retirement invisible — the #4583 shape. The notice tells the author exactly - * which keys went and the tombstone tells them where to put back the ones they - * actually consume, which is a decision only they can make. Measured input to - * that choice: `examples/**` and `apps/**` author ZERO of these keys today (the - * showcase's two themes declare `colors` only), so there is no in-repo body of - * authored config a rewrite would have saved. - * - * `retiredFromLoadPath`: the schema tombstones each key with its prescription, - * so a live parse rejects loudly and only `os migrate meta --from 16` lists the mechanical edits for existing sources. Absorbing these at load would let an author keep believing they had - * configured a type scale. - */ -const themeInertTokenScalesRemoved: MetadataConversion = { - id: 'theme-inert-token-scales-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: - 'theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight' - + ' / theme.typography.letterSpacing / theme.typography.fontFamily.heading' - + ' / theme.typography.fontFamily.mono / theme.animation / theme.zIndex', - summary: - "theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', " - + "'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed " - + '(ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, ' - + '--letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono ' - + 'faithfully, and no first-party component or stylesheet has ever read one. ' - + 'Re-declare any variable you actually consume under customVars, which emits it verbatim)', - apply(stack, emit) { - return mapCollection(stack, 'themes', (theme, path) => { - let next = stripKeys(theme, ['animation', 'zIndex'], emit, path); - - // `typography` is one level down, and `fontFamily` two — `stripKeys` is - // top-level only, so each nesting level is walked explicitly and - // copy-on-write is preserved at every level (an untouched theme keeps its - // identity, which is what `mapCollection` tests for). - const typography = next.typography; - if (typography && typeof typography === 'object' && !Array.isArray(typography)) { - const typo = typography as Record; - let nextTypo = stripKeys( - typo, - ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'], - emit, - `${path}.typography`, - ); - - const fontFamily = nextTypo.fontFamily; - if (fontFamily && typeof fontFamily === 'object' && !Array.isArray(fontFamily)) { - const nextFamily = stripKeys( - fontFamily as Record, - ['heading', 'mono'], - emit, - `${path}.typography.fontFamily`, - ); - if (nextFamily !== fontFamily) nextTypo = { ...nextTypo, fontFamily: nextFamily }; - } - - if (nextTypo !== typo) next = { ...next, typography: nextTypo }; - } - - return next; - }); - }, - fixture: { - before: { - themes: [ - { - name: 'corporate', - label: 'Corporate', - colors: { primary: '#7C3AED' }, - typography: { - fontFamily: { base: 'Inter, sans-serif', heading: 'Georgia, serif', mono: 'ui-monospace' }, - fontSize: { base: '1rem', lg: '1.125rem' }, - fontWeight: { normal: 400, bold: 700 }, - lineHeight: { normal: '1.5' }, - letterSpacing: { wide: '0.025em' }, - }, - animation: { duration: { fast: '150ms' }, timing: { ease: 'cubic-bezier(0.4, 0, 0.2, 1)' } }, - zIndex: { modal: 1050 }, - }, - // A theme that authored none of them keeps its identity — the - // copy-on-write contract `stripKeys` / `mapCollection` are built on. - { name: 'minimal', label: 'Minimal', colors: { primary: '#000000' } }, - ], - }, - after: { - themes: [ - { - name: 'corporate', - label: 'Corporate', - colors: { primary: '#7C3AED' }, - // `fontFamily` SURVIVES with `base` alone: it is the one font-family - // key with a live consumer (`--font-sans`). The block is not removed, - // only narrowed — which is why the fixture keeps it rather than - // dropping `typography` wholesale. - typography: { fontFamily: { base: 'Inter, sans-serif' } }, - }, - { name: 'minimal', label: 'Minimal', colors: { primary: '#000000' } }, - ], - }, - // One notice per KEY, not per stop and not per theme: eight keys authored - // on the first theme, zero on the second. - expectedNotices: 8, - }, -}; - -/** - * The two spellings of the page-header node, both converted by - * {@link pageHeaderSubtitleAlias}. - * - * `page:header` is the protocol type (`PageComponentType`, `ComponentPropsMap`); - * `page-header` is objectui's kebab legacy alias (`@object-ui/layout`), which is - * authorable here because `PageComponentSchema.type` is deliberately - * `z.union([PageComponentType, z.string()])` — an open namespace for custom - * components. - */ -const PAGE_HEADER_COMPONENT_TYPES = new Set(['page:header', 'page-header']); - -/** - * Page-header component prop `description` → `subtitle` (protocol 17, - * objectui#3226 / #4827). - * - * One concept — the page's second line — carried two authorable spellings, one - * per renderer. `@objectstack/spec`'s `PageHeaderProps` (`ui/component.zod.ts`) - * declares `subtitle` and nothing else; objectui's kebab legacy alias - * `page-header` advertised `description` in its registration `inputs` and its - * renderer read a bare `subtitle ?? description`. That is the Prime Directive - * #12 shape exactly: a producer dialect held up by a consumer fallback, with - * the registry teaching the off-spec key on the way in. - * - * **Why a conversion and not a deletion** — the load-bearing half of this - * entry. The alias's entire reason to exist is out-of-repo consumer schemas, - * so "no in-repo author writes `description`" is not evidence that nobody - * does; on this key in particular, in-repo evidence has zero coverage. - * Deleting the consumer read would drop those pages' second line *silently* — - * the title still renders, one line vanishes, nothing errors — which is the - * least reportable failure shape there is. So the alias retires the ADR-0087 - * D2 way instead: rewritten to the canonical key at load, and at every stored - * row's rehydration (`applyConversionsToStoredItem` walks `pages` for free), - * declared, loud, tested and expiring. objectui#3226 deletes its `??` once - * this ships. - * - * **Both spellings of the node are converted**, deliberately. `page-header` is - * the alias the fallback served; `page:header` is the canonical type, where an - * authored `description` is *already* dropped on the floor today because that - * renderer only ever read `subtitle` — the same defect, one spelling over. The - * alias here is the KEY, not the type: this entry does **not** rewrite - * `page-header` → `page:header`. That registration is objectui's to retire, on - * its own schedule, and rewriting a type over an open namespace is the class of - * move {@link flowNodeHttpRename}'s conflict guard exists for. - * - * Precedence is the house rule {@link renameKey} encodes and nothing new: an - * already-canonical `subtitle` WINS, and the shadowed `description` is left - * exactly where it sits — unconverted, unreported, unremoved — as in - * {@link flowNodeCrudObjectAlias}. Only header nodes are touched: `description` - * is a live declared prop elsewhere on the same surface (`element:text_input` - * helper text), and those components are not this entry's business. - * - * **This entry is why the walker's reach had to grow (#6775).** Every other - * page-component entry leans on a tombstone to cover the sites a conversion - * cannot reach; this one has none to lean on, because `description` stays a - * live declared prop on other components, so `properties.description` parses - * green at ANY position. Until the walker descended into `slots.*` (#6776) and - * into container `properties` (#6775), a header on a `kind: 'slotted'` record - * page or inside a card/tab panel was rewritten by nobody and reported by - * nobody — and objectui's `subtitle ?? description` fallback could not retire - * without those pages silently losing their second line, the exact failure - * shape this entry exists to prevent. The fixture pins all three positions. - * - * **Retired from the load path at 18** — its one-major window (protocol 17) - * has run (#23421). The authoring funnel no longer rewrites the old key, so an - * author meets `PageHeaderProps`' own refusal, which names the rename - * (`aliases: { description: 'subtitle' }`), at every door that judges page - * props: `os validate` / `os build` / `os lint` and the metadata save door - * alike, where before the first two converted silently and the save door - * refused. Stored rows keep healing: stored-row rehydration replays the full - * chain, this entry included (`applyConversionsToStoredItem`), and so do the - * artifact-ingestion door (inside its window) and `os migrate meta`. The kebab - * `page-header` type stays in its walk for those replays only — objectui no - * longer registers that type, so a source node of it renders the unknown - * component panel whatever its props say. - */ -const pageHeaderSubtitleAlias: MetadataConversion = { - id: 'page-header-subtitle-alias', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '17.7.0', - surface: 'page.component.page-header.description', - summary: - "page-header component prop 'description' → 'subtitle' (the off-spec spelling a renderer tolerated through a bare `subtitle ?? description` fallback; `subtitle` is the declared key, and the fallback retires)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - const type = component.type; - if (typeof type !== 'string' || !PAGE_HEADER_COMPONENT_TYPES.has(type)) return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const renamed = renameKey(properties, 'description', 'subtitle'); - if (!renamed) return component; - emit({ from: 'description', to: 'subtitle', path: `${path}.properties.subtitle` }); - return { ...component, properties: renamed }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'crm_lead_detail', - regions: [ - { - name: 'header', - components: [ - // The kebab legacy alias — the spelling the `??` fallback served. - { type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }, - // The canonical type authored with the legacy key: converted too, - // because today this second line is dropped on the floor. - { type: 'page:header', properties: { title: 'Lead', description: 'One lead' } }, - // Both spellings, DIFFERENT text (#4923): kept, so the author - // reconciles the two second lines rather than the loader picking. - { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'other' } }, - // `description` is this component's OWN declared prop (helper text) — untouched. - { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, - ], - }, - ], - }, - // The slotted record page — `regions: []`, header in a named slot. This - // is the shape objectui's own guide prescribes for a customized record - // header, and a region-only walk visited none of it (#6776). - { - name: 'crm_account_detail', - kind: 'slotted', - regions: [], - slots: { - header: { type: 'page:header', properties: { title: '{name}', description: 'Account overview' } }, - }, - }, - // Container nesting (#6775): a header inside a card's `children`, one in - // its `footer`, one inside a tab panel, and one two levels down. All are - // spec-valid (`properties` is an open bag) and all were invisible to the - // region-only walk — with no tombstone to catch them at parse time. - { - name: 'crm_pipeline_dashboard', - regions: [ - { - name: 'main', - components: [ - { - type: 'page:card', - properties: { - title: 'Pipeline', - children: [ - { type: 'page-header', properties: { title: 'Open', description: 'This quarter' } }, - ], - footer: [ - { type: 'page:header', properties: { title: 'Closed', description: 'Last quarter' } }, - ], - }, - }, - { - type: 'page:tabs', - properties: { - tabStyle: 'line', - items: [ - { - label: 'Activity', - children: [ - { type: 'page:header', properties: { title: 'Recent', description: 'Last 7 days' } }, - // Two levels down — the recursion, not just one hop. - { - type: 'page:card', - properties: { - children: [ - { type: 'page:header', properties: { title: 'Nested', description: 'Deep' } }, - ], - }, - }, - ], - }, - ], - }, - }, - ], - }, - ], - }, - ], - }, - after: { - pages: [ - { - name: 'crm_lead_detail', - regions: [ - { - name: 'header', - components: [ - { type: 'page-header', properties: { title: 'Leads', subtitle: 'All open leads' } }, - { type: 'page:header', properties: { title: 'Lead', subtitle: 'One lead' } }, - { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'other' } }, - { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, - ], - }, - ], - }, - { - name: 'crm_account_detail', - kind: 'slotted', - regions: [], - slots: { - header: { type: 'page:header', properties: { title: '{name}', subtitle: 'Account overview' } }, - }, - }, - { - name: 'crm_pipeline_dashboard', - regions: [ - { - name: 'main', - components: [ - { - type: 'page:card', - properties: { - title: 'Pipeline', - children: [ - { type: 'page-header', properties: { title: 'Open', subtitle: 'This quarter' } }, - ], - footer: [ - { type: 'page:header', properties: { title: 'Closed', subtitle: 'Last quarter' } }, - ], - }, - }, - { - type: 'page:tabs', - properties: { - tabStyle: 'line', - items: [ - { - label: 'Activity', - children: [ - { type: 'page:header', properties: { title: 'Recent', subtitle: 'Last 7 days' } }, - { - type: 'page:card', - properties: { - children: [ - { type: 'page:header', properties: { title: 'Nested', subtitle: 'Deep' } }, - ], - }, - }, - ], - }, - ], - }, - }, - ], - }, - ], - }, - ], - }, - expectedNotices: 7, - }, -}; - -/** - * The SDUI component-props reconciliation (protocol 17, #5775) — three entries - * below, one shared reason. - * - * #5068 wired the first parse `ComponentPropsMap` ever had and measured what - * the corpus actually authors against it. The measurement came back with - * divergence in BOTH directions: keys objectui's renderers honour that the - * schema never declared, and keys the schema declared (one of them REQUIRED) - * that no renderer has ever read. The maintainer's 2026-08-06 ruling took - * direction A — the #5611 rule, "the delivered and authorized shape is the - * contract" — so the honoured keys were declared and the unread ones retire - * here. - * - * **The reach is every position a picker can be authored in** (#6775). - * {@link mapPageComponents} walks `regions[].components[]`, `slots.` and - * the containers a component nests under its `properties` — the same set - * `walkPageComponents` lints — so a picker inside a card's `children` or a tab - * panel is rewritten where it sits. The tombstones still carry the refusal at - * parse time for anything a conversion declines to touch (a disagreeing pair - * under {@link renameKey}'s house rule, or a source no migration ran over); - * what changed is that "run `os migrate meta`" is now a prescription the - * conversion can honour at a nested site, not only at region level. - * - * All three are **retired from the load path**: each key is tombstoned in - * `ui/component.zod.ts`, so `os validate`, `os build` and `os lint` refuse it - * with the prescription (the component-props gate, `@objectstack/lint`; the - * load path keeps the open `properties` bag as written) and only - * `os migrate meta` lists the mechanical edits for existing sources. - */ -const RECORD_PICKER_COMPONENT_TYPE = 'element:record_picker'; - -/** - * `element:record_picker.displayField` → `labelField` (protocol 17, #5775). - * - * Two spellings of one concept — "which field is the row's text" — of which the - * schema required the one nobody reads. `record-picker.tsx` resolves - * `props.labelField ?? 'name'` and renders `row[labelField]`; `displayField` - * appears nowhere in that renderer, and objectui's own component registry - * publishes `labelField` as the designer input. So an author who followed the - * schema and wrote `displayField: 'title'` got a picker listing `name`, with a - * success receipt and no diagnostic anywhere — the ADR-0078 shape. - * - * A rename rather than a deletion because the two keys are synonyms: the value - * (a field name) is exactly what `labelField` wants. Precedence is - * {@link renameKey}'s house rule (#4923) and nothing new — a redundant twin is - * dropped, a DISAGREEING pair is left for the author to reconcile rather than - * the loader picking a field. - */ -const recordPickerDisplayFieldToLabelField: MetadataConversion = { - id: 'record-picker-display-field-to-label-field', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.element:record_picker.displayField', - summary: - "record-picker component prop 'displayField' → 'labelField' (the required key no renderer read; `labelField ?? 'name'` is what renders the row, so the delivered spelling became the declared one)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== RECORD_PICKER_COMPONENT_TYPE) return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const renamed = renameKey(properties, 'displayField', 'labelField'); - if (!renamed) return component; - emit({ from: 'displayField', to: 'labelField', path: `${path}.properties.labelField` }); - return { ...component, properties: renamed }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'showcase_page_variables', - regions: [ - { - name: 'main', - components: [ - { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { displayField: 'title' } }, - // Both spellings, SAME value: the redundant twin goes (#4923). - { type: 'element:record_picker', dataSource: { object: 'a' }, properties: { labelField: 'name', displayField: 'name' } }, - // Both spellings, DIFFERENT fields: kept, so the author reconciles - // the two rather than the loader picking a column. - { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { labelField: 'name', displayField: 'title' } }, - // `displayField` is a live LOOKUP-FIELD key elsewhere on the - // surface — a different component's business, untouched here. - // (The neighbor was `element:form` until #9249 retired that - // element whole; its strip conversion would now touch the node, - // so the overlap rides on a type no conversion dispatches on.) - { type: 'element:text', properties: { displayField: 'title' } }, - // Nested one container down (#6775) — a picker inside a card is - // where a form-shaped page actually puts one. - { - type: 'page:card', - properties: { - title: 'Link a project', - children: [ - { type: 'element:record_picker', dataSource: { object: 'd' }, properties: { displayField: 'code' } }, - ], - }, - }, - ], - }, - ], - }, - // A slotted page's named slot — same component, other authoring shape. - { - name: 'showcase_project_detail', - kind: 'slotted', - regions: [], - slots: { - details: [ - { type: 'element:record_picker', dataSource: { object: 'e' }, properties: { displayField: 'label' } }, - ], - }, - }, - ], - }, - after: { - pages: [ - { - name: 'showcase_page_variables', - regions: [ - { - name: 'main', - components: [ - { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { labelField: 'title' } }, - { type: 'element:record_picker', dataSource: { object: 'a' }, properties: { labelField: 'name' } }, - { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { labelField: 'name', displayField: 'title' } }, - { type: 'element:text', properties: { displayField: 'title' } }, - { - type: 'page:card', - properties: { - title: 'Link a project', - children: [ - { type: 'element:record_picker', dataSource: { object: 'd' }, properties: { labelField: 'code' } }, - ], - }, - }, - ], - }, - ], - }, - { - name: 'showcase_project_detail', - kind: 'slotted', - regions: [], - slots: { - details: [ - { type: 'element:record_picker', dataSource: { object: 'e' }, properties: { labelField: 'label' } }, - ], - }, - }, - ], - }, - expectedNotices: 4, - }, -}; - -/** - * `element:record_picker.searchFields` / `.multiple` — declared capabilities the - * control does not have (protocol 17, #5775, ADR-0049). - * - * The renderer is a shadcn `Select` over a `find()` result: no search input - * exists, so `searchFields` narrowed nothing, and the control is single-choice - * writing ONE id into the bound page variable, so `multiple: true` selected - * nothing extra while reporting success. Neither key has a reader anywhere in - * objectui. Enforce-or-remove: they are removed, not deprecated, and either - * returns the day the capability is implemented (#5021 / #4988 precedent). - * - * Pure lossless deletes — neither key ever had an effect to lose. - */ -const recordPickerInertKeysRemoved: MetadataConversion = { - id: 'record-picker-inert-keys-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple', - summary: - "record-picker component props 'searchFields'/'multiple' removed (the control is a plain single-select with no search box; neither key had a reader)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== RECORD_PICKER_COMPONENT_TYPE) return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const stripped = stripKeys(properties, ['searchFields', 'multiple'], emit, `${path}.properties`); - if (stripped === properties) return component; - return { ...component, properties: stripped }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'picker_gallery', - regions: [ - { - name: 'main', - components: [ - { - type: 'element:record_picker', - dataSource: { object: 'showcase_project' }, - properties: { searchFields: ['name', 'code'], multiple: true }, - }, - // `multiple` is a live FIELD key (lookup fields) — a different - // surface entirely, and not this entry's business. (The - // neighbor was `element:form` until #9249 retired that element - // whole; the overlap now rides on a type no conversion - // dispatches on.) - { type: 'element:text', properties: { multiple: true } }, - // Inside a tab panel (#6775): `page:tabs` hangs its sub-tree off - // `properties.items[].children`, which the walk now descends. - { - type: 'page:tabs', - properties: { - tabStyle: 'line', - items: [ - { - label: 'Pick one', - children: [ - { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { multiple: true } }, - ], - }, - ], - }, - }, - ], - }, - ], - }, - // The named-slot shape, on a slotted record page. - { - name: 'picker_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'element:record_picker', dataSource: { object: 'c' }, properties: { searchFields: ['name'] } }, - }, - }, - ], - }, - after: { - pages: [ - { - name: 'picker_gallery', - regions: [ - { - name: 'main', - components: [ - { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: {} }, - { type: 'element:text', properties: { multiple: true } }, - { - type: 'page:tabs', - properties: { - tabStyle: 'line', - items: [ - { - label: 'Pick one', - children: [ - { type: 'element:record_picker', dataSource: { object: 'b' }, properties: {} }, - ], - }, - ], - }, - }, - ], - }, - ], - }, - { - name: 'picker_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'element:record_picker', dataSource: { object: 'c' }, properties: {} }, - }, - }, - ], - }, - expectedNotices: 4, - }, -}; - -/** - * `page:card.body` → `children` (protocol 17, #5775). - * - * Every container on this surface composes through `children` — `grid`, - * `flex`, `page:section`, `page:accordion` items, `page:tabs` items — and the - * card renderer reads `schema.body ?? schema.children` with its own comment - * saying authors expect `children` to work here too. The showcase's two cards - * author `children`. Only the declaration said `body`, which made the #5068 - * gate report the showcase's own correct pages as authoring an unknown key. - * - * Converging on `children` rather than declaring both: one composition key, not - * two de-facto contracts (Prime Directive #12). `footer` is a genuinely - * distinct slot and is untouched. The renderer keeps its `body ??` fallback for - * stored documents — that is objectui's to retire on its own schedule, exactly - * as {@link pageHeaderSubtitleAlias} left the kebab `page-header` registration - * alone. - */ -const pageCardBodyToChildren: MetadataConversion = { - id: 'page-card-body-to-children', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.page:card.body', - summary: - "page:card component prop 'body' → 'children' (one composition key across every container; the card renderer already reads both)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== 'page:card') return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const renamed = renameKey(properties, 'body', 'children'); - if (!renamed) return component; - emit({ from: 'body', to: 'children', path: `${path}.properties.children` }); - return { ...component, properties: renamed }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'my_work', - regions: [ - { - name: 'sidebar', - components: [ - { - type: 'page:card', - properties: { title: 'Shortcuts', body: [{ type: 'element:text' }], footer: [{ type: 'element:text' }] }, - }, - // Both spellings, DIFFERENT content: kept, so the author picks - // which body the card should have. - { - type: 'page:card', - properties: { children: [{ type: 'element:text' }], body: [{ type: 'element:image' }] }, - }, - // `body` on a component that is not a card — not this entry's key. - { type: 'record:alert', properties: { body: 'Confirm the work before marking it done.' } }, - // A card nested in a card (#6775). The OUTER rename moves the - // sub-tree from `body` to `children`, and the descent reads the - // MAPPED component, so the inner card is visited exactly once — - // under the canonical key, not once per spelling. - { - type: 'page:card', - properties: { - title: 'Outer', - body: [ - { type: 'page:card', properties: { title: 'Inner', body: [{ type: 'element:text' }] } }, - ], - }, - }, - ], - }, - ], - }, - // The named-slot shape: a card authored into a slotted page's `details`. - { - name: 'my_work_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'page:card', properties: { title: 'Detail', body: [{ type: 'element:text' }] } }, - }, - }, - ], - }, - after: { - pages: [ - { - name: 'my_work', - regions: [ - { - name: 'sidebar', - components: [ - { - type: 'page:card', - properties: { title: 'Shortcuts', children: [{ type: 'element:text' }], footer: [{ type: 'element:text' }] }, - }, - { - type: 'page:card', - properties: { children: [{ type: 'element:text' }], body: [{ type: 'element:image' }] }, - }, - { type: 'record:alert', properties: { body: 'Confirm the work before marking it done.' } }, - { - type: 'page:card', - properties: { - title: 'Outer', - children: [ - { type: 'page:card', properties: { title: 'Inner', children: [{ type: 'element:text' }] } }, - ], - }, - }, - ], - }, - ], - }, - { - name: 'my_work_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'page:card', properties: { title: 'Detail', children: [{ type: 'element:text' }] } }, - }, - }, - ], - }, - expectedNotices: 4, - }, -}; - -/** - * Inline `type:'api'` action: object-form `params` → `bodyExtra` (protocol 17, - * #5777). - * - * One key carried two fact-contracts. `InlineActionSchema.params` is picked - * from `ActionSchema` and is an `ActionParam[]` **definition array** — the - * fields a dialog collects before the action runs. What the showcase's - * pure-SDUI contact form authored on its submit button is a **request payload - * map** (`params: { name: '{{page.inquiryName}}', … }`), and objectui's - * `ActionRunner` took both: its own comment says "Accept both — when `params` - * is an array, treat it as the input-collection definition", discriminating on - * `Array.isArray`. A tolerant consumer fossilizing a wrong convention is Prime - * Directive #12's exact shape, and here the fossil was load-bearing: the - * generated reference could only describe the array, so an author following the - * docs could not write a working `api` submit button at all. - * - * The maintainer's 2026-08-06 ruling on #5777 took **direction A — a separate - * payload key**, explicitly refusing the same-name union of option B. The - * separate key is `bodyExtra`, which `ActionSchema` has declared all along for - * exactly this ("static body fragment merged into the outgoing request body for - * `type:'api'` actions"); #5777's spec half picks it onto the inline shape, so - * the rewrite target is a key the contract already owns rather than a third - * name. `payload` is already an alias pointing there (#5013) and `body` is - * already the `script` hook body, so those two spellings were never available. - * - * **Why a conversion and not a deletion.** The two shapes are disjoint — - * `Array.isArray` decides, with no value that could be read either way — so the - * rewrite is mechanical and lossless, which is the ADR-0087 D2 precondition. - * Deleting instead would strand every page authored the old way on a bare - * `expected array, received object`. - * - * **Scoped to `type:'api'`, deliberately.** Object-form `params` on an inline - * `type:'url'` action is a THIRD meaning again — `ActionRunner.interpolateTarget` - * reads it as the `${param.X}` interpolation scope, and `executeUrl` reads - * `params.newTab` — so rewriting those into an api request body would be lossy, - * not lossless. Nothing in the reachable corpus authors that shape; it stays - * refused by the array-only field, and this entry does not touch it. - * - * **Not extended to registered actions.** `ActionSchema` is parsed at - * `defineAction`, so its array-only `params` has always refused the object form - * at the authoring door — the defect existed only on the inline path, where - * `PageComponent.properties` is an open bag and nothing parsed the props until - * #5068. Registered actions already reach the payload through `bodyExtra`. - * - * Precedence is {@link renameKey}'s house rule and nothing new: an - * already-present `bodyExtra` WINS and a differing object-form `params` is left - * exactly where it sits, for the author to reconcile (#4923). + * Page-header component prop `description` → `subtitle` (protocol 17, + * objectui#3226 / #4827). * - * The reach is every position a button can be authored in, as for - * {@link pageHeaderSubtitleAlias} and {@link pageCardBodyToChildren} (#6775): - * `regions[].components[]`, `slots.`, and the containers a component - * nests under its `properties` — which is where a submit button most often - * sits, inside the card that holds the form. The array-only field still covers - * anything the rewrite declines to touch: it refuses the object form with a - * message naming `bodyExtra`, at any position. + * One concept — the page's second line — carried two authorable spellings, one + * per renderer. `@objectstack/spec`'s `PageHeaderProps` (`ui/component.zod.ts`) + * declares `subtitle` and nothing else; objectui's kebab legacy alias + * `page-header` advertised `description` in its registration `inputs` and its + * renderer read a bare `subtitle ?? description`. That is the Prime Directive + * #12 shape exactly: a producer dialect held up by a consumer fallback, with + * the registry teaching the off-spec key on the way in. * - * **Retired from the load path at 18** — its one-major window (protocol 17) - * has run (#23421). The authoring funnel no longer rewrites the object form, - * so an author meets that array-only field's refusal naming `bodyExtra` at - * every door that judges page props — `os validate` / `os build` / `os lint` - * and the metadata save door alike, where before the first two converted - * silently and the save door refused. Stored rows keep healing: stored-row - * rehydration replays the full chain, this entry included - * (`applyConversionsToStoredItem`), and so do the artifact-ingestion door - * (inside its window) and `os migrate meta`. - */ -const inlineActionApiParamsToBodyExtra: MetadataConversion = { - id: 'inline-action-api-params-to-body-extra', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '17.7.0', - surface: 'page.component.element:button.action.params', - summary: - "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (a static payload and a parameter definition are two things, so the payload gets its own key; `params` stays the ActionParam[] definition array)", - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== 'element:button') return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const action = properties.action; - if (!isDict(action)) return component; - // Only the payload meaning converts. `Array.isArray` is the whole - // discriminator — a definition array stays put — and the `api` guard - // keeps the url-action interpolation scope out of it. - if (action.type !== 'api') return component; - if (!isDict(action.params)) return component; - const renamed = renameKey(action, 'params', 'bodyExtra'); - if (!renamed) return component; - emit({ from: 'params', to: 'bodyExtra', path: `${path}.properties.action.bodyExtra` }); - return { ...component, properties: { ...properties, action: renamed } }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'showcase_contact_form', - regions: [ - { - name: 'main', - components: [ - // The measured site: a pure-SDUI form submit posting page vars. - { - type: 'element:button', - properties: { - label: 'Submit inquiry', - action: { - type: 'api', - target: '/api/v1/forms/contact-us/submit', - method: 'POST', - params: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, - }, - }, - }, - // A real ActionParam[] definition array — the other meaning of - // the same key, untouched. - { - type: 'element:button', - properties: { - label: 'Close order', - action: { - type: 'api', - target: '/api/v1/sales_order/close', - params: [{ name: 'reason', label: 'Reason', type: 'text' }], - }, - }, - }, - // Both spellings, DIFFERENT payloads: kept, so the author - // reconciles the two bodies rather than the loader picking. - { - type: 'element:button', - properties: { - label: 'Both', - action: { - type: 'api', - target: '/api/v1/x', - params: { a: 1 }, - bodyExtra: { b: 2 }, - }, - }, - }, - // `type:'url'` — object-form `params` is the `${param.X}` - // interpolation scope there, a third meaning. Not this entry's. - { - type: 'element:button', - properties: { - label: 'Open', - action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, - }, - }, - // The shape a pure-SDUI form is actually built in (#6775): the - // submit button sits in the card's `footer`, one container down. - { - type: 'page:card', - properties: { - title: 'Contact us', - footer: [ - { - type: 'element:button', - properties: { - label: 'Send', - action: { type: 'api', target: '/api/v1/forms/send', params: { note: '{{page.note}}' } }, - }, - }, - ], - }, - }, - ], - }, - ], - }, - // The named-slot shape: an action button in a slotted page's `actions`. - { - name: 'showcase_contact_detail', - kind: 'slotted', - regions: [], - slots: { - actions: [ - { - type: 'element:button', - properties: { - label: 'Resend', - action: { type: 'api', target: '/api/v1/forms/resend', params: { id: '{{record.id}}' } }, - }, - }, - ], - }, - }, - ], - }, - after: { - pages: [ - { - name: 'showcase_contact_form', - regions: [ - { - name: 'main', - components: [ - { - type: 'element:button', - properties: { - label: 'Submit inquiry', - action: { - type: 'api', - target: '/api/v1/forms/contact-us/submit', - method: 'POST', - bodyExtra: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, - }, - }, - }, - { - type: 'element:button', - properties: { - label: 'Close order', - action: { - type: 'api', - target: '/api/v1/sales_order/close', - params: [{ name: 'reason', label: 'Reason', type: 'text' }], - }, - }, - }, - { - type: 'element:button', - properties: { - label: 'Both', - action: { - type: 'api', - target: '/api/v1/x', - params: { a: 1 }, - bodyExtra: { b: 2 }, - }, - }, - }, - { - type: 'element:button', - properties: { - label: 'Open', - action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, - }, - }, - { - type: 'page:card', - properties: { - title: 'Contact us', - footer: [ - { - type: 'element:button', - properties: { - label: 'Send', - action: { type: 'api', target: '/api/v1/forms/send', bodyExtra: { note: '{{page.note}}' } }, - }, - }, - ], - }, - }, - ], - }, - ], - }, - { - name: 'showcase_contact_detail', - kind: 'slotted', - regions: [], - slots: { - actions: [ - { - type: 'element:button', - properties: { - label: 'Resend', - action: { type: 'api', target: '/api/v1/forms/resend', bodyExtra: { id: '{{record.id}}' } }, - }, - }, - ], - }, - }, - ], - }, - expectedNotices: 3, - }, -}; - -/** - * `page:tabs.type` → `tabStyle` (protocol 17, #6776). - * - * The concept — the tab strip's visual style, `line` | `card` | `pill` — was - * declared all along. What was wrong is the **spelling**: a props key named - * `type` sits on the one name a page component cannot spare, because the node's - * own dispatch key is `type` too. Three independently-measured consequences on - * objectui `origin/main`, none of them cosmetic: - * - * - `SchemaRenderer.tsx:253,264` hoists `properties` onto the node but skips - * `type` and `id` explicitly, or the inner value would shadow which - * renderer to dispatch to. Its comment names this very case ("tab visual - * style: 'line' | 'card' | 'pill'"). - * - `sdui-parser`'s `BASE_PROPS` (`validate.ts:20-30`) contains `'type'`, and - * `validate.ts:68` `continue`s on any base prop — so a manifest input named - * `type` is skipped before the unknown/typed checks and is never validated. - * - In the flat and JSX carriers a node reads - * `{ type: 'page:tabs', items: […], tabStyle: 'card' }` — `type` is the tag - * name, and the spec's spelling has nowhere left to go. - * - * So `tabStyle` is not drift: it is the only spelling those carriers can - * express, it is what objectui's registry publishes as the designer input, and - * `containers.tsx:381` reads BOTH (`schema?.properties?.type || schema?.tabStyle`). - * Converging on the spelling the renderer reads — rather than the one that - * declares well — is exactly {@link recordPickerDisplayFieldToLabelField}'s - * shape (#5775), and one spelling rather than two is Prime Directive #12. The - * alternative considered and refused was declaring `tabStyle` as an alias of - * `type`: that fossilizes a second dialect for one concept, and the dialect - * that would survive is the one that silently fails to validate. - * - * A rename rather than a deletion because the two keys are synonyms down to the - * value vocabulary — the same three-value enum, no re-mapping. Precedence is - * {@link renameKey}'s house rule (#4923): a redundant twin is dropped, a - * DISAGREEING pair is left for the author to reconcile rather than the loader - * picking a look. - * - * The reach is every position, as for {@link pageCardBodyToChildren} (#6775): - * a `page:tabs` nested inside another component's `properties` is rewritten - * where it sits. The discriminator matters more here than anywhere else, since - * the key being renamed shares a name with the node's dispatch key — so the - * fixture pins that descending into a props bag does NOT turn some other - * component's inner `type` (an action's `type: 'url'`, a tab item's fields) - * into a rewrite target: only `properties.type` on a node whose own `type` is - * `page:tabs` moves. The tombstone still carries the refusal at the authoring - * site (`tsc`) and at `os validate` / `os build` / `os lint`, where the - * component-props gate refuses it as an `error` (the load path keeps the open - * `properties` bag as written), whatever the walk reached. - * - * `retiredFromLoadPath: true`: no alias window, deliberately. The tombstone owns - * the refusal; this entry exists so `spec-changes.json`, the upgrade guide and - * `os migrate meta` still carry the conversion. + * **Why a conversion and not a deletion** — the load-bearing half of this + * entry. The alias's entire reason to exist is out-of-repo consumer schemas, + * so "no in-repo author writes `description`" is not evidence that nobody + * does; on this key in particular, in-repo evidence has zero coverage. + * Deleting the consumer read would drop those pages' second line *silently* — + * the title still renders, one line vanishes, nothing errors — which is the + * least reportable failure shape there is. So the alias retires the ADR-0087 + * D2 way instead: rewritten to the canonical key at load, and at every stored + * row's rehydration (`applyConversionsToStoredItem` walks `pages` for free), + * declared, loud, tested and expiring. objectui#3226 deletes its `??` once + * this ships. + * + * **Both spellings of the node are converted**, deliberately. `page-header` is + * the alias the fallback served; `page:header` is the canonical type, where an + * authored `description` is *already* dropped on the floor today because that + * renderer only ever read `subtitle` — the same defect, one spelling over. The + * alias here is the KEY, not the type: this entry does **not** rewrite + * `page-header` → `page:header`. That registration is objectui's to retire, on + * its own schedule, and rewriting a type over an open namespace is the class of + * move {@link flowNodeHttpRename}'s conflict guard exists for. + * + * Precedence is the house rule {@link renameKey} encodes and nothing new: an + * already-canonical `subtitle` WINS, and the shadowed `description` is left + * exactly where it sits — unconverted, unreported, unremoved — as in + * {@link flowNodeCrudObjectAlias}. Only header nodes are touched: `description` + * is a live declared prop elsewhere on the same surface (`element:text_input` + * helper text), and those components are not this entry's business. + * + * **This entry is why the walker's reach had to grow (#6775).** Every other + * page-component entry leans on a tombstone to cover the sites a conversion + * cannot reach; this one has none to lean on, because `description` stays a + * live declared prop on other components, so `properties.description` parses + * green at ANY position. Until the walker descended into `slots.*` (#6776) and + * into container `properties` (#6775), a header on a `kind: 'slotted'` record + * page or inside a card/tab panel was rewritten by nobody and reported by + * nobody — and objectui's `subtitle ?? description` fallback could not retire + * without those pages silently losing their second line, the exact failure + * shape this entry exists to prevent. The fixture pins all three positions. + * + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23421). The authoring funnel no longer rewrites the old key, so an + * author meets `PageHeaderProps`' own refusal, which names the rename + * (`aliases: { description: 'subtitle' }`), at every door that judges page + * props: `os validate` / `os build` / `os lint` and the metadata save door + * alike, where before the first two converted silently and the save door + * refused. Stored rows keep healing: stored-row rehydration replays the full + * chain, this entry included (`applyConversionsToStoredItem`), and so do the + * artifact-ingestion door (inside its window) and `os migrate meta`. The kebab + * `page-header` type stays in its walk for those replays only — objectui no + * longer registers that type, so a source node of it renders the unknown + * component panel whatever its props say. */ -const pageTabsTypeToTabStyle: MetadataConversion = { - id: 'page-tabs-type-to-tab-style', +const pageHeaderSubtitleAlias: MetadataConversion = { + id: 'page-header-subtitle-alias', toMajor: 17, retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.page:tabs.type', + retiredAfter: '17.7.0', + surface: 'page.component.page-header.description', summary: - "page:tabs component prop 'type' → 'tabStyle' (a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them)", + "page-header component prop 'description' → 'subtitle' (the off-spec spelling a renderer tolerated through a bare `subtitle ?? description` fallback; `subtitle` is the declared key, and the fallback retires)", apply(stack, emit) { return mapPageComponents(stack, (component, path) => { - if (component.type !== 'page:tabs') return component; + const type = component.type; + if (typeof type !== 'string' || !PAGE_HEADER_COMPONENT_TYPES.has(type)) return component; const properties = component.properties; if (!isDict(properties)) return component; - const renamed = renameKey(properties, 'type', 'tabStyle'); + const renamed = renameKey(properties, 'description', 'subtitle'); if (!renamed) return component; - emit({ from: 'type', to: 'tabStyle', path: `${path}.properties.tabStyle` }); + emit({ from: 'description', to: 'subtitle', path: `${path}.properties.subtitle` }); return { ...component, properties: renamed }; }); }, @@ -6129,35 +2051,55 @@ const pageTabsTypeToTabStyle: MetadataConversion = { before: { pages: [ { - name: 'sys_position_detail', + name: 'crm_lead_detail', + regions: [ + { + name: 'header', + components: [ + // The kebab legacy alias — the spelling the `??` fallback served. + { type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }, + // The canonical type authored with the legacy key: converted too, + // because today this second line is dropped on the floor. + { type: 'page:header', properties: { title: 'Lead', description: 'One lead' } }, + // Both spellings, DIFFERENT text (#4923): kept, so the author + // reconciles the two second lines rather than the loader picking. + { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'other' } }, + // `description` is this component's OWN declared prop (helper text) — untouched. + { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, + ], + }, + ], + }, + // The slotted record page — `regions: []`, header in a named slot. This + // is the shape objectui's own guide prescribes for a customized record + // header, and a region-only walk visited none of it (#6776). + { + name: 'crm_account_detail', + kind: 'slotted', + regions: [], + slots: { + header: { type: 'page:header', properties: { title: '{name}', description: 'Account overview' } }, + }, + }, + // Container nesting (#6775): a header inside a card's `children`, one in + // its `footer`, one inside a tab panel, and one two levels down. All are + // spec-valid (`properties` is an open bag) and all were invisible to the + // region-only walk — with no tombstone to catch them at parse time. + { + name: 'crm_pipeline_dashboard', regions: [ { name: 'main', components: [ - { type: 'page:tabs', properties: { type: 'card', items: [{ label: 'Holders' }] } }, - // Both spellings, SAME value: the redundant twin goes (#4923). - { type: 'page:tabs', properties: { tabStyle: 'pill', type: 'pill', items: [] } }, - // Both spellings, DIFFERENT looks: kept, so the author picks one - // rather than the loader picking for them. - { type: 'page:tabs', properties: { tabStyle: 'pill', type: 'card', items: [] } }, - // A `type` one level down inside another component's properties - // is a different key entirely: `action.type` is the action's - // discriminator, and the walk descends into CONTAINER keys - // (`children` / `items[].children` / `body` / `footer`), never - // into an arbitrary props value. - { - type: 'element:button', - properties: { label: 'Open', action: { type: 'url', target: '/x' } }, - }, - // Tabs nested in a card, and tabs inside a tab panel (#6775): - // the rewrite reaches both, and the outer strip's own `items` - // stay ordinary tab records, not components. { type: 'page:card', properties: { - title: 'Related', + title: 'Pipeline', children: [ - { type: 'page:tabs', properties: { type: 'pill', items: [{ label: 'Notes' }] } }, + { type: 'page-header', properties: { title: 'Open', description: 'This quarter' } }, + ], + footer: [ + { type: 'page:header', properties: { title: 'Closed', description: 'Last quarter' } }, ], }, }, @@ -6167,9 +2109,18 @@ const pageTabsTypeToTabStyle: MetadataConversion = { tabStyle: 'line', items: [ { - label: 'Nested', + label: 'Activity', children: [ - { type: 'page:tabs', properties: { type: 'card', items: [] } }, + { type: 'page:header', properties: { title: 'Recent', description: 'Last 7 days' } }, + // Two levels down — the recursion, not just one hop. + { + type: 'page:card', + properties: { + children: [ + { type: 'page:header', properties: { title: 'Nested', description: 'Deep' } }, + ], + }, + }, ], }, ], @@ -6179,40 +2130,47 @@ const pageTabsTypeToTabStyle: MetadataConversion = { }, ], }, - // The shape this key is really authored in: `page:tabs` IS one of the - // seven named slots, and all four in-repo sites are `slots.tabs` on a - // `kind: 'slotted'` record page. Region-only reach would have missed - // every one of them (#6776 — see `mapPageComponents`). + ], + }, + after: { + pages: [ + { + name: 'crm_lead_detail', + regions: [ + { + name: 'header', + components: [ + { type: 'page-header', properties: { title: 'Leads', subtitle: 'All open leads' } }, + { type: 'page:header', properties: { title: 'Lead', subtitle: 'One lead' } }, + { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'other' } }, + { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, + ], + }, + ], + }, { - name: 'sys_user_detail', + name: 'crm_account_detail', + kind: 'slotted', regions: [], slots: { - tabs: { type: 'page:tabs', properties: { type: 'line', position: 'top', items: [] } }, + header: { type: 'page:header', properties: { title: '{name}', subtitle: 'Account overview' } }, }, }, - ], - }, - after: { - pages: [ { - name: 'sys_position_detail', + name: 'crm_pipeline_dashboard', regions: [ { name: 'main', components: [ - { type: 'page:tabs', properties: { tabStyle: 'card', items: [{ label: 'Holders' }] } }, - { type: 'page:tabs', properties: { tabStyle: 'pill', items: [] } }, - { type: 'page:tabs', properties: { tabStyle: 'pill', type: 'card', items: [] } }, - { - type: 'element:button', - properties: { label: 'Open', action: { type: 'url', target: '/x' } }, - }, { type: 'page:card', properties: { - title: 'Related', + title: 'Pipeline', children: [ - { type: 'page:tabs', properties: { tabStyle: 'pill', items: [{ label: 'Notes' }] } }, + { type: 'page-header', properties: { title: 'Open', subtitle: 'This quarter' } }, + ], + footer: [ + { type: 'page:header', properties: { title: 'Closed', subtitle: 'Last quarter' } }, ], }, }, @@ -6222,9 +2180,17 @@ const pageTabsTypeToTabStyle: MetadataConversion = { tabStyle: 'line', items: [ { - label: 'Nested', + label: 'Activity', children: [ - { type: 'page:tabs', properties: { tabStyle: 'card', items: [] } }, + { type: 'page:header', properties: { title: 'Recent', subtitle: 'Last 7 days' } }, + { + type: 'page:card', + properties: { + children: [ + { type: 'page:header', properties: { title: 'Nested', subtitle: 'Deep' } }, + ], + }, + }, ], }, ], @@ -6234,126 +2200,208 @@ const pageTabsTypeToTabStyle: MetadataConversion = { }, ], }, - { - name: 'sys_user_detail', - regions: [], - slots: { - tabs: { type: 'page:tabs', properties: { tabStyle: 'line', position: 'top', items: [] } }, - }, - }, ], }, - expectedNotices: 5, + expectedNotices: 7, }, }; /** - * `page:header.icon` and `page:card.actions` — two declared page-component props - * with NO renderer read point at all (protocol 17, #6946, ADR-0049). - * - * Maintainer ruling 2026-08-09 (decision-inbox round, 「全部接受」) on - * objectui#3829, **route (c)**: retire both upstream. That card had put three - * shapes on the table — wire the key, publish it with a KNOWN GAP marker (the - * `record:activity.showSubscriptionToggle` precedent), or retire it here — and - * the ruling took the third, so the platform contract loses two keys rather - * than growing two features nobody asked for. - * - * What "no read point" means, per key, measured against objectui at the - * `.objectui-sha` pin (`09987b68`) rather than inherited from the card: - * - * - **`page:header.icon`** — `PageHeaderRenderer` resolves `icon` only inside - * the ACTION pipeline (`action.icon`, per button); the header's own props - * bag is never asked for one. `@object-ui/layout`'s `` does - * draw an `icon`, but only from a REACT prop a host passes: unlike - * `actions`, which four lines away falls back to `schema?.actions ?? - * schema?.properties?.actions`, `icon` has no schema fallback, so an - * authored node cannot reach it. The registration publishes no `icon` - * input either. - * - **`page:card.actions`** — `PageCardRenderer` builds its `` from - * `title`, `bordered`, `body ?? children` and `footer`, full stop. There is - * no actions area in the markup and no `actions` input in the - * registration. Its sibling `page:header` DOES read `actions` off the bag, - * which is exactly why the divergence survived: the two declarations look - * identical. - * - * Both keys are in objectui's own `UNPUBLISHED_EXEMPTIONS` map as B-class - * ("spec declares it, NO renderer read point") entries pointing at objectui#3829 - * — an independent measurement of the same fact, taken in the other repo. - * - * **Pure lossless deletes.** Neither key ever had an effect to lose, and - * neither has a lossless rewrite target: the header has no second icon slot - * (its identity is the record chrome), and the card has no actions area to move - * a list into — buttons belong in `children`/`footer` as components, which is a - * page rewrite and not a mechanical one. So this strips, exactly as - * {@link recordPickerInertKeysRemoved} does for the picker's two inert keys. - * - * ⚠️ `page:header.actions` is NOT touched — it is read (`containers.tsx`, and - * `@object-ui/layout`'s header) and stays. One key name, two components, one - * of them live: the strip is scoped by component `type`, never by key name. - * - * objectui#3829 (drop the two exemptions) is Blocked-by #6946 and proceeds on - * the next pin bump. + * The SDUI component-props reconciliation (protocol 17, #5775) — three entries + * below, one shared reason. + * + * #5068 wired the first parse `ComponentPropsMap` ever had and measured what + * the corpus actually authors against it. The measurement came back with + * divergence in BOTH directions: keys objectui's renderers honour that the + * schema never declared, and keys the schema declared (one of them REQUIRED) + * that no renderer has ever read. The maintainer's 2026-08-06 ruling took + * direction A — the #5611 rule, "the delivered and authorized shape is the + * contract" — so the honoured keys were declared and the unread ones retire + * here. + * + * **The reach is every position a picker can be authored in** (#6775). + * {@link mapPageComponents} walks `regions[].components[]`, `slots.` and + * the containers a component nests under its `properties` — the same set + * `walkPageComponents` lints — so a picker inside a card's `children` or a tab + * panel is rewritten where it sits. The tombstones still carry the refusal at + * parse time for anything a conversion declines to touch (a disagreeing pair + * under {@link renameKey}'s house rule, or a source no migration ran over); + * what changed is that "run `os migrate meta`" is now a prescription the + * conversion can honour at a nested site, not only at region level. + * + * All three are **retired from the load path**: each key is tombstoned in + * `ui/component.zod.ts`, so `os validate`, `os build` and `os lint` refuse it + * with the prescription (the component-props gate, `@objectstack/lint`; the + * load path keeps the open `properties` bag as written) and only + * `os migrate meta` lists the mechanical edits for existing sources. + */ +const RECORD_PICKER_COMPONENT_TYPE = 'element:record_picker'; + +/** + * Inline `type:'api'` action: object-form `params` → `bodyExtra` (protocol 17, + * #5777). + * + * One key carried two fact-contracts. `InlineActionSchema.params` is picked + * from `ActionSchema` and is an `ActionParam[]` **definition array** — the + * fields a dialog collects before the action runs. What the showcase's + * pure-SDUI contact form authored on its submit button is a **request payload + * map** (`params: { name: '{{page.inquiryName}}', … }`), and objectui's + * `ActionRunner` took both: its own comment says "Accept both — when `params` + * is an array, treat it as the input-collection definition", discriminating on + * `Array.isArray`. A tolerant consumer fossilizing a wrong convention is Prime + * Directive #12's exact shape, and here the fossil was load-bearing: the + * generated reference could only describe the array, so an author following the + * docs could not write a working `api` submit button at all. + * + * The maintainer's 2026-08-06 ruling on #5777 took **direction A — a separate + * payload key**, explicitly refusing the same-name union of option B. The + * separate key is `bodyExtra`, which `ActionSchema` has declared all along for + * exactly this ("static body fragment merged into the outgoing request body for + * `type:'api'` actions"); #5777's spec half picks it onto the inline shape, so + * the rewrite target is a key the contract already owns rather than a third + * name. `payload` is already an alias pointing there (#5013) and `body` is + * already the `script` hook body, so those two spellings were never available. + * + * **Why a conversion and not a deletion.** The two shapes are disjoint — + * `Array.isArray` decides, with no value that could be read either way — so the + * rewrite is mechanical and lossless, which is the ADR-0087 D2 precondition. + * Deleting instead would strand every page authored the old way on a bare + * `expected array, received object`. + * + * **Scoped to `type:'api'`, deliberately.** Object-form `params` on an inline + * `type:'url'` action is a THIRD meaning again — `ActionRunner.interpolateTarget` + * reads it as the `${param.X}` interpolation scope, and `executeUrl` reads + * `params.newTab` — so rewriting those into an api request body would be lossy, + * not lossless. Nothing in the reachable corpus authors that shape; it stays + * refused by the array-only field, and this entry does not touch it. + * + * **Not extended to registered actions.** `ActionSchema` is parsed at + * `defineAction`, so its array-only `params` has always refused the object form + * at the authoring door — the defect existed only on the inline path, where + * `PageComponent.properties` is an open bag and nothing parsed the props until + * #5068. Registered actions already reach the payload through `bodyExtra`. + * + * Precedence is {@link renameKey}'s house rule and nothing new: an + * already-present `bodyExtra` WINS and a differing object-form `params` is left + * exactly where it sits, for the author to reconcile (#4923). + * + * The reach is every position a button can be authored in, as for + * {@link pageHeaderSubtitleAlias} and the retired `page-card-body-to-children` + * (#6775, retired with the protocol-16.x kits in #25767): + * `regions[].components[]`, `slots.`, and the containers a component + * nests under its `properties` — which is where a submit button most often + * sits, inside the card that holds the form. The array-only field still covers + * anything the rewrite declines to touch: it refuses the object form with a + * message naming `bodyExtra`, at any position. + * + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23421). The authoring funnel no longer rewrites the object form, + * so an author meets that array-only field's refusal naming `bodyExtra` at + * every door that judges page props — `os validate` / `os build` / `os lint` + * and the metadata save door alike, where before the first two converted + * silently and the save door refused. Stored rows keep healing: stored-row + * rehydration replays the full chain, this entry included + * (`applyConversionsToStoredItem`), and so do the artifact-ingestion door + * (inside its window) and `os migrate meta`. */ -const pageStructureInertKeysRemoved: MetadataConversion = { - id: 'page-structure-inert-keys-removed', +const inlineActionApiParamsToBodyExtra: MetadataConversion = { + id: 'inline-action-api-params-to-body-extra', toMajor: 17, retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.page:header.icon / page.component.page:card.actions', + retiredAfter: '17.7.0', + surface: 'page.component.element:button.action.params', summary: - "page:header prop 'icon' and page:card prop 'actions' removed (neither has a renderer " - + 'read point in objectui; the header resolves icons per action and the card renders ' - + 'title/children/footer only)', + "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (a static payload and a parameter definition are two things, so the payload gets its own key; `params` stays the ActionParam[] definition array)", apply(stack, emit) { return mapPageComponents(stack, (component, path) => { + if (component.type !== 'element:button') return component; const properties = component.properties; if (!isDict(properties)) return component; - // Scoped by component type: `actions` is LIVE on `page:header` and `icon` - // is live on half a dozen other components, so a key-name-only strip - // would delete working metadata. - const key = - component.type === 'page:header' ? 'icon' - : component.type === 'page:card' ? 'actions' - : null; - if (!key) return component; - const stripped = stripKeys(properties, [key], emit, `${path}.properties`); - if (stripped === properties) return component; - return { ...component, properties: stripped }; + const action = properties.action; + if (!isDict(action)) return component; + // Only the payload meaning converts. `Array.isArray` is the whole + // discriminator — a definition array stays put — and the `api` guard + // keeps the url-action interpolation scope out of it. + if (action.type !== 'api') return component; + if (!isDict(action.params)) return component; + const renamed = renameKey(action, 'params', 'bodyExtra'); + if (!renamed) return component; + emit({ from: 'params', to: 'bodyExtra', path: `${path}.properties.action.bodyExtra` }); + return { ...component, properties: { ...properties, action: renamed } }; }); }, fixture: { before: { pages: [ { - name: 'connect_agent', + name: 'showcase_contact_form', regions: [ { - name: 'header', + name: 'main', components: [ - // The published platform-page shape: title + subtitle + a - // decorative icon nothing drew. + // The measured site: a pure-SDUI form submit posting page vars. { - type: 'page:header', - properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.', icon: 'bot' }, + type: 'element:button', + properties: { + label: 'Submit inquiry', + action: { + type: 'api', + target: '/api/v1/forms/contact-us/submit', + method: 'POST', + params: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, + }, + }, }, - // `page:header.actions` SURVIVES — the live half of the same key - // name (see the ⚠️ above). - { type: 'page:header', properties: { title: 'Lead', actions: ['convert_lead'], icon: 'user' } }, - // `icon` on a component that is not a page header — not this - // entry's key, and live on that one. - { type: 'element:button', properties: { label: 'Open', icon: 'external-link' } }, + // A real ActionParam[] definition array — the other meaning of + // the same key, untouched. { - type: 'page:card', - properties: { title: 'Shortcuts', actions: ['new_task'], footer: [{ type: 'element:text' }] }, + type: 'element:button', + properties: { + label: 'Close order', + action: { + type: 'api', + target: '/api/v1/sales_order/close', + params: [{ name: 'reason', label: 'Reason', type: 'text' }], + }, + }, + }, + // Both spellings, DIFFERENT payloads: kept, so the author + // reconciles the two bodies rather than the loader picking. + { + type: 'element:button', + properties: { + label: 'Both', + action: { + type: 'api', + target: '/api/v1/x', + params: { a: 1 }, + bodyExtra: { b: 2 }, + }, + }, + }, + // `type:'url'` — object-form `params` is the `${param.X}` + // interpolation scope there, a third meaning. Not this entry's. + { + type: 'element:button', + properties: { + label: 'Open', + action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, + }, }, - // A card nested inside a card's `children` (#6775 reach): the - // descent visits it, so the inner `actions` goes too. + // The shape a pure-SDUI form is actually built in (#6775): the + // submit button sits in the card's `footer`, one container down. { type: 'page:card', properties: { - title: 'Outer', - children: [ - { type: 'page:card', properties: { title: 'Inner', actions: ['edit'] } }, + title: 'Contact us', + footer: [ + { + type: 'element:button', + properties: { + label: 'Send', + action: { type: 'api', target: '/api/v1/forms/send', params: { note: '{{page.note}}' } }, + }, + }, ], }, }, @@ -6361,14 +2409,21 @@ const pageStructureInertKeysRemoved: MetadataConversion = { }, ], }, - // The named-slot shape (#6776): a header authored into a slotted - // record page's `details` slot. + // The named-slot shape: an action button in a slotted page's `actions`. { - name: 'connect_agent_detail', + name: 'showcase_contact_detail', kind: 'slotted', regions: [], slots: { - details: { type: 'page:header', properties: { title: 'Agent', icon: 'bot' } }, + actions: [ + { + type: 'element:button', + properties: { + label: 'Resend', + action: { type: 'api', target: '/api/v1/forms/resend', params: { id: '{{record.id}}' } }, + }, + }, + ], }, }, ], @@ -6376,178 +2431,85 @@ const pageStructureInertKeysRemoved: MetadataConversion = { after: { pages: [ { - name: 'connect_agent', + name: 'showcase_contact_form', regions: [ { - name: 'header', + name: 'main', components: [ { - type: 'page:header', - properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.' }, + type: 'element:button', + properties: { + label: 'Submit inquiry', + action: { + type: 'api', + target: '/api/v1/forms/contact-us/submit', + method: 'POST', + bodyExtra: { name: '{{page.inquiryName}}', email: '{{page.inquiryEmail}}' }, + }, + }, }, - { type: 'page:header', properties: { title: 'Lead', actions: ['convert_lead'] } }, - { type: 'element:button', properties: { label: 'Open', icon: 'external-link' } }, { - type: 'page:card', - properties: { title: 'Shortcuts', footer: [{ type: 'element:text' }] }, + type: 'element:button', + properties: { + label: 'Close order', + action: { + type: 'api', + target: '/api/v1/sales_order/close', + params: [{ name: 'reason', label: 'Reason', type: 'text' }], + }, + }, }, { - type: 'page:card', + type: 'element:button', properties: { - title: 'Outer', - children: [ - { type: 'page:card', properties: { title: 'Inner' } }, - ], + label: 'Both', + action: { + type: 'api', + target: '/api/v1/x', + params: { a: 1 }, + bodyExtra: { b: 2 }, + }, }, }, - ], - }, - ], - }, - { - name: 'connect_agent_detail', - kind: 'slotted', - regions: [], - slots: { - details: { type: 'page:header', properties: { title: 'Agent' } }, - }, - }, - ], - }, - expectedNotices: 5, - }, -}; - -/** - * `record:details.layout` — a mode selector whose two declared modes were never - * implemented (protocol 17, #6946, ADR-0049). - * - * Maintainer ruling 2026-08-09 (decision-inbox round, 「全部接受」) on - * objectui#3818: the removal direction. - * - * This one is NOT a zero-read-point key, and the distinction is the whole - * finding. `RecordDetailsRenderer` does read it — and reads it against values - * this schema never permitted: - * - * const layout = schema.layout === 'inline' || schema.layout === 'compact' - * ? 'horizontal' : 'vertical'; - * - * The declared enum is `auto | custom`. Neither matches, so both legal values - * take the same `vertical` branch: the key was accepted, read, and could not - * change anything. Its `.describe()` meanwhile promised "auto uses object - * highlightFields, custom uses explicit sections" — a behaviour the renderer - * implements, but keyed off whether `sections` is authored at all, never off - * this flag. - * - * **Why every gate stayed green over it.** `check:react-declaration-parity` - * compares two DECLARATIONS, not a declaration against an implementation - * (AGENTS.md) — and objectui's registry declared `layout` with the SAME - * `auto | custom` enum, so the gate saw perfect agreement over a key nothing - * honoured. A third spelling, `stacked | inline | compact`, sat in - * `@object-ui/types`' `RecordDetailsComponentProps` mirror. Three declarations - * of one key, none of them the branch the renderer takes. - * - * **A PURE STRIP, deliberately.** There is no value to carry: `auto` and - * `custom` were behaviourally identical to each other and to omission, so a - * rewrite would have nowhere lossless to go. What already decides the body is - * what the author wrote — `sections` for explicit groups, its absence for the - * object's `highlightFields`. - * - * ⚠️ `record:highlights.layout` is a DIFFERENT, live key (`horizontal | - * vertical`, honoured) and is untouched: this entry is scoped by component - * `type`. - * - * objectui#3818 (delete the `layout` input and the dead `inline|compact` - * branch) is Blocked-by #6946 and proceeds on the next pin bump. - */ -const recordDetailsLayoutRemoved: MetadataConversion = { - id: 'record-details-layout-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'page.component.record:details.layout', - summary: - "record:details component prop 'layout' removed (the declared auto|custom modes were " - + 'never implemented; the renderer branches only on inline|compact, values the schema never ' - + 'permitted, so both legal values selected nothing)', - apply(stack, emit) { - return mapPageComponents(stack, (component, path) => { - if (component.type !== 'record:details') return component; - const properties = component.properties; - if (!isDict(properties)) return component; - const stripped = stripKeys(properties, ['layout'], emit, `${path}.properties`); - if (stripped === properties) return component; - return { ...component, properties: stripped }; - }); - }, - fixture: { - before: { - pages: [ - { - name: 'task_detail_layout', - regions: [ - { - name: 'main', - components: [ - // The `custom` spelling, alongside the `sections` that actually - // decides the body. { - type: 'record:details', + type: 'element:button', properties: { - layout: 'custom', - columns: '2', - sections: [{ name: 'basics', fields: ['subject'] }], + label: 'Open', + action: { type: 'url', target: '/x?id=${param.id}', params: { id: 'abc' } }, }, }, - // The `auto` spelling — the declared default, written out. - { type: 'record:details', properties: { layout: 'auto' } }, - // `layout` on a component that is not `record:details`: - // `record:highlights.layout` is LIVE and must survive. - { type: 'record:highlights', properties: { layout: 'horizontal', fields: ['status'] } }, - ], - }, - ], - }, - { - name: 'task_detail_layout_slotted', - kind: 'slotted', - regions: [], - slots: { - details: [ - { type: 'record:details', properties: { layout: 'custom', fields: ['name'] } }, - ], - }, - }, - ], - }, - after: { - pages: [ - { - name: 'task_detail_layout', - regions: [ - { - name: 'main', - components: [ { - type: 'record:details', + type: 'page:card', properties: { - columns: '2', - sections: [{ name: 'basics', fields: ['subject'] }], + title: 'Contact us', + footer: [ + { + type: 'element:button', + properties: { + label: 'Send', + action: { type: 'api', target: '/api/v1/forms/send', bodyExtra: { note: '{{page.note}}' } }, + }, + }, + ], }, }, - { type: 'record:details', properties: {} }, - { type: 'record:highlights', properties: { layout: 'horizontal', fields: ['status'] } }, ], }, ], }, { - name: 'task_detail_layout_slotted', + name: 'showcase_contact_detail', kind: 'slotted', regions: [], slots: { - details: [ - { type: 'record:details', properties: { fields: ['name'] } }, + actions: [ + { + type: 'element:button', + properties: { + label: 'Resend', + action: { type: 'api', target: '/api/v1/forms/resend', bodyExtra: { id: '{{record.id}}' } }, + }, + }, ], }, }, @@ -6557,229 +2519,6 @@ const recordDetailsLayoutRemoved: MetadataConversion = { }, }; -/** - * `app.hidden: true` → `app._unpublished: true` on **stored** rows (protocol 17, - * #4829, ADR-0045 amended 2026-08-09). - * - * ADR-0045 §3 originally hung its publish gate on `app.hidden`, citing an - * "ADR-0019 launcher contract" that does not exist — ADR-0019 contains no - * `hidden`. `hidden` already had a contract of its own, written in - * `ui/app.zod.ts` the day the key was born: navigation presentation, *"hidden - * apps stay fully routable and permission-checked"*, for personal-settings apps - * reached from the avatar menu. One boolean, two contracts, contradicting each - * other on the only question that matters — and the platform's own `account` - * app, authored `hidden: true` on purpose, was therefore withheld from every - * user without builder access. The gate now reads the machine-managed - * `_unpublished`; this entry carries the existing population across. - * - * **Why the rewrite is unambiguous.** Under the old regime a `hidden: true` row - * in `sys_metadata` could only have come from the materialization path, because - * that value *was* the gate: an app stored that way was invisible to every - * non-builder, so nobody stored it to mean "keep me out of the switcher". The - * one app that really does mean that is code-declared (`platform-objects`' - * ACCOUNT_APP), and code-declared artifacts never enter `sys_metadata`. The - * Studio app form has no `hidden` control either (`ui/app.form.ts`), so no - * authoring path could have produced a second meaning. - * - * **`retiredFromLoadPath: true` — load-bearing here, not bookkeeping.** - * Retirement is what confines this rewrite to *stored* rows. `hidden` is NOT - * retired as an authorable key — it keeps its birth contract, narrowed to - * navigation — so a conversion running on the load path would rewrite - * `defineApp({ hidden: true })`, and ACCOUNT_APP itself, into unpublished apps - * and reproduce #4829 through the conversion layer. Excluded from the load path, - * it replays only where the old meaning is the only meaning: the stored-row - * rehydration seams (`applyConversionsToStoredItem`, which pins - * `includeRetired`) and `os migrate meta`. - * - * That split is also the answer for anyone who later wants a *stored* app to be - * nav-hidden: declare it on the app artifact, which this entry never touches. If - * a stored-row spelling is ever wanted it needs its own decision — a Studio - * control, and a rule for how the two populations coexist — not this entry - * quietly ceasing to fire. - * - * A row that already carries `_unpublished` is left ALONE, both keys intact - * ({@link renameKey}'s house rule, #4923): the machine has already spoken about - * that row, and a disagreeing pair is for a human to reconcile rather than for - * the loader to pick a winner. It is also what makes a second pass a no-op. - */ -const appHiddenToUnpublished: MetadataConversion = { - id: 'app-hidden-to-unpublished', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'app.hidden', - summary: - "stored app publish gate 'hidden' → '_unpublished' (ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", - apply(stack, emit) { - return mapCollection(stack, 'apps', (app, path) => { - if (app.hidden !== true) return app; - if (app._unpublished != null) return app; - const next = { ...app }; - delete next.hidden; - next._unpublished = true; - emit({ from: 'hidden', to: '_unpublished', path: `${path}._unpublished` }); - return next; - }); - }, - fixture: { - // DISJOINT from every other app fixture: none of these apps carries a key - // another entry strips, so each replays through the whole table hitting - // only its own. - before: { - apps: [ - // The materialized build mid-flight — the population this exists for. - { name: 'production_management', label: '生产管理', hidden: true, navigation: [] }, - // Published and listed: `hidden: false` meant exactly that under both - // regimes, so there is nothing to rewrite. - { name: 'crm', label: 'CRM', hidden: false, navigation: [] }, - // Already carries the canonical gate. Left verbatim — the loader does - // not reconcile a disagreeing pair on the author's behalf (#4923), and - // this is what makes a second pass a no-op. - { name: 'team_settings', hidden: true, _unpublished: false, navigation: [] }, - ], - }, - after: { - apps: [ - { name: 'production_management', label: '生产管理', _unpublished: true, navigation: [] }, - { name: 'crm', label: 'CRM', hidden: false, navigation: [] }, - { name: 'team_settings', hidden: true, _unpublished: false, navigation: [] }, - ], - }, - expectedNotices: 1, - }, -}; - -/** - * `global_nav` leaves `ACTION_LOCATIONS` (protocol 17, #6888 — ADR-0049 - * enforce-or-remove, maintainer ruling 2026-08-09). - * - * The location was declared from the day the vocabulary was written and no - * product surface ever served it. The console's ⌘K palette - * (`app-shell/src/chrome/CommandPalette.tsx`) composes its seven groups from - * nav items, objects, dashboards, pages, reports, recent items, record search - * and theme — its `actions` group is hard-coded chrome — and the file - * references neither `global_nav` nor any action-metadata source at all. Of the - * five references to the value in the whole UI repo at the vendored SHA, four - * were the Studio designer and the fifth a doc comment. - * - * What makes it worse than an ordinary inert declaration is the direction of - * the lie: `metadata-admin/previews/ActionPreview.tsx` drew the author a mock - * `⌘K · Command palette` frame, so the authoring tool PROMISED a rendering the - * product cannot do. An author declares the location, watches it "work" in the - * designer, ships it, and it renders nowhere — the ADR-0078 - * declares/renders/does-nothing shape, arriving through the location - * vocabulary rather than through a missing key. Retired rather than - * implemented: no user has asked for command-palette actions and the only two - * declarers were our own showcase corpus, so wiring the palette would have been - * capability expansion with no pull. If real appetite appears it re-enters - * through the front door, implementation first. - * - * Like `hook-body-crypto-hash-removed` and - * `dataset-measure-array-string-agg-removed` above, this is an enum-VALUE - * retirement: there is no `retiredKey()` tombstone to hang the prescription on, - * so the enum's own error map carries it (`GLOBAL_NAV_RETIRED`, - * `ui/action.zod.ts`), keyed on `issue.input` so only the spelling that used to - * be legal is told it "was removed". For the same reason nothing lands in - * `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are expected to be - * byte-identical — no def and no authorable KEY changed. - * - * ## The empty-array edge, decided deliberately - * - * The value is stripped from `locations` and **the key is kept even when the - * array empties** — `locations: []`, never `delete locations`. On this surface - * the two are different declarations, not two spellings of one: - * `content/docs/ui/actions.mdx` ("Headless actions: declare it, then hide it") - * documents `[]` as a first-class shape — the action stays callable over - * REST/MCP/AI and keeps its capability gate, param contract and audit trail — - * while an ABSENT key means the author never placed the action at all, which is - * exactly what `packages/lint`'s `action-no-placement` rule warns about. The - * rule states the distinction in those words: "an author who said 'nowhere, - * deliberately' (`[]`) and one who never said anything at all (key absent)". - * Dropping the key would therefore convert a deliberate placement into a lint - * finding and lose the author's own statement of intent. Stripping to `[]` is - * also the truthful reading of the retirement: the action's UI home is gone, - * and headless is what it now is. - * - * This differs from `dataset-measure-array-string-agg-removed`, which DROPS the - * measure, for a reason that is about validity rather than taste: a measure - * with neither `aggregate` nor `derived` fails the dataset's own `superRefine`, - * so stripping alone would emit an item that cannot parse. An action with - * `locations: []` parses, and is documented to. It follows - * `hook-body-crypto-hash-removed`'s "the `capabilities` key itself stays (an - * empty grant set is legal)" instead. - * - * `retiredFromLoadPath`: the enum rejects the value outright, so a live author - * is taught at parse rather than silently rewritten. The entry exists so stored - * 16.x/17-rc rows replay clean (`applyConversionsToStoredItem` — without it a - * pre-removal row flags `metadata_spec_invalid` forever, mislabelling - * chain-owned history as a current-contract violation) and so - * `os migrate meta --from 16` lists the mechanical edits for author sources. - */ -const actionGlobalNavLocationRemoved: MetadataConversion = { - id: 'action-global-nav-location-removed', - toMajor: 17, - retiredFromLoadPath: true, - retiredAfter: '16.1.0', - surface: 'action.locations[]', - summary: - "action location 'global_nav' removed (no running-app surface rendered it; the ⌘K " - + 'palette reads no action metadata, while the Studio designer previewed a command-palette ' - + 'frame for it. The value is stripped and the key kept, so an action left with no location ' - + 'becomes the documented headless shape `locations: []`)', - apply(stack, emit) { - return mapCollection(stack, 'actions', (action, path) => { - const locations = action.locations; - if (!Array.isArray(locations) || !locations.includes('global_nav')) return action; - emit({ from: 'global_nav', to: '(removed)', path: `${path}.locations` }); - return { ...action, locations: locations.filter((l) => l !== 'global_nav') }; - }); - }, - fixture: { - before: { - actions: [ - // The empty-array edge: `global_nav` was this action's ONLY location, - // so the strip empties the array. The key survives — see the docblock. - { - name: 'portfolio_snapshot', - label: 'Portfolio Snapshot', - type: 'script', - locations: ['global_nav'], - }, - // Surgical: the surviving locations stay, in order, beside the strip. - { - name: 'new_task', - label: 'New Task', - type: 'modal', - target: 'gallery', - locations: ['record_header', 'global_nav', 'list_toolbar'], - }, - // An action without the retired value passes through untouched. - { - name: 'mark_done', - label: 'Mark Done', - type: 'script', - locations: ['list_item'], - }, - ], - }, - after: { - actions: [ - { name: 'portfolio_snapshot', label: 'Portfolio Snapshot', type: 'script', locations: [] }, - { - name: 'new_task', - label: 'New Task', - type: 'modal', - target: 'gallery', - locations: ['record_header', 'list_toolbar'], - }, - { name: 'mark_done', label: 'Mark Done', type: 'script', locations: ['list_item'] }, - ], - }, - // One per rewritten action — `mark_done` is untouched. - expectedNotices: 2, - }, -}; - /** * Field `scale` / `precision`: malformed declarations removed (protocol 18, * #8321). @@ -10448,7 +6187,8 @@ const objectGridDefaultFiltersRemoved: MetadataConversion = { * nothing at runtime, so stopping the reads without this producer-side * retirement would turn a declared contract into a silent no-op. * - * NOT a pure lossless delete, unlike {@link pageStructureInertKeysRemoved}: + * NOT a pure lossless delete, unlike the retired `page-structure-inert-keys-removed` + * (#25767): * when `sort` is absent the fallback WAS the grid's sort, so the conversion * carries the pair over — renamed to `sort` and wrapped in the array shape the * renderer itself wraps it into at `:2847`. When `sort` is present the @@ -11025,8 +6765,8 @@ const objectKanbanQuickAddRemoved: MetadataConversion = { * (`previews/block-config.ts`), so stored `sys_metadata` pages can carry * either value. No example app, skill or doc in this repo authors it. * - * ⚠️ Scoped by component `type`, never by key name, as - * {@link pageStructureInertKeysRemoved} scoped `icon`: `breadcrumb` is an + * ⚠️ Scoped by component `type`, never by key name, as the retired + * `page-structure-inert-keys-removed` scoped `icon`: `breadcrumb` is an * ordinary word for an open-namespace component's own prop, and the kebab * `page-header` spelling is objectui's legacy registration, not a key this * spec declares. `nav:breadcrumb` — a component TYPE, not this key — is @@ -12304,8 +8044,8 @@ const formViewOptionDefaultRemoved: MetadataConversion = { * new: an already-canonical `reference` WINS — a redundant twin is dropped, a * DISAGREEING pair is left for the author to reconcile rather than the loader * picking a target. Covers object fields and object-extension fields: the same - * `FieldSchema`, so the same dialect ({@link fieldConditionalRequiredToRequiredWhen} - * is the precedent, one protocol back). + * `FieldSchema`, so the same dialect (`field-conditionalRequired-to-requiredWhen`, + * retired with the protocol-16.x kits in #25767, was the precedent one protocol back). */ const fieldReferenceToAlias: MetadataConversion = { id: 'field-reference-to-alias', @@ -14088,8 +9828,8 @@ const connectorTriggersRemoved: MetadataConversion = { * ONE value with TWO spellings across sibling arms of one union, and the driver * reading both. That is the consumer-side dialect Prime Directive #12 forbids. * - * Driver-awareness is load-bearing here for the reason - * {@link datasourceConfigDriverKeyAliases} states: `persistence` is a memory + * Driver-awareness is load-bearing here for the reason the retired + * `datasource-config-driver-key-aliases` stated: `persistence` is a memory * driver key, and a `persistence` block under some other driver is not this * shape. `resolveDriverId` keeps the rewrite where it belongs. * @@ -14409,8 +10149,8 @@ const tursoConfigEncryptionKeyRemoved: MetadataConversion = { * `datasources[].config.timeout` → `timeoutMs` for the turso driver (protocol * 18, #15680 for #14478). * - * Driver-awareness is load-bearing exactly as it is for - * {@link datasourceConfigDriverKeyAliases}: a bare `config.timeout` under some + * Driver-awareness is load-bearing exactly as it was for the retired + * `datasource-config-driver-key-aliases`: a bare `config.timeout` under some * OTHER driver is that driver's own key and must not be touched, so the rewrite * is gated on `resolveDriverId(ds.driver) === 'turso'` (which also catches a * stored `driver: 'libsql'` through the alias table). @@ -18254,14 +13994,9 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ * the built `./shared` entry. */ export const CONVERSIONS_BY_MAJOR: Readonly> = { - 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], - 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], - 14: [bookAudienceProfileToPermissionSet], + 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias], 15: [viewVisibleOnToVisibleWhen, pageComponentVisibilityToVisibleWhen], 17: [ - actionExecuteToTarget, - fieldConditionalRequiredToRequiredWhen, - agentToolsToSkills, sharingRuleAccessLevelFullToEdit, flowNodeCrudObjectAlias, flowNodeNotifyConfigAliases, @@ -18270,59 +14005,15 @@ export const CONVERSIONS_BY_MAJOR: Readonly { - it('replays a RETIRED conversion over a stored object row (conditionalRequired → requiredWhen)', () => { + it('replays a RETIRED conversion over a stored object row (externalSharingModel removed)', () => { const row = { - name: 'crm_task', - label: 'Task', - fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, + name: 'portal_case', + label: 'Case', + sharingModel: 'public_read', + externalSharingModel: 'private', }; const notices: ConversionNotice[] = []; - const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }); - expect(out.fields.due_date).toEqual({ type: 'date', requiredWhen: 'record.stage == "closed"' }); - expect(notices.map((n) => n.conversionId)).toContain('field-conditionalRequired-to-requiredWhen'); + const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }) as Record; + expect(out).toEqual({ name: 'portal_case', label: 'Case', sharingModel: 'public_read' }); + expect(notices.map((n) => n.conversionId)).toContain('object-external-sharing-model-removed'); }); - it('replays a retired conversion over a standalone stored action row (execute → target)', () => { - const row = { name: 'convert', label: 'Convert', type: 'script', execute: 'convertHandler' }; + it('replays a retired conversion over a standalone stored action row (aria removed)', () => { + const row = { name: 'escalate', label: 'Escalate', type: 'script', aria: { ariaLabel: 'Escalate', role: 'button' } }; const out = applyConversionsToStoredItem('action', row) as Record; - expect(out.target).toBe('convertHandler'); - expect('execute' in out).toBe(false); + expect(out).toEqual({ name: 'escalate', label: 'Escalate', type: 'script' }); + expect('aria' in out).toBe(false); }); it('reaches actions EMBEDDED in a stored object row', () => { const row = { name: 'crm_lead', label: 'Lead', - actions: [{ name: 'convert', type: 'script', execute: 'convertHandler' }], + actions: [{ name: 'reopen', type: 'script', aria: { ariaDescribedBy: 'reopen_help' } }], }; const out = applyConversionsToStoredItem('object', row) as { actions: Array> }; - expect(out.actions[0]!.target).toBe('convertHandler'); - expect('execute' in out.actions[0]!).toBe(false); + expect(out.actions[0]).toEqual({ name: 'reopen', type: 'script' }); + expect('aria' in out.actions[0]!).toBe(false); + }); + + // [#25767] The bound stated above, pinned: a ≤16.x kit is gone from the + // registered table, so its key is left exactly as stored — the row is not + // half-migrated by some other entry, and nothing is reported. This is what + // makes "rehydrate on ≤18.1 first" the prescription rather than a courtesy. + it('leaves an aged-out key (≤16.x kit) as stored — the stored pass converts only what is registered', () => { + const row = { + name: 'crm_task', + label: 'Task', + enable: { trash: false, searchable: true }, + fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, + }; + const notices: ConversionNotice[] = []; + expect(applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) })).toBe(row); + expect(notices).toEqual([]); }); // #8321 — the authoring schema now refuses non-integer/negative @@ -68,27 +89,9 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { }); it('accepts the legacy PLURAL row-type spelling', () => { - const row = { name: 'crm_deal', label: 'Deal', sharingModel: 'read' }; + const row = { name: 'crm_deal', label: 'Deal', sharingModel: 'private', externalSharingModel: 'private' }; const out = applyConversionsToStoredItem('objects', row) as Record; - expect(out.sharingModel).toBe('public_read'); - }); - - // #3207 — a 16.x row still carrying the retired `enable.trash`/`enable.mru` - // flags replays clean instead of tripping the registry's spec-conformance - // diagnostic forever (`.strict()` capabilities would flag it - // `metadata_spec_invalid` on every rehydration). - it('strips the retired enable.trash/mru flags from a stored object row', () => { - const row = { - name: 'sys_audit_log', - label: 'Audit Log', - enable: { trash: false, mru: false, searchable: true }, - }; - const notices: ConversionNotice[] = []; - const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }) as { - enable: Record; - }; - expect(out.enable).toEqual({ searchable: true }); - expect(notices.filter((n) => n.conversionId === 'object-enable-trash-mru-removed')).toHaveLength(2); + expect(out).toEqual({ name: 'crm_deal', label: 'Deal', sharingModel: 'private' }); }); it('is idempotent — a canonical row passes through by reference', () => { @@ -103,7 +106,8 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { it('never mutates the stored input', () => { const row = { name: 'crm_task', - fields: { due_date: { type: 'date', conditionalRequired: 'record.x == 1' } }, + sharingModel: 'private', + externalSharingModel: 'private', }; const snapshot = structuredClone(row); applyConversionsToStoredItem('object', row); @@ -119,52 +123,33 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows)', () => { expect(applyConversionsToStoredItem('object', arr)).toBe(arr); }); - // #4456 — the driver-factory `??` fallback graduation. A runtime datasource - // persisted before the #4410 config gate may carry the legacy spellings; the - // stored pass MUST serve it canonical, because the factory now reads exactly - // one spelling per key (deleting the fallbacks without this replay would - // silently move a sqlite `file:` row's data to `:memory:`). - describe('stored datasource rows (datasource-config-driver-key-aliases)', () => { - // The fourth column is the driver id the stored pass SERVES, which differs - // from the stored one for exactly one row: commit e2798fab7 renamed the canonical mongo - // id to `mongodb`, and `datasource-driver-mongo-to-mongodb` converges the - // stored spelling in the same replay. Both conversions run over one row here, - // which is the case worth pinning — the config-key rename is keyed by - // canonical driver id, so it has to keep landing for a row whose id is itself - // being renamed. + // Commit e2798fab7 renamed the canonical mongo id to `mongodb`; + // `datasource-driver-mongo-to-mongodb` (retired from the load path at 18, + // #23543 F1) converges the stored spelling here. The driver-factory `??` + // fallback graduation that used to run over the same row + // (`datasource-config-driver-key-aliases`, #4456) aged out with the 16.x kits + // (#25767): a runtime datasource row still spelling `file:`/`uri:`/`user:` is + // served as stored, and is rehydrated on a release ≤18.1 to be canonicalized. + describe('stored datasource rows (datasource-driver-mongo-to-mongodb)', () => { it.each([ - ['sqlite', { file: './data/app.db' }, { filename: './data/app.db' }, 'sqlite'], - ['sqlite', { database: './data/app.db' }, { filename: './data/app.db' }, 'sqlite'], - ['postgres', { connectionString: 'postgresql://db/x', user: 'svc' }, { url: 'postgresql://db/x', username: 'svc' }, 'postgres'], - ['mysql', { host: 'db', database: 'orders', user: 'svc' }, { host: 'db', database: 'orders', username: 'svc' }, 'mysql'], - ['mongo', { uri: 'mongodb://db/x', user: 'svc' }, { url: 'mongodb://db/x', username: 'svc' }, 'mongodb'], - ['mongodb', { uri: 'mongodb://db/x', user: 'svc' }, { url: 'mongodb://db/x', username: 'svc' }, 'mongodb'], - ])('serves a stored %s row with legacy config keys canonical', (driver, config, expected, servedDriver) => { + ['mongo', 'mongodb'], + ['mongodb', 'mongodb'], + ])('serves a stored %s row as %s, config untouched', (driver, servedDriver) => { + const config = { url: 'mongodb://db/x', username: 'svc' }; const row = { name: 'legacy_ds', driver, config, origin: 'runtime' }; const out = applyConversionsToStoredItem('datasource', row) as { config: Record }; - expect(out.config).toEqual(expected); + expect(out.config).toEqual(config); expect(out).toMatchObject({ name: 'legacy_ds', driver: servedDriver, origin: 'runtime' }); }); - it('leaves `database` alone for the drivers where it is canonical', () => { + it('leaves a canonical row of another driver alone, by reference', () => { const row = { name: 'wh', driver: 'postgres', config: { host: 'db', database: 'analytics' } }; expect(applyConversionsToStoredItem('datasource', row)).toBe(row); }); - it('keeps BOTH spellings on a stored row when they disagree', () => { - const row = { name: 'ds', driver: 'sqlite', config: { filename: './real.db', file: './stale.db' } }; - const out = applyConversionsToStoredItem('datasource', row) as { config: Record }; - // `renameKey` convention since #4923: two DIFFERENT files under two names - // is the author's ambiguity to resolve, so the replay picks neither. - expect(out.config).toEqual({ filename: './real.db', file: './stale.db' }); - }); - - it('drops a redundant alias on a stored row when it repeats the canonical value', () => { - const row = { name: 'ds', driver: 'sqlite', config: { filename: './real.db', file: './real.db' } }; - const out = applyConversionsToStoredItem('datasource', row) as { config: Record }; - // Data at rest gets the same hygiene as a fresh load (#3903 replays the - // full chain), so a rehydrated row is canonical rather than half-migrated. - expect(out.config).toEqual({ filename: './real.db' }); + it('leaves a pre-#4410 legacy config spelling as stored — the alias kit aged out (#25767)', () => { + const row = { name: 'ds', driver: 'sqlite', config: { file: './data/app.db' } }; + expect(applyConversionsToStoredItem('datasource', row)).toBe(row); }); }); diff --git a/packages/spec/src/conversions/view-spelling-walk.test.ts b/packages/spec/src/conversions/view-spelling-walk.test.ts index 92a1d4bf19a..11e93976e9a 100644 --- a/packages/spec/src/conversions/view-spelling-walk.test.ts +++ b/packages/spec/src/conversions/view-spelling-walk.test.ts @@ -25,6 +25,13 @@ * identical payload under `config`, and to the identical payload flattened at * the top level. Every conversion is exercised in all three, because the gap * was family-wide rather than specific to any entry. + * + * The entries driven here are the 17.x view-family retirements still in the + * table (`filterableFields`, `tabs`, `visibleOn`, the per-option `default`, + * the overlay's `owner`/`hidden`). The 16.x ones that used to be driven + * (`pdf`, the inert and pass-through list keys, the form's `defaultSort`/`aria`) + * aged out of the table at 18 (ADR-0087 amendment, #25767), so a row still + * carrying them is served as stored and meets the strict refusal at the parse. */ import { describe, expect, it } from 'vitest'; @@ -113,87 +120,55 @@ describe('view conversions reach the ViewItem-record spelling ({ viewKind, confi ]); }); - it('strips the retired `pdf` export format from a record `config`', () => { + it('strips the retired `filterableFields` whitelist from a record `config`, and only that', () => { const { out, notices } = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], exportOptions: ['xlsx', 'pdf'] }).record, - ); - expect((out.config as Dict).exportOptions).toEqual(['xlsx']); - expect(pathsFor(notices, 'view-export-options-pdf-removed')).toEqual([ - 'views[0].config.exportOptions', - ]); - }); - - it('strips `pdf` from the OBJECT export spelling under a record `config`', () => { - const { out } = convertViewRow( listSpellings({ type: 'grid', columns: ['name'], - exportOptions: { formats: ['csv', 'pdf'], maxRecords: 100 }, + searchableFields: ['name'], // a different key that stays + filterableFields: ['status', 'priority'], }).record, ); - // The surviving sibling keys stay — the strip is the value, not the block. - expect((out.config as Dict).exportOptions).toEqual({ formats: ['csv'], maxRecords: 100 }); + const config = out.config as Dict; + expect('filterableFields' in config).toBe(false); + expect(config.searchableFields).toEqual(['name']); + expect(pathsFor(notices, 'view-list-filterable-fields-removed')).toEqual([ + 'views[0].config.filterableFields', + ]); }); - it('strips the inert LIST keys from a `viewKind: list` record, and only those', () => { + it('strips the retired list `tabs` from a record `config`', () => { const { out, notices } = convertViewRow( listSpellings({ type: 'grid', columns: ['name'], - responsive: { sm: {} }, - performance: { lazyLoad: true }, - aria: { label: 'Leads' }, // list `aria` is LIVE — must survive - }).record, - ); - const config = out.config as Dict; - expect('responsive' in config).toBe(false); - expect('performance' in config).toBe(false); - expect(config.aria).toEqual({ label: 'Leads' }); - expect(pathsFor(notices, 'view-inert-keys-removed').sort()).toEqual([ - 'views[0].config.performance', - 'views[0].config.responsive', - ]); - }); - - it('strips the inert FORM keys from a `viewKind: form` record, and only those', () => { - const { out } = convertViewRow( - formSpellings({ - type: 'simple', - data: { provider: 'object', object: 'crm_lead' }, // form `data` is LIVE - defaultSort: [{ field: 'created_at', order: 'desc' }], - aria: { label: 'Lead form' }, + tabs: [{ name: 'mine', label: 'Mine', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }], }).record, ); const config = out.config as Dict; - expect('defaultSort' in config).toBe(false); - expect('aria' in config).toBe(false); - expect(config.data).toEqual({ provider: 'object', object: 'crm_lead' }); + expect('tabs' in config).toBe(false); + expect(Object.keys(config).sort()).toEqual(['columns', 'type']); + expect(pathsFor(notices, 'view-list-tabs-removed')).toEqual(['views[0].config.tabs']); }); - it('is SHAPE-scoped across the record arms: a form key is not stripped from a list record', () => { - // `aria` is inert on a FORM and live on a LIST. Reading `viewKind` wrong - // (or ignoring it and stripping both key sets) would delete a live key. - const { out } = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], aria: { label: 'Leads' } }).record, + it('is SHAPE-scoped across the record arms: a list key is not stripped from a form record', () => { + // `tabs` is a retired LIST key; a form record carrying the same spelling is + // not this entry's business. Reading `viewKind` wrong (or ignoring it and + // stripping by key name alone) would delete a key the entry never named. + const { out, notices } = convertViewRow( + formSpellings({ type: 'simple', tabs: [{ name: 'mine', label: 'Mine' }] }).record, ); - expect((out.config as Dict).aria).toEqual({ label: 'Leads' }); + expect((out.config as Dict).tabs).toEqual([{ name: 'mine', label: 'Mine' }]); + expect(pathsFor(notices, 'view-list-tabs-removed')).toEqual([]); }); - it('strips the pass-through list keys from a record `config`', () => { - const { out, notices } = convertViewRow( - listSpellings({ - type: 'grid', - columns: ['name'], - resizable: true, // live — survives - striped: true, - bordered: true, - virtualScroll: true, - }).record, - ); - const config = out.config as Dict; - expect(config.resizable).toBe(true); - expect(Object.keys(config).sort()).toEqual(['columns', 'resizable', 'type']); - expect(pathsFor(notices, 'view-list-passthrough-keys-removed')).toHaveLength(3); + it('leaves an aged-out key (16.x kit) in a record `config` as stored — only registered entries run here', () => { + // `striped` / `pdf` / `responsive` left the table with their kits (#25767): + // the row is not half-migrated by some other entry, and nothing is reported. + const row = listSpellings({ type: 'grid', columns: ['name'], striped: true, exportOptions: ['xlsx', 'pdf'] }).record; + const { out, notices } = convertViewRow(row); + expect(out).toEqual(row); + expect(notices).toHaveLength(0); }); it('strips the per-option `default` inside a record `config`, nested rows included', () => { @@ -230,7 +205,7 @@ describe('view conversions reach the ViewItem-record spelling ({ viewKind, confi it('leaves the record identity fields alone while converting its `config`', () => { const { out } = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], striped: true }).record, + listSpellings({ type: 'grid', columns: ['name'], filterableFields: ['status'] }).record, ); expect(out.name).toBe('crm_lead.all'); expect(out.object).toBe('crm_lead'); @@ -254,67 +229,40 @@ describe('view conversions reach the flattened-overlay spelling (payload at top ]); }); - it('strips the retired `pdf` export format from a flattened LIST overlay', () => { - const { out, notices } = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], exportOptions: ['xlsx', 'pdf'] }).flattened, - ); - expect(out.exportOptions).toEqual(['xlsx']); - expect(pathsFor(notices, 'view-export-options-pdf-removed')).toEqual([ - 'views[0].exportOptions', - ]); - }); - - it('strips the inert LIST keys from a flattened LIST overlay', () => { + it('strips the retired `filterableFields` whitelist from a flattened LIST overlay', () => { const { out, notices } = convertViewRow( listSpellings({ type: 'grid', columns: ['name'], - responsive: { sm: {} }, - performance: { lazyLoad: true }, - aria: { label: 'Leads' }, + searchableFields: ['name'], + filterableFields: ['status', 'priority'], }).flattened, ); - expect('responsive' in out).toBe(false); - expect('performance' in out).toBe(false); - expect(out.aria).toEqual({ label: 'Leads' }); - expect(pathsFor(notices, 'view-inert-keys-removed').sort()).toEqual([ - 'views[0].performance', - 'views[0].responsive', + expect('filterableFields' in out).toBe(false); + expect(out.searchableFields).toEqual(['name']); + expect(pathsFor(notices, 'view-list-filterable-fields-removed')).toEqual([ + 'views[0].filterableFields', ]); }); - it('strips the inert FORM keys from a flattened FORM overlay', () => { - const { out } = convertViewRow( - formSpellings({ - type: 'simple', - data: { provider: 'object', object: 'crm_lead' }, - defaultSort: [{ field: 'created_at', order: 'desc' }], - aria: { label: 'Lead form' }, - }).flattened, - ); - expect('defaultSort' in out).toBe(false); - expect('aria' in out).toBe(false); - expect(out.data).toEqual({ provider: 'object', object: 'crm_lead' }); - }); - - it('strips the pass-through list keys from a flattened LIST overlay', () => { + it('strips the retired list `tabs` from a flattened LIST overlay', () => { const { out, notices } = convertViewRow( listSpellings({ type: 'grid', columns: ['name'], - striped: true, - bordered: true, - virtualScroll: true, + tabs: [{ name: 'urgent', label: 'Urgent' }], }).flattened, ); - expect('striped' in out).toBe(false); - expect('bordered' in out).toBe(false); - expect('virtualScroll' in out).toBe(false); - expect(pathsFor(notices, 'view-list-passthrough-keys-removed').sort()).toEqual([ - 'views[0].bordered', - 'views[0].striped', - 'views[0].virtualScroll', - ]); + expect('tabs' in out).toBe(false); + expect(out.columns).toEqual(['name']); + expect(pathsFor(notices, 'view-list-tabs-removed')).toEqual(['views[0].tabs']); + }); + + it('leaves an aged-out key (16.x kit) on a flattened overlay as stored', () => { + const row = listSpellings({ type: 'grid', columns: ['name'], striped: true, virtualScroll: true }).flattened; + const { out, notices } = convertViewRow(row); + expect(out).toEqual(row); + expect(notices).toHaveLength(0); }); it('strips the per-option `default` inside a flattened FORM overlay', () => { @@ -361,7 +309,7 @@ describe('view conversions reach the flattened-overlay spelling (payload at top columnState: { order: ['name'], widths: { name: 120 } }, type: 'grid', columns: ['name'], - striped: true, + filterableFields: ['status'], }); expect(out.name).toBe('crm_lead.all'); expect(out.object).toBe('crm_lead'); @@ -372,7 +320,7 @@ describe('view conversions reach the flattened-overlay spelling (payload at top expect(out.scope).toBe('user'); expect(out.columnState).toEqual({ order: ['name'], widths: { name: 120 } }); // The two retired keys on this row — each stripped by its own entry. - expect('striped' in out).toBe(false); + expect('filterableFields' in out).toBe(false); expect('owner' in out).toBe(false); expect(pathsFor(notices, 'view-overlay-owner-hidden-removed')).toEqual(['views[0].owner']); }); @@ -380,7 +328,7 @@ describe('view conversions reach the flattened-overlay spelling (payload at top describe('the container spelling is unchanged, and the three agree', () => { it('converts a container `list`, a named `listViews` entry, a record and an overlay alike', () => { - const payload = { type: 'grid', columns: ['name'], striped: true, exportOptions: ['xlsx', 'pdf'] }; + const payload = { type: 'grid', columns: ['name'], filterableFields: ['status'], tabs: [{ name: 'mine', label: 'Mine' }] }; const s = listSpellings(payload); const fromContainer = convertViewRow(s.container).out.list as Dict; @@ -388,7 +336,7 @@ describe('the container spelling is unchanged, and the three agree', () => { const fromRecord = convertViewRow(s.record).out.config as Dict; const flattened = convertViewRow(s.flattened).out; - const expected = { type: 'grid', columns: ['name'], exportOptions: ['xlsx'] }; + const expected = { type: 'grid', columns: ['name'] }; expect(fromContainer).toEqual(expected); expect(fromNamed).toEqual(expected); expect(fromRecord).toEqual(expected); @@ -400,11 +348,21 @@ describe('the container spelling is unchanged, and the three agree', () => { it('converts a container `form` and a `viewKind: form` record alike', () => { const payload = { type: 'simple', - sections: [{ label: 'Details', visibleOn: 'record.open', fields: [] }], - aria: { label: 'Lead form' }, + sections: [{ + label: 'Details', + visibleOn: 'record.open', + fields: [{ field: 'status', type: 'select', options: [{ label: 'Open', value: 'open', default: true }] }], + }], }; const s = formSpellings(payload); - const expected = { type: 'simple', sections: [{ label: 'Details', visibleWhen: 'record.open', fields: [] }] }; + const expected = { + type: 'simple', + sections: [{ + label: 'Details', + visibleWhen: 'record.open', + fields: [{ field: 'status', type: 'select', options: [{ label: 'Open', value: 'open' }] }], + }], + }; expect(convertViewRow(s.container).out.form).toEqual(expected); expect((convertViewRow(s.containerNamed).out.formViews as Dict).quick).toEqual(expected); @@ -416,7 +374,7 @@ describe('the walker recognizes shapes rather than guessing at them', () => { it('leaves a body that is none of the three spellings untouched', () => { // No `viewKind`, no container slot — nothing positively identifies a view // payload here, so the chain must not invent one and start stripping. - const row = { name: 'mystery', object: 'crm_lead', striped: true, aria: { label: 'x' } }; + const row = { name: 'mystery', object: 'crm_lead', filterableFields: ['status'], aria: { label: 'x' } }; const { out, notices } = convertViewRow(row); expect(out).toEqual(row); expect(notices).toHaveLength(0); @@ -427,7 +385,7 @@ describe('the walker recognizes shapes rather than guessing at them', () => { // requires a config object; both overlay arms guard `config: undefined`). // A malformed body gets no rewrite — it is not silently re-read as a // flattened overlay whose top level would then be stripped. - const row = { name: 'broken', object: 'crm_lead', viewKind: 'list', config: null, striped: true }; + const row = { name: 'broken', object: 'crm_lead', viewKind: 'list', config: null, filterableFields: ['status'] }; const { out, notices } = convertViewRow(row); expect(out).toEqual(row); expect(notices).toHaveLength(0); @@ -435,7 +393,7 @@ describe('the walker recognizes shapes rather than guessing at them', () => { it('is idempotent: replaying the chain over an already-converted row is a no-op', () => { const once = convertViewRow( - listSpellings({ type: 'grid', columns: ['name'], striped: true, exportOptions: ['xlsx', 'pdf'] }).record, + listSpellings({ type: 'grid', columns: ['name'], filterableFields: ['status'], tabs: [] }).record, ); const twice = convertViewRow(once.out); expect(twice.out).toEqual(once.out); diff --git a/packages/spec/src/conversions/walk.ts b/packages/spec/src/conversions/walk.ts index effd2695fbe..8eb1d2ede23 100644 --- a/packages/spec/src/conversions/walk.ts +++ b/packages/spec/src/conversions/walk.ts @@ -237,10 +237,12 @@ const MAX_COMPONENT_DEPTH = 32; * and the three walks disagreed about `page:card.footer`). * * This walker reads EVERY entry, the retired ones included: `page:card.body` - * is a tombstoned spelling the authoring walks skip, but the renderers still - * read it for stored documents, and normalizing stored documents is what this - * walker is for — a conversion that runs before `page-card-body-to-children` - * in the chain meets the sub-tree still under `body`. + * was a tombstoned spelling the authoring walks skip (the tombstone and its + * `page-card-body-to-children` conversion were a ≤16.x kit and aged out on + * #25767; the slot is still listed as retired in COMPONENT_SLOTS), but the + * renderers still read it for stored documents, and normalizing stored + * documents is what this walker is for — a conversion that ran before the + * rename in the chain met the sub-tree still under `body`. * * Recognised by SHAPE — an array (a panel list: an array whose object entries * carry the panel key's array) — and deliberately NOT keyed by component @@ -280,10 +282,10 @@ function mapComponentList( * The mapper runs on the container FIRST and the descent then reads the * *mapped* component's `properties`, the same ordering {@link mapNodeTree} uses * for flow regions. That is what keeps the walk single-visit under a conversion - * that MOVES a container key: `page-card-body-to-children` renames + * that MOVES a container key: the retired `page-card-body-to-children` renamed * `properties.body` → `properties.children`, and because the descent happens * after the rename, the sub-tree is walked once under the canonical key instead - * of once per spelling. + * of once per spelling — the ordering stays for the next such conversion. */ function mapComponentTree( component: Dict, diff --git a/packages/spec/src/data/analytics-strictness-batchd.test.ts b/packages/spec/src/data/analytics-strictness-batchd.test.ts index a2ca0a70835..77158b525b4 100644 --- a/packages/spec/src/data/analytics-strictness-batchd.test.ts +++ b/packages/spec/src/data/analytics-strictness-batchd.test.ts @@ -294,9 +294,17 @@ describe('batch D — the strict base does not break the request wrapper', () => }); }); - it('the retired-envelope tombstones still fire — `query` and `format` carry their migration text', () => { - expect(reject(AnalyticsQueryRequestSchema, { ...QUERY, query: { measures: ['count'] } })).toContain('was removed from AnalyticsQueryRequest'); - expect(reject(AnalyticsQueryRequestSchema, { ...QUERY, format: 'csv' })).toContain('was removed from AnalyticsQueryRequest'); + it('the retired envelope keys `query` and `format` are refused as unknown keys naming the upgrade guide', () => { + // Their tombstones were 16.x retirement kits and aged out at 18 (#25767, + // ADR-0087 D2 = A): the strict base refuses each as an unknown key, and the + // refusal points at the protocol upgrade guide, where the envelope + // prescription now lives. Still never a silent strip. + for (const [key, body] of [['query', { measures: ['count'] }], ['format', 'csv']] as const) { + const message = reject(AnalyticsQueryRequestSchema, { ...QUERY, [key]: body }); + expect(message).toContain('unrecognized_keys'); + expect(message).toContain(`\`${key}\``); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + } }); }); diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index 3ae04c6f21b..831ab6654c2 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -54,7 +54,7 @@ const RETIRED_CAPABILITIES: Record = { + 'metadata, so declaring a capability here never changed which engine path ran. Delete the ' + 'block. If you wrote `readOnly: true`, read its note below — it did NOT make anything ' + 'read-only. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', readOnly: CAPABILITIES_REMOVED_PREFIX + '`readOnly` in particular NEVER made a datasource read-only: no write path consulted it, ' @@ -87,7 +87,7 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + 'enforced, but they are a DIFFERENT key on a different type and spell the delay `backoffMs`, ' + 'not `baseDelayMs`. Moving these values onto a hook or a job only makes sense if you ' + 'actually want that hook or job retried. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', healthCheck: '`datasource.healthCheck` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'health-check loop ever read it, so `enabled: true` scheduled nothing and the two timeouts ' @@ -95,12 +95,12 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + '(`ping()` / `checkHealth()`), which the datasource admin service calls for "Test ' + 'connection". The only recurring datasource timer is `external.validation.checkIntervalMs`, ' + 'which checks SCHEMA DRIFT — a different concern, not a liveness probe. Delete the block. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', externalLabel: '`external.label` was removed in @objectstack/spec 17.0.0 (ADR-0049) — nothing read ' + "the federation block's own label. Use the datasource's TOP-LEVEL `label`, which is what " + 'Setup → Datasources actually renders. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', externalRequirePermission: '`external.requirePermission` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'authorization check ever consulted it, so a permission named here gated nothing. Access to ' @@ -108,7 +108,7 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + 'exactly as for a managed datasource. Naming a permission that is never required is the ' + 'false-compliance shape ADR-0049 exists to remove — grant or withhold the object ' + 'permissions instead. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', }; /** @@ -166,7 +166,7 @@ const RETIRED_READ_REPLICAS = + 'Delete the key. There is no read-replica routing to migrate to — if your database fronts ' + 'its replicas behind one endpoint (pgpool, ProxySQL, an RDS reader endpoint), point ' + '`config` at that endpoint, which is the only read-scaling path that works today. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * The refusal of one retired driver spelling as a regular expression: diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index 4ab081012b3..b1f9d4a2e03 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -280,8 +280,9 @@ export const DriverCapabilitiesSchema = lazySchema(() => z.object({ // which is the very defect the removal closes. `retiredKey()` keeps each // key authored-unwritable: tsc rejects it at the authoring site (the class // literal of every driver that `implements IDataDriver`), and a parse - // rejects it with the prescription below. Tombstones age out ~two majors - // after 17 (see shared/retired-key.ts). + // rejects it with the prescription below. Tombstones age out one major + // after the removal (see shared/retired-key.ts; #25767) — these are 17.x + // retirements, so they leave when 19 is cut. create: retiredKey(capRemoved('create', 'CRUD is not optional for a driver: `create`/`find`/`findOne`/`update`/`delete` are ' diff --git a/packages/spec/src/data/driver/config-registry.zod.ts b/packages/spec/src/data/driver/config-registry.zod.ts index 50487c22574..ae6a0212370 100644 --- a/packages/spec/src/data/driver/config-registry.zod.ts +++ b/packages/spec/src/data/driver/config-registry.zod.ts @@ -215,16 +215,16 @@ export interface DriverVocabularyEntry { * The prescription for an authored `datasource.driver: 'mongo'` — the * `mongodb` row's one {@link DriverVocabularyEntry.retiredAliases} entry. * Written to `enumWithRetiredValues`' conventions: the value first, the release - * and ADR that removed it, why, the one-line fix, and the house `os migrate + * and ADR that removed it, why, the one-line fix, and the post-floor `os migrate * meta` sentence (`datasource-driver-mongo-to-mongodb` is a `toMajor: 17` - * entry, so its step is reached by `--from 16`). + * entry whose step left the chain when the support floor rose to 17, #25767). */ const MONGO_DRIVER_ID_RETIRED = "Datasource `driver: 'mongo'` was removed in @objectstack/spec 18 (ADR-0087 D2) — the MongoDB driver's " + 'id has been `mongodb` since 17, the spelling both boot hosts, the `@objectstack/driver-mongodb` package ' + "and every connection URL scheme use, and the loader rewrote `mongo` to it until this release. Use `driver: 'mongodb'`. " + 'Change the value; the driver it builds and the `config` contract it is judged against are unchanged. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * THE table. Everything else in this module is a projection of it. diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index fff70c7ab30..b013d3c705d 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -1906,40 +1906,41 @@ describe('FieldSchema - group property', () => { describe('FieldSchema — the `conditionalRequired` alias is REMOVED', () => { // #3754 lowered the alias into `requiredWhen` and dropped it from the output. - // Protocol 17 removes it from the spec entirely. - // - // TOMBSTONED rather than deleted, because `FieldSchema` is deliberately not - // `.strict()` — this object already carries a comment about the last time a - // key outlived its schema and got silently stripped (`dataQuality` / `cached`, - // #3726 / #3733). A silent strip here would mean the field is simply never - // required, with nothing to read anywhere. - it('rejects an authored `conditionalRequired` instead of silently stripping it', () => { + // Protocol 17 removed it from the spec entirely, tombstoned rather than + // deleted because `FieldSchema` was not `.strict()` then (#3726 / #3733). + // The field shape is strict now, and the tombstone was a 16.x retirement + // kit, so it aged out on #25767 (ADR-0087 D2 = A): the key is refused as a + // strict unknown key, and the refusal's trailing sentence names the + // protocol upgrade guide, where the `requiredWhen` prescription lives. What + // these pins keep is the half that matters — never a silent strip. + it('refuses an authored `conditionalRequired` as a strict unknown key instead of silently stripping it', () => { expect(() => FieldSchema.parse({ type: 'text', conditionalRequired: "status = 'closed_won'", - })).toThrow(/`conditionalRequired` was removed/); + })).toThrow(/Unrecognized key\(s\) on this field: `conditionalRequired`/); }); - it('carries the upgrade prescription in the rejection itself', () => { + it('points the refusal at the upgrade guide, where the prescription now lives', () => { let message = ''; try { FieldSchema.parse({ type: 'text', conditionalRequired: 'amount > 1000' }); } catch (err) { message = String(err); } - expect(message).toContain('use `requiredWhen`'); - expect(message).toContain('os migrate meta'); + expect(message).toContain('Keys retired before the previous protocol major are no longer prescribed here'); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toContain('os migrate meta'); }); - it('rejects it even when the canonical `requiredWhen` is also present', () => { + it('refuses it even when the canonical `requiredWhen` is also present', () => { expect(() => FieldSchema.parse({ type: 'text', requiredWhen: "record.status == 'sent'", conditionalRequired: 'record.amount > 1000', - })).toThrow(/`conditionalRequired` was removed/); + })).toThrow(/Unrecognized key\(s\) on this field: `conditionalRequired`/); }); - it('rejects it on the real authoring path — a field nested in an object', () => { + it('refuses it on the real authoring path — a field nested in an object', () => { // The removal has to bite where authors actually write fields, not only in // a bare `FieldSchema.parse()`. expect(() => ObjectSchema.parse({ @@ -1948,7 +1949,7 @@ describe('FieldSchema — the `conditionalRequired` alias is REMOVED', () => { fields: { amount: { type: 'currency', conditionalRequired: "record.stage == 'closing'" }, }, - })).toThrow(/`conditionalRequired` was removed/); + })).toThrow(/Unrecognized key\(s\) on this field: `conditionalRequired`/); }); it('accepts the canonical `requiredWhen`, alone or beside `required`', () => { @@ -1993,12 +1994,13 @@ describe('FieldSchema - conditional field rules (visibleWhen / readonlyWhen / re // The original assertion pinned the two keys as coexisting in the output; // #3754 inverted it to "canonical wins, alias dropped"; #3855 removes the // alias outright, so the pair is now unrepresentable at the input too — the - // strongest form of the same guarantee. + // strongest form of the same guarantee. The alias's tombstone aged out at + // 18 (#25767), so the refusal is the strict unknown-key one, guide link and all. expect(() => FieldSchema.parse({ type: 'text', requiredWhen: "record.status == 'sent'", conditionalRequired: "record.status == 'closed_won'", - })).toThrow(/`conditionalRequired` was removed/); + })).toThrow(/`conditionalRequired`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); const result = FieldSchema.parse({ type: 'text', requiredWhen: "record.status == 'sent'" }); expect(result.requiredWhen).toEqual({ dialect: 'cel', source: "record.status == 'sent'" }); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 445bb270381..8afcf80b4ea 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; import type { KeySetGuidance } from '../shared/suggestions.zod'; // Package-internal, like `strict-object` itself — the `shared/index.ts` barrel @@ -2020,21 +2019,6 @@ export const FieldSchema = lazySchema(() => { readonlyWhen: EvaluatedExpressionInputSchema.optional().describe("Predicate (CEL) — field is read-only when TRUE. e.g. P`record.status == 'paid'`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every update that writes the field — `objectstack validate` refuses it. Put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference."), requiredWhen: EvaluatedExpressionInputSchema.optional().describe("Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a `validations[]` `script` rule, which re-checks the merged record with no exemption. Enforced by `evaluateValidationRules`. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (`record.account.tier`) faults on every row and refuses every write that reaches it — `objectstack validate` refuses it; put such a check in a `validations[]` `script` rule, whose `condition` is read one hop through a reference. The only slot; the `conditionalRequired` alias was removed in protocol 17."), - /** - * [REMOVED in protocol 17 — #3855] The deprecated alias of `requiredWhen`. - * Tombstoned rather than deleted: `FieldSchema` is `strictObject` (#4001), so - * a plain deletion would reject the key — but with a generic unknown-key - * error that does not name `requiredWhen`. The prescription is the payload: - * it carries the rename, so an author who wrote `conditionalRequired` is told - * where the CEL predicate goes. It also types the key `never`, so the mistake - * fails `tsc` at the authoring site before any parse runs. - */ - conditionalRequired: retiredKey( - '`conditionalRequired` was removed in @objectstack/spec 17 — use `requiredWhen`. ' + - 'Rename the key; the value (a CEL predicate) is unchanged. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * Deadline semantic — `date` / `datetime` only (DEADLINE_FIELD_TYPES). * objectui#11815, maintainer ruling D: overdue is DECLARED, never guessed. diff --git a/packages/spec/src/data/hook-body.zod.ts b/packages/spec/src/data/hook-body.zod.ts index 55bf0f90fde..fdef2828b7b 100644 --- a/packages/spec/src/data/hook-body.zod.ts +++ b/packages/spec/src/data/hook-body.zod.ts @@ -18,7 +18,7 @@ const CRYPTO_HASH_RETIRED = + 'in the host (a Connector recipe, or an engine-side hook) instead. If you need hashing in ' + 'a body, reopen it through the capability admission process — implementation first, the ' + 'declaration lands with the implementation. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * Capability tokens a script body may request. diff --git a/packages/spec/src/data/mapping.zod.ts b/packages/spec/src/data/mapping.zod.ts index ea5ecf870b2..2f161ced2af 100644 --- a/packages/spec/src/data/mapping.zod.ts +++ b/packages/spec/src/data/mapping.zod.ts @@ -44,14 +44,14 @@ const RETIRED_EXTRACT_QUERY = + 'export path that does not exist. Delete the key. Exports run through the ordinary ' + 'query API (`POST /api/v1/data/:object/query`); if a mapping-driven export is ever ' + 'designed, this is where it plugs back in. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; const RETIRED_ERROR_POLICY = '`mapping.errorPolicy` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'import code ever read it, so `skip` / `abort` / `retry` selected between three ' + 'behaviours that were all the same behaviour. Delete the key. Error handling on the ' + 'import path belongs to the import REQUEST\'s own options, not to the stored mapping. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; const RETIRED_BATCH_SIZE = '`mapping.batchSize` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' @@ -61,7 +61,7 @@ const RETIRED_BATCH_SIZE = + 'the seed loader\'s and the NoSQL driver cursor\'s are all LIVE and enforced — but each is ' + 'a DIFFERENT key on a different type sizing its own path, and none of them sizes a ' + 'mapping import. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; const MAPPING_RETIRED_KEY_GUIDANCE: Readonly> = { extractQuery: RETIRED_EXTRACT_QUERY, diff --git a/packages/spec/src/data/object-strictness-batch20.test.ts b/packages/spec/src/data/object-strictness-batch20.test.ts index 21ca0ff5ef8..a387452499a 100644 --- a/packages/spec/src/data/object-strictness-batch20.test.ts +++ b/packages/spec/src/data/object-strictness-batch20.test.ts @@ -565,22 +565,22 @@ describe('批 20 — `IndexSchema` is closed (the held 14th site, once the conso expect(reject(IndexSchema, { fields: ['name'], notAnIndexKey: 1 })).not.toContain('database layer'); }); - it('the `type`/`partial` tombstones SURVIVE the strict close — their prescription, not `unrecognized_keys`', () => { - // The tombstones are declared keys, so the strict unknown-key path never - // sees them; each still answers its own migration text. Losing that prose - // to a generic strict rejection would be a regression the close must not - // ship — pinned per key, on the message itself. - const retiredPartial = IndexSchema.safeParse({ fields: ['name'], partial: "status = 'open'" }); - expect(retiredPartial.success, '`partial` is retired, not merely unknown').toBe(false); - expect(retiredPartial.error!.issues[0]!.message).toMatch(/`indexes\[\]\.partial` was removed.*Delete the key/s); - - const retiredType = IndexSchema.safeParse({ fields: ['name'], type: 'brin' }); - expect(retiredType.success, '`type` is retired, not merely unknown').toBe(false); - expect(retiredType.error!.issues[0]!.message).toMatch(/`indexes\[\]\.type` was removed.*Delete the key/s); - - // …and neither is offered as a rename target for a near-miss: a tombstone - // is excluded from the candidate list (`acceptsNothing`), so `partail` - // must not be answered with the one key the schema cannot accept. + it('the aged-out `type`/`partial` are refused by the strict close as unknown keys naming the upgrade guide', () => { + // Both tombstones were 16.x retirement kits and left the shape at 18 + // (#25767, ADR-0087 D2 = A). The close is what keeps the removal loud: each + // key is an `unrecognized_keys` refusal naming it, with the protocol + // upgrade guide — where the two prescriptions now live — in the message. + for (const body of [{ fields: ['name'], partial: "status = 'open'" }, { fields: ['name'], type: 'brin' }]) { + const retired = IndexSchema.safeParse(body); + expect(retired.success).toBe(false); + const issue = retired.error!.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + } + + // …and neither is offered as a rename target for a near-miss: a key the + // shape no longer declares is no candidate at all, so `partail` must not + // be answered with the spelling the schema cannot accept. expect(reject(IndexSchema, { fields: ['name'], partail: 'x' })).not.toContain('Did you mean'); }); diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 81972629144..8564c193abc 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; // (#5286). import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, isTenancyDisabled, isPublicSharingEnabled, resolveCrudAffordances, type ServiceObject } from './object.zod'; import { resolveInjectedSystemColumns } from './injected-system-columns'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '../shared/protocol-upgrade-guide'; import { projectPublishedJsonSchema } from '../../scripts/lib/refinement-projection'; import { collectDroppedRefinements } from '../../scripts/lib/dropped-refinements'; import { Field } from './field.zod'; @@ -76,8 +77,11 @@ describe('ObjectCapabilities', () => { const message = result.success ? '' : result.error.issues.map((i) => i.message).join('\n'); expect(message).toContain(`\`${key}\``); expect(message, 'the migration command is the durable reference').not.toMatch(/#\d{3,5}/); - // The prescription names the source rewrite (the #3207 conversion). - expect(message).toContain('os migrate meta --from 16'); + // The prescription names the source rewrite the #3207 conversion used to + // perform; the step aged out with the 16.x kits (#25767), so the house + // sentence says so and links the guide instead of naming `--from 16`. + expect(message).toContain('This edit left `os migrate meta` with the protocol-16.x step (support floor 17)'); + expect(message).not.toContain('os migrate meta --from 16'); } // `trash` additionally points at the parked soft-delete issue — the // parking spot the 2026-08-02 #3207 ruling designates (#1893, the old @@ -127,7 +131,7 @@ describe('ObjectCapabilities', () => { it('emission order: which key is wrong → the fix → the history, last', () => { const message = capabilityRejection({ trash: false, searchible: true }); const preamble = 'Unrecognized key(s) on `enable`:'; - const fix = 'os migrate meta --from 16'; + const fix = '`enable.trash` was removed'; const history = 'every flag carries an enforcement contract'; expect(message.startsWith(preamble)).toBe(true); @@ -452,19 +456,24 @@ describe('IndexSchema', () => { * the PRESCRIPTION, not merely the rejection. */ describe('IndexSchema retired keys', () => { - it('REJECTS `type`, with the fix and the reason in the message', () => { + // #25767: both tombstones were 16.x retirement kits and aged out (ADR-0087 + // D2 = A). The index shape is strict, so each key is refused as an unknown + // key and the refusal's trailing sentence names the protocol upgrade guide, + // which carries the two prescriptions from here. Still never a silent strip. + it('REFUSES `type` as a strict unknown key, pointing at the upgrade guide', () => { expect(() => IndexSchema.parse({ fields: ['tags'], type: 'gin' })) - .toThrow(/`indexes\[\]\.type` was removed.*no driver ever read it.*Delete the key/s); + .toThrow(/Unrecognized key\(s\) on this index: `type`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); - it('REJECTS `partial`, naming the database-layer replacement', () => { + it('REFUSES `partial` the same way', () => { expect(() => IndexSchema.parse({ fields: ['name'], partial: "state = 'active'" })) - .toThrow(/`indexes\[\]\.partial` was removed.*Delete the key.*CREATE \[UNIQUE\] INDEX/s); + .toThrow(/Unrecognized key\(s\) on this index: `partial`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); - it('points at the CLI conversion rather than naming the conversion id', () => { + it('no longer names a migrate step — the floor rose past the one that carried these', () => { for (const bad of [{ fields: ['a'], type: 'btree' }, { fields: ['a'], partial: 'x' }]) { - expect(() => IndexSchema.parse(bad)).toThrow(/os migrate meta --from 16/); + expect(() => IndexSchema.parse(bad)).not.toThrow(/os migrate meta --from/); + expect(() => IndexSchema.parse(bad)).toThrow(); } }); @@ -494,7 +503,7 @@ describe('IndexSchema retired keys', () => { label: 'Invoice', fields: { name: { type: 'text', label: 'Name' } }, indexes: [{ fields: ['name'], partial: "state = 'active'" }], - })).toThrow(/`indexes\[\]\.partial` was removed/s); + })).toThrow(/Unrecognized key\(s\) on this index: `partial`/); }); }); @@ -2199,6 +2208,7 @@ describe('tenancy unknown-key message order — bullets before the explainer', ( expect(messageFor({ tenantfield: 'org_id' })).toBe( 'Unrecognized key(s) on `tenancy`: `tenantfield`. ' + 'Did you mean `tenantfield` → `tenantField`? ' + + `${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ` + EXPLAINER, ); }); @@ -2207,7 +2217,7 @@ describe('tenancy unknown-key message order — bullets before the explainer', ( // No tombstone, no near key: no bullet at all — the front matter and the // explainer carry everything the old catch-all bullet said. expect(messageFor({ zzNotAKey: 1 })).toBe( - `Unrecognized key(s) on \`tenancy\`: \`zzNotAKey\`. ${EXPLAINER}`, + `Unrecognized key(s) on \`tenancy\`: \`zzNotAKey\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${EXPLAINER}`, ); }); }); @@ -2702,7 +2712,10 @@ describe('managedBy: retiring the overloaded `system` bucket', () => { expect(msg).toMatch(/managedBy/s); expect(msg).toMatch(/removed in @objectstack\/spec 17/s); expect(msg).toMatch(/system-data/s); - expect(msg).toMatch(/os migrate meta --from 16/s); + // The automated fix rode the protocol-17 step, which aged out with the 16.x + // kits (#25767): the house sentence says so and links the guide. + expect(msg).toMatch(/support floor 17/s); + expect(msg).not.toMatch(/os migrate meta --from 16/s); }); it('accepts the replacement value', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 829e7108518..51eba02161d 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -163,13 +163,13 @@ const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record = { 'soft-delete that never ran). Delete the key. For recoverability use per-field ' + '`trackHistory` (audit trail) or a `lifecycle` policy; soft delete is parked, ' + 'and if built returns as a live enforced flag (ADR-0049 prune-or-build). ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', mru: '`enable.mru` was removed from @objectstack/spec in the 16.x line (' + 'ADR-0049) — Most-Recently-Used tracking was never implemented; no reader ' + 'existed, so the flag changed nothing. Delete the key. If MRU tracking is ' + 'built it returns as a live enforced flag (ADR-0049 prune-or-build). ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', }; /** @@ -569,35 +569,6 @@ export const IndexSchema = lazySchema(() => strictObject({ // is NOT NULL-safe (#5030). unique: DeclaredIndexUniqueScopeSchema.optional().default(false).describe("Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization'").meta({ title: 'Unique' }), - // ── Tombstones (ADR-0049 / ADR-0087) ───────────────────────────────── - // Kept LAST in the shape on purpose — see the #5606 note in the block - // comment above. `IndexSchema` is not `.strict()`, so a plain delete would - // make Zod strip an authored value silently, which is the same no-op these - // keys already were (#3726 / #3733, the ADR-0104 class). The tombstone - // makes the removal audible in the two channels an upgrading author - // actually reads: `tsc` (input type `never`) and the parse itself. - // `object-index-type-partial-removed` strips both from stored/authored - // sources on the protocol-17 migration. - type: retiredKey( - '`indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever ' + - 'read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex\'s ' + - '`table.index()` / `table.unique()`, which cannot express an access method, so the value ' + - 'changed no DDL; its `.default(\'btree\')` merely made an inert knob show up in every parse ' + - 'output. Delete the key. The index method is the driver/dialect\'s decision (Postgres ' + - 'defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a ' + - 'database-layer migration when a workload actually needs one). ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - partial: retiredKey( - '`indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + - 'driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a ' + - 'FULL index and the predicate silently did nothing. Delete the key. Partial indexes are ' + - 'built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … ' + - 'WHERE ` from a runtime migration (this is what `metadata-protocol`\'s ' + - '`ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it ' + - 'reads partiality back from the database\'s own DDL, never from this key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), })); /** @@ -1522,7 +1493,7 @@ const MANAGED_BY_SYSTEM_RETIRED = + 'and can be deleted; keep it only to NARROW. CSV `import` is deliberately NOT in that ' + 'default: it stays opt-in per object via `userActions: { import: true }`, which ' + 'is what a v16 `system` object already resolved to. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * Known-confusable schema keys → precise authoring guidance. @@ -1574,8 +1545,9 @@ const UNKNOWN_KEY_GUIDANCE: Record = { // the one upgrade channel every consumer is guaranteed to hit — an agent // bumping @objectstack/spec sees THIS message, not our docs site. Each entry // names what replaced the key and the version/decision that removed it. - // Tombstones age out too: drop an entry ~two majors after the removal - // (by then it's archaeology, not an upgrade; see CHANGELOG.md for history). + // Tombstones age out too: drop an entry one major after the removal (#25767; + // by then it's archaeology, not an upgrade — the protocol upgrade guide and + // CHANGELOG.md carry the history, and the strict refusal links the guide). namespace: '`namespace` was retired (ADR-0129 D3) — the object `name` IS the canonical id ' + 'everywhere (API, ObjectQL, REST, SDK, DB table), so there is no separate namespace ' + diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 62db53f803b..660f321bb22 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -91,7 +91,7 @@ const AGG_RETIRED_TAIL = 'There is no replacement in the query vocabulary: read the rows with an ordinary `fields` ' + 'query and shape them in the caller, or model the roll-up as a stored field. It returns ' + 'only WITH a portable lowering — ADR-0049\'s enforce leg, implementation first. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; const ARRAY_AGG_RETIRED = '`array_agg`' + AGG_RETIRED_MIDDLE diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 84b04fa30ed..e6f3cc1d10e 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -967,7 +967,7 @@ const ConnectorBaseSchema = lazySchema(() => z.object({ 'here was inert while reading like a configured cap. Delete the key. Do NOT substitute ' + '`shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; ' + 'until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', ), /** diff --git a/packages/spec/src/kernel/manifest-unknown-keys.test.ts b/packages/spec/src/kernel/manifest-unknown-keys.test.ts index f9b9020cc71..f575f592b42 100644 --- a/packages/spec/src/kernel/manifest-unknown-keys.test.ts +++ b/packages/spec/src/kernel/manifest-unknown-keys.test.ts @@ -340,7 +340,6 @@ describe('the accept side does not move, and `main` is declared', () => { it('a retired key still answers with its tombstone prescription — closing the surface did not replace it with a bare refusal', () => { for (const [key, value] of [ - ['loading', { strategy: 'lazy' }], ['capabilities', { implements: [] }], ['configuration', { title: 'Cfg' }], ['extensions', { a: 1 }], @@ -357,6 +356,17 @@ describe('the accept side does not move, and `main` is declared', () => { } }); + it('an aged-out retired key (`loading`, a 16.x kit) is refused as an unknown key naming the upgrade guide', () => { + // #25767 (ADR-0087 D2 = A): the tombstone left the shape one major after + // it retired, so the closed surface is what keeps the removal loud — the + // strict refusal names the key and the guide the prescription moved to. + const result = ManifestSchema.safeParse({ ...legal(), loading: { strategy: 'lazy' } }); + expect(result.success).toBe(false); + const issue = unrecognized(result)!; + expect(issue.keys).toEqual(['loading']); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + }); + it('a near miss of a retired key is refused WITHOUT being pointed at the tombstone', () => { // `capabilitis` is one keystroke from the retired `capabilities`. The rename // channel must not send the author there — the second rejection would tell diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index ebb1f2158b4..81d2d8e9ed3 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -986,45 +986,6 @@ export const ManifestSchema = strictObject({ navigationContributions: z.array(NavigationContributionSchema).optional() .describe('Navigation items this package contributes into apps owned by other packages'), - /** - * REMOVED in v17 (#4914, ADR-0049 enforce-or-remove). - * - * `loading` carried the whole `PluginLoadingConfig` block — `strategy`, - * `preload`, `codeSplitting`, `dynamicImport`, `initialization`, - * `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring` - * — and NOTHING read any of it. It parsed, it entered the manifest, and it - * changed nothing. Tombstoned rather than deleted: when this landed - * `ManifestSchema` was not `.strict()`, so a plain deletion would have - * silently stripped the key, replacing an inert declaration with an invisible - * one; the surface has since been closed, and the tombstone stays because it - * types the key `never` and carries this prescription, which a bare refusal - * would not. - * - * See `plugin-loading.zod.ts` for the full record, including why `sandboxing` - * made this a security concern and not merely tidying. - */ - loading: retiredKey( - '`manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 ' + - 'enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, ' + - '`dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, ' + - '`sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it ' + - 'configured nothing. Delete the key. Plugins are composed at boot — `defineStack` ' + - 'registers them and the kernel runs `init` then `start` in an order topologically ' + - "resolved from each composed plugin's own `dependencies` / `optionalDependencies` " + - '(`resolvePluginOrder`); the set is fixed until the process restarts. ' + - '⚠️ `loading.sandboxing` in particular never isolated anything: it did not run ' + - 'plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no ' + - 'call. If you were relying on it for isolation, you had none — and the plugin trust ' + - 'tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud ' + - 'marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is ' + - 'rejected with HTTP 422 and forced to manual review), while load-side enforcement is ' + - 'NOT implemented, so a locally installed plugin is not isolated by the tier it ' + - 'declares. ⛔ Nor do the permission declarations give it back: the install-time ' + - 'granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by ' + - 'nothing, so it refuses no operation. Neither surface confines a plugin today — do ' + - 'not author either one expecting isolation.', - ), - /** * Platform Compatibility Requirements. * Specifies the minimum ObjectStack platform version required to run this package. diff --git a/packages/spec/src/kernel/plugin-loading-retirement.test.ts b/packages/spec/src/kernel/plugin-loading-retirement.test.ts index 081de1c5875..d2d9e697a59 100644 --- a/packages/spec/src/kernel/plugin-loading-retirement.test.ts +++ b/packages/spec/src/kernel/plugin-loading-retirement.test.ts @@ -18,11 +18,14 @@ import { ManifestSchema } from './manifest.zod'; // vocabulary as proof the platform isolates plugins, wrote the config, and got a // clean parse and zero isolation. // -// Route: `retiredKey()` tombstone, NOT plain deletion. `ManifestSchema` is not -// `.strict()`, so deleting the key would make zod strip it in silence — -// replacing an inert declaration with an invisible one (the #3726 / #3733 shape, -// ADR-0104). The tombstone is audible in two channels: `tsc` (the key's input -// type is `never`) and the parse below. +// Route at the time: `retiredKey()` tombstone, NOT plain deletion — then +// `ManifestSchema` was not `.strict()`, so deleting the key would have made zod +// strip it in silence (the #3726 / #3733 shape, ADR-0104). The surface closed +// since, and the tombstone — a 16.x retirement kit — aged out of the shape at +// 18 (#25767, ADR-0087 amendment, D2 = A). What keeps the removal audible now +// is the strict refusal itself: `tsc` (TS2353, the key does not exist on the +// type) and the `unrecognized_keys` issue below, which names the key and the +// protocol upgrade guide the prescription moved to. // // ⚠️ On the assertion set. The dispatch asked for the ADR-0112 rejection // envelope (`code` + `status`) as the floor for a rejection-class case. That @@ -47,62 +50,48 @@ describe('manifest.loading retirement', () => { name: 'Example Plugin', } as const; - it('REJECTS an authored `loading` block, naming the key and carrying the fix', () => { - const result = ManifestSchema.safeParse({ - ...baseManifest, - loading: { strategy: 'lazy' }, - }); - + /** The one `unrecognized_keys` issue of a refused manifest, naming `loading`. */ + const unknownKeyRefusal = (loading: Record) => { + const result = ManifestSchema.safeParse({ ...baseManifest, loading }); expect(result.success).toBe(false); - if (result.success) return; // narrowing; the assertion above already failed + if (result.success) return undefined; + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys') as + | { code: string; keys: string[]; path: PropertyKey[]; message: string } + | undefined; + expect(issue, 'the refusal must be the strict unknown-key one').toBeDefined(); + return issue!; + }; - const issue = result.error.issues.find((i) => i.path[0] === 'loading'); - expect(issue, 'the refusal must name `loading`').toBeDefined(); + it('REFUSES an authored `loading` block as an unknown key, naming the key and the upgrade guide', () => { + const issue = unknownKeyRefusal({ strategy: 'lazy' })!; // The machine-readable half of the envelope this surface actually has. - expect(issue!.code).toBe('invalid_type'); - expect(issue!.path).toEqual(['loading']); - // The prescription itself — this string IS the migration doc for whoever - // hits it, so it is contract, not commentary. - expect(issue!.message).toMatch(/`manifest\.loading`.*removed.*17\.0\.0/s); - expect(issue!.message).toMatch(/Delete the key/s); + expect(issue.keys).toEqual(['loading']); + expect(issue.path).toEqual([]); + // No tombstone issue beside it: the key is unknown now, not declared `never`. + expect(ManifestSchema.safeParse({ ...baseManifest, loading: { strategy: 'lazy' } }).error!.issues + .filter((i) => i.path[0] === 'loading')).toEqual([]); + // Where the prescription (sandboxing never isolated anything; the trust + // tier is `manifest.runtime`) now lives — the message says so. + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); - it('REJECTS the sandboxing block specifically, since that is the dangerous one', () => { - const result = ManifestSchema.safeParse({ - ...baseManifest, - loading: { sandboxing: { enabled: true, isolationLevel: 'process' } }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - - const issue = result.error.issues.find((i) => i.path[0] === 'loading'); - expect(issue!.code).toBe('invalid_type'); - // The prescription must say plainly that no isolation was ever applied — - // an author who believed otherwise has a security decision to revisit, and - // silence here is what made the inert key dangerous in the first place. - expect(issue!.message).toMatch(/never isolated anything/s); - expect(issue!.message).toMatch(/manifest\.runtime/s); + it('REFUSES the sandboxing block the same way — the dangerous one is not stripped in silence', () => { + // An author who believed the block isolated anything has a security + // decision to revisit; a clean parse here is exactly what made the inert + // key dangerous, and the strict refusal is what forbids it. + const issue = unknownKeyRefusal({ sandboxing: { enabled: true, isolationLevel: 'process' } })!; + expect(issue.keys).toEqual(['loading']); }); - it('REJECTS hotReload too, and points at the surviving vocabulary', () => { - const result = ManifestSchema.safeParse({ - ...baseManifest, - loading: { hotReload: { enabled: true, strategy: 'state-preserve' } }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error.issues.find((i) => i.path[0] === 'loading')!.code).toBe('invalid_type'); + it('REFUSES hotReload too', () => { + expect(unknownKeyRefusal({ hotReload: { enabled: true, strategy: 'state-preserve' } })!.keys).toEqual(['loading']); }); it('parses cleanly once the key is deleted, and grows no `loading` property', () => { const parsed = ManifestSchema.parse({ ...baseManifest }); expect(parsed.id).toBe('com.example.plugin'); - // Absence must stay absence. `ManifestSchema` is closed now, so a plain - // deletion would no longer strip an authored `loading` in silence — it would - // refuse it with a bare unknown-key message, without the prescription; this - // pin plus the rejection above are what keep the tombstone honest. + // Absence must stay absence. `ManifestSchema` is closed, which is the whole + // reason the tombstone could age out without the key going silent again. expect(parsed).not.toHaveProperty('loading'); }); diff --git a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts index 4bb8ad88867..3b82aada926 100644 --- a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts +++ b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { ManifestSchema, PluginRuntimeSchema } from './manifest.zod'; -import { entry as loadingRetiredEntry } from '../migrations/entries/semantic/17.plugin-manifest-loading-retired'; // ─── [commit a9ee98992] `manifest.runtime` text states publish-gate-only enforcement ── // @@ -31,12 +30,18 @@ import { entry as loadingRetiredEntry } from '../migrations/entries/semantic/17. // without that negative pin, a future edit could re-add "which are enforced" // beside the new text and every positive assertion would stay green. // -// ⚠️ On the assertion set: a describe string and a tombstone prescription are -// not a rejection envelope, so there is no ADR-0112 `code` + `status` to pin -// here (the tombstone's own envelope is pinned in -// `plugin-loading-retirement.test.ts`). Where the WORDING is the contract, the -// wording is what gets pinned (#5240). The final case guards the other -// direction — that this text-only change moved no accept/reject behaviour. +// ⚠️ On the assertion set: a describe string is not a rejection envelope, so +// there is no ADR-0112 `code` + `status` to pin here. Where the WORDING is the +// contract, the wording is what gets pinned (#5240). The final case guards the +// other direction — that this text-only change moved no accept/reject +// behaviour. +// +// [#25767] The `manifest.loading` tombstone and its ADR-0087 D3 entry — the +// two other carriers of this sentence, whose halves this file used to pin in +// the same way — were a 16.x retirement kit and aged out at 18 (ADR-0087 +// amendment, D2 = A). `loading` is a strict unknown key now, pinned in +// `plugin-loading-retirement.test.ts`; the describes below are the carriers +// that remain, and the ones the reference docs render. /** Both halves of the truthful split, as separate probes. */ const SAYS_PUBLISH_GATE_ENFORCED = /publish gate/i; @@ -80,91 +85,17 @@ describe('manifest.runtime trust-tier text is truthful', () => { expect(text).toMatch(SAYS_LOAD_SIDE_NOT_ENFORCED); }); - it('corrects the `loading` tombstone that redirected authors here', () => { - const result = ManifestSchema.safeParse({ - ...baseManifest, - loading: { sandboxing: { enabled: true, isolationLevel: 'process' } }, - }); - - expect(result.success).toBe(false); - if (result.success) return; - - const message = result.error.issues.find((i) => i.path[0] === 'loading')!.message; - - // Still names the tier — the redirection itself is not the defect, the - // unqualified "which are enforced" was. - expect(message).toMatch(/manifest\.runtime/); - expect(message).toMatch(SAYS_PUBLISH_GATE_ENFORCED); - expect(message).toMatch(SAYS_422); - expect(message).toMatch(SAYS_LOAD_SIDE_NOT_ENFORCED); - - // ⛔ The retracted claim must not come back. This is the pin that makes the - // three above mean something: they all stay green if someone re-adds the - // old sentence alongside the new one. - expect( - message, - 'the tombstone must not claim the trust tier is enforced without qualification', - ).not.toMatch(/trust tier \(`manifest\.runtime`\) and the permission declarations, which are enforced/); - }); - - it('states the PERMISSIONS half truthfully too — the handoff landed', () => { - // The coordination pin this replaces read - // `expect(message).toContain('the permission declarations, which are enforced')` - // and existed to go RED the day commit aaacf1d5c corrected the other half of the - // sentence. It did its job: maintainer ruling 2026-09-12 took option B on - // the permissions half as well — say it truthfully now — so the retracted - // claim is gone and the truthful split is asserted in its place. - // - // Both halves of the tombstone now name a surface that does NOT confine a - // plugin, for two DIFFERENT reasons, and the sentence has to keep both: - // • the trust tier — enforced at the marketplace publish gate, not at load; - // • the permission declarations — REGISTERED on the enforcer at load - // (#13457) and queried by nothing, so they refuse no operation. - // The measurement behind the second lives in `@objectstack/core`: - // `granted-permissions-not-enforced.pin.test.ts` goes red when a production - // `SecurePluginContext` construction site appears (measured in commit aaacf1d5c, the ADR-0025 - // materialize seam), which is the signal that this text is stale again. + it('`loading` no longer carries the sentence at all — it is a strict unknown key (#25767)', () => { + // The tombstone that used to redirect authors here aged out; the refusal + // an author meets is the strict one, naming the key and the upgrade guide. + // Pinned so a re-added tombstone would have to bring the truthful split + // back with it, under the pins above. const result = ManifestSchema.safeParse({ ...baseManifest, loading: { strategy: 'lazy' } }); expect(result.success).toBe(false); if (result.success) return; - - const message = result.error.issues.find((i) => i.path[0] === 'loading')!.message; - expect(message).toMatch(SAYS_GRANT_REGISTERED_NOT_ENFORCED); - // The negative pin, for the same reason the tier half carries one: without - // it, a future edit could re-add the retracted sentence beside the truthful - // one and the positive assertion above would stay green. - expect( - message, - 'the tombstone must not claim the permission declarations are enforced', - ).not.toMatch(/permission declarations, which are enforced/); - }); - - it('corrects the permissions half in the shipped ADR-0087 D3 entry too', () => { - // Same two-carrier problem the tier half had: the tombstone ships once in - // the schema and once through this entry into `docs/protocol-upgrade-guide.md`. - const text = loadingRetiredEntry.replacement; - - expect(text).toMatch(SAYS_GRANT_REGISTERED_NOT_ENFORCED); - expect( - text, - 'the D3 entry must not present the permission declarations as an enforced surface', - ).not.toMatch(/permission declarations, which\s+are the surfaces the platform actually enforces/); - }); - - it('corrects the same claim in the shipped ADR-0087 D3 entry', () => { - // The tombstone ships TWICE — once in the schema, once through this entry - // into `docs/protocol-upgrade-guide.md`. Fixing only the schema would leave - // the upgrade guide still telling authors the tier is a surface "the - // platform actually enforces". - const text = loadingRetiredEntry.replacement; - - expect(text).toMatch(SAYS_PUBLISH_GATE_ENFORCED); - expect(text).toMatch(SAYS_422); - expect(text).toMatch(SAYS_LOAD_SIDE_NOT_ENFORCED); - expect( - text, - 'the D3 entry must not present the trust tier as an enforced surface', - ).not.toMatch(/trust tier\s*\(`manifest\.runtime`, ADR-0025 §3\.6\) and the manifest permission declarations/); + expect(result.error.issues.filter((i) => i.path[0] === 'loading')).toEqual([]); + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys')!; + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('changes NO accept/reject behaviour — every tier still parses as before', () => { diff --git a/packages/spec/src/migrations/chain.ts b/packages/spec/src/migrations/chain.ts index 4776c104d69..882abeb95c6 100644 --- a/packages/spec/src/migrations/chain.ts +++ b/packages/spec/src/migrations/chain.ts @@ -13,6 +13,7 @@ */ import { PROTOCOL_MAJOR } from '../kernel/protocol-version.js'; +import { PROTOCOL_UPGRADE_GUIDE_URL } from '../shared/protocol-upgrade-guide.js'; import { ALL_CONVERSIONS } from '../conversions/registry.js'; import type { MetadataConversion } from '../conversions/types.js'; import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR } from './registry.js'; @@ -175,7 +176,12 @@ export class MigrationFloorError extends Error { ) { super( `Cannot migrate from protocol ${fromMajor}: the chain's support floor is ${floor} ` + - `(ADR-0087 D3). Upgrade to protocol ${floor} by another path first, then re-run.`, + `(ADR-0087 D3). Upgrade to protocol ${floor} by another path first, then re-run. ` + + // A kit retired at or below the floor is gone with its step (one major + // past its retirement, ADR-0087 amendment #25767): the keys are refused + // as unknown keys and their prescriptions live only in the upgrade guide. + `Keys retired at or below protocol ${floor - 1} are refused as unknown keys and ` + + `prescribed in the protocol upgrade guide: ${PROTOCOL_UPGRADE_GUIDE_URL}`, ); this.name = 'MigrationFloorError'; } diff --git a/packages/spec/src/migrations/entries/README.md b/packages/spec/src/migrations/entries/README.md index 5f04e3b1179..2f02eb42765 100644 --- a/packages/spec/src/migrations/entries/README.md +++ b/packages/spec/src/migrations/entries/README.md @@ -35,9 +35,16 @@ Write **one new file**, named `..ts` with `/` and `:` replac `__`, then run `pnpm --filter @objectstack/spec gen:migration-registry`: ``` -17 + data/AggregationNode:distinct → retired-keys/17.data__AggregationNode__distinct.ts +18 + data/AggregationNode:distinct → retired-keys/18.data__AggregationNode__distinct.ts ``` +The major in the name is the one the retirement **happens in** (the build's +`CURRENT_MAJOR`), and it is also the entry's lifetime: a kit lives one protocol major +after the one it retired in and leaves when the next major is cut (ADR-0087 amendment +2026-10-26, #25767 — at 18 every `17.*` file left with step 17). A row must never leave +AHEAD of its tombstone; the deletion gate dates a tombstone whose row is gone by the +previous major's release tag instead. + ```ts // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts deleted file mode 100644 index 05cf82bc576..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// Commit f549a0d4a — api/protocol.zod.ts view-management operations -export const entry = 'api/CreateViewRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts deleted file mode 100644 index b2d39ed8b09..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/CreateViewResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts deleted file mode 100644 index 160ccd3fedf..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/DeleteViewRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts deleted file mode 100644 index 056e1e53f13..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/DeleteViewResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts deleted file mode 100644 index aadf5c45e56..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/GetViewRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts deleted file mode 100644 index 3de2069d8a7..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/GetViewResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts deleted file mode 100644 index 1c7f230fb2c..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/ListViewsRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts deleted file mode 100644 index cba1d055c72..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/ListViewsResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts deleted file mode 100644 index 75662754aa2..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/UpdateViewRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts deleted file mode 100644 index 84d425c2022..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/UpdateViewResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts deleted file mode 100644 index fc694c27871..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #6414 — automation/etl.zod.ts, the whole L2 layer -export const entry = 'automation/ETLDestination'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts deleted file mode 100644 index 248634862ca..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'automation/ETLEndpointType'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts deleted file mode 100644 index 62ebcdbef69..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'automation/ETLPipeline'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts deleted file mode 100644 index 4b955208371..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'automation/ETLPipelineRun'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts deleted file mode 100644 index 1c926428254..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'automation/ETLRunStatus'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts deleted file mode 100644 index 6a96761dea4..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'automation/ETLSource'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts deleted file mode 100644 index c3155fc5703..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'automation/ETLSyncMode'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts deleted file mode 100644 index f4a1ce14fd8..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'automation/ETLTransformation'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts deleted file mode 100644 index eff52c5ef56..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'automation/ETLTransformationType'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalDataSource.ts b/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalDataSource.ts deleted file mode 100644 index 22031d64f5e..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalDataSource.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8075 — data/external-lookup.zod.ts, retired whole (ADR-0049 -// enforce-or-remove; fork (b) of the #8075 census, accepted 2026-08-12). The -// external-data-source shape whose `authentication.config` record accepted -// inline OAuth client secrets and API keys — its own docblock example wrote -// `"clientSecret": "..."`. Zero consumers: no metadata-type binding, no stack -// collection, no object/field embedding (`object.external` binds -// `ObjectExternalBindingSchema`, which routes credentials through datasource -// config per ADR-0015/0062), and its only in-module consumer -// (`ExternalLookupSchema.dataSource`) was itself consumed by nothing. Route 3: -// no tombstone, no D2 conversion — this table plus the D3 -// `external-lookup-message-queue-families-retired` are the declaration. -export const entry = 'data/ExternalDataSource'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalFieldMapping.ts b/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalFieldMapping.ts deleted file mode 100644 index 6db5a640891..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalFieldMapping.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8075 — data/external-lookup.zod.ts, retired whole (ADR-0049). Extended -// `shared/FieldMapping` and was embedded only by `ExternalLookupSchema`, which -// nothing consumed. Its `transform` key's #5552 `retiredKey()` tombstone — and -// the `data/ExternalFieldMapping:transform` RETIRED_KEYS entry that registered -// it — are SUBSUMED here, the WidgetManifest.performance way: they go with the -// shape that carried them, which is strictly stronger, because there is no -// longer a mapping shape to author the key INTO. The base tombstone on -// `shared/FieldMapping` and the `integration/ConnectorFieldMapping` spelling -// are untouched and still reject the key with the #5552 prescription. -export const entry = 'data/ExternalFieldMapping'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalLookup.ts b/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalLookup.ts deleted file mode 100644 index 6b2c74e8e13..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalLookup.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8075 — data/external-lookup.zod.ts, retired whole (ADR-0049). The root of -// the module's dependency shape: it embedded `ExternalDataSource` (the inline -// credential sink) and `ExternalFieldMapping`, and was itself referenced by no -// stack collection, metadata type or import outside packages/spec — the -// #5552 conversion's docblock had already recorded that no external-lookup -// document exists for the conversion walker to visit. Real-time external -// lookup returns via the enforce route of ADR-0049: the executor first, the -// vocabulary second. -export const entry = 'data/ExternalLookup'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts deleted file mode 100644 index 331beecad79..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #4914 — the plugin manifest's `loading` block (ADR-0049 enforce-or-remove, -// maintainer ruling 2026-08-04). `PluginLoadingConfig` was reachable from -// authored metadata ONLY through `Manifest.loading`, and the ten members -// registered with it were embedded only by it, so retiring the carrier key -// unpublishes the whole closure. The carrier itself is a `retiredKey()` tombstone -// registered one level up in `RETIRED_KEYS_BY_MAJOR`. -// -// ⚠️ `kernel/PluginLoadingEvent` and `kernel/PluginLoadingState` are -// deliberately NOT here. They live in the same module and share its prefix, -// but neither was ever embedded in `PluginLoadingConfig` — they are the -// observational half (a lifecycle event and a per-plugin state), they are -// not authorable, and they still emit. Module adjacency is not evidence, -// the `system/ServerRateLimitConfig` note above applies verbatim. -export const entry = 'kernel/PluginCaching'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts deleted file mode 100644 index d3e3ee13ba4..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginCodeSplitting'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts deleted file mode 100644 index 17d90748618..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginDependencyResolution'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts deleted file mode 100644 index 04686103f40..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginDynamicImport'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts deleted file mode 100644 index 94495ef9818..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginHotReload'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts deleted file mode 100644 index b055f785a6c..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginInitialization'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts deleted file mode 100644 index b9110663954..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginLoadingConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts deleted file mode 100644 index 3e735c9438d..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginLoadingStrategy'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts deleted file mode 100644 index ab25566970a..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginPerformanceMonitoring'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts deleted file mode 100644 index 432e74661cc..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginPreloadConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts deleted file mode 100644 index cd0ac01d6da..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'kernel/PluginSandboxing'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts b/packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts deleted file mode 100644 index 9875fcf6fcc..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'shared/FieldMappingTransform'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ConsumerConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ConsumerConfig.ts deleted file mode 100644 index f0ca17f7992..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.system__ConsumerConfig.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). Consumer -// group config (groupId / offset reset / auto-commit / poll size), embedded -// only by `system/MessageQueueConfig` (retired in the same change) and -// consumed by nothing. -export const entry = 'system/ConsumerConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__DeadLetterQueue.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__DeadLetterQueue.ts deleted file mode 100644 index 1c4c4b94735..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.system__DeadLetterQueue.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). DLQ config, -// embedded only by `system/MessageQueueConfig` (retired in the same change) -// and consumed by nothing. ⚠️ `kernel/DeadLetterQueueEntry` is a different -// declaration (the event bus's per-event DLQ record) and is untouched — -// name adjacency is not evidence, the `system/ServerRateLimitConfig` note -// applies verbatim. -export const entry = 'system/DeadLetterQueue'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueConfig.ts deleted file mode 100644 index 6e428a2ace6..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueConfig.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049 -// enforce-or-remove; fork (b) of the #8075 census, accepted 2026-08-12). The -// top-level broker-administration config, carrying a required inline -// `sasl.password` credential when `sasl` was present. Zero consumers outside -// packages/spec repo-wide; no `message_queue` metadata type; the connector -// 'message_queue' ConnectorType enum value never referenced this schema. The -// consumed near-namesake `kernel/EventMessageQueueConfig` -// (`EventBusConfigSchema.messageQueue`) deliberately carries NO credential -// field and is untouched. Route 3: no tombstone, no D2 conversion — this -// table plus the D3 `external-lookup-message-queue-families-retired` are the -// declaration. -export const entry = 'system/MessageQueueConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueProvider.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueProvider.ts deleted file mode 100644 index 36736ba1598..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueProvider.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). The -// six-value broker provider enum, embedded only by -// `system/MessageQueueConfig` (retired in the same change). Not the same -// declaration as `kernel/EventMessageQueueConfig.provider` — that is its own -// INLINE `z.enum` (same six values, never a reference to this def) and is -// untouched. -export const entry = 'system/MessageQueueProvider'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts deleted file mode 100644 index bfe2c4af80d..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #5295 — system/http-server.zod.ts runtime vocabulary -export const entry = 'system/ServerCapabilities'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts deleted file mode 100644 index 560189a7f52..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'system/ServerEvent'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts deleted file mode 100644 index ab99cedf27a..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'system/ServerEventType'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts deleted file mode 100644 index ff3b131953b..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'system/ServerStatus'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__TopicConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__TopicConfig.ts deleted file mode 100644 index 42e88b4fe08..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.system__TopicConfig.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). Topic -// partitions/replication/retention/compression config, embedded only by -// `system/MessageQueueConfig` (retired in the same change) and consumed by -// nothing — leaving it behind would strand an exported schema with no -// consumer (#3950). -export const entry = 'system/TopicConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts deleted file mode 100644 index 62d51f175a1..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/DateFormat'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts deleted file mode 100644 index fb1d94fd923..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/I18nObject'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts deleted file mode 100644 index 65409a4e602..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/LocaleConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts deleted file mode 100644 index e8cc4bbc308..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/NumberFormat'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts deleted file mode 100644 index bf8390d4dc5..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/PluralRule'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts deleted file mode 100644 index cbfd60752bd..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/WidgetEvent'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts deleted file mode 100644 index 9b9bfc23d29..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/WidgetLifecycle'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts deleted file mode 100644 index 9cd6475bf8e..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/WidgetManifest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts deleted file mode 100644 index b6bffe48e02..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/WidgetProperty'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts deleted file mode 100644 index 2ced2c07e12..00000000000 --- a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/WidgetSource'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts deleted file mode 100644 index a2e10323ed8..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7481 — the sibling of `api/AuthFeaturesConfig:passkeys`, retired in the same -// maintainer ruling (2026-08-11) and for the same reason, but with one -// difference a reader of this table must not lose: what was inert here is the -// ADVERTISEMENT, not the capability. `AuthPluginConfig.plugins.magicLink` still -// wires better-auth's magic-link plugin and `/api/v1/auth/magic-link/{send,verify}` -// still answer — no client just renders anything off the served flag, so the -// flag alone was the false promise. The prescription says so explicitly rather -// than reusing its sibling's string. -// -// Same response-surface disposition as its sibling: no D2 conversion (nothing -// authors an `AuthFeaturesConfig`), prescription carried by this tombstone plus -// the D3 semantic entry `auth-config-unadvertised-reserved-features`. -export const entry = 'api/AuthFeaturesConfig:magicLink'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts deleted file mode 100644 index 21d69da3246..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7481 — the `/api/v1/auth/config` `features` payload stops advertising the -// two capabilities no client can act on (maintainer ruling 2026-08-11, -// declared = enforced: a deployer must not be able to flip a flag that does -// nothing anywhere). `passkeys` is the emptier of the pair — no login UI reads -// it AND no better-auth passkey plugin is wired behind it. -// -// Registered here but NOT in `src/conversions/registry.ts`, for the same reason -// as the `api/ListNotifications{Request,Response}:cursor` pair: this is a -// RESPONSE surface — the server mints it on every `GET /auth/config` and -// nobody authors or persists an `AuthFeaturesConfig` — so there is no source -// for `os migrate meta` to rewrite. The prescription reaches consumers as the -// D3 semantic entry `auth-config-unadvertised-reserved-features` plus this -// tombstone, the `EnhancedApiError.fieldErrors` disposition. -// -// The withdrawal is conditional, not permanent: the flags return in the change -// that ships the login UI (objectui#4179). Until then the standing record is -// `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED` in `kernel/public-auth-features.ts`. -export const entry = 'api/AuthFeaturesConfig:passkeys'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts deleted file mode 100644 index e2b5105845c..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// Commit 90bbf2510 — the notification-inbox pagination key, tombstoned on BOTH halves -// of `GET /api/v1/notifications` because one capability is never half- -// deleted (maintainer ruling 2026-08-07, ruled jointly with the `unreadCount` fix, commit 17d095413). Two -// keys, one prescription: `NOTIFICATIONS_CURSOR_REMOVED` in -// `api/protocol.zod.ts` is the single string both rejection sites raise. -// -// Registered here but NOT in `src/conversions/registry.ts`, and that -// asymmetry is the point rather than an omission: a D2 conversion rewrites -// an authored source or a stored `sys_metadata` row, and these two shapes -// are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing -// persists one. The prescription reaches consumers as the D3 semantic entry -// `notification-list-cursor-retired` plus this tombstone, which is the -// disposition `BatchOptions.validateOnly` and the `AnalyticsQueryRequest` -// envelope keys already take in this major ("a semantic TODO for API -// callers rather than a stack conversion"). -export const entry = 'api/ListNotificationsRequest:cursor'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts deleted file mode 100644 index 12ffedb7a27..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'api/ListNotificationsResponse:cursor'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts b/packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts deleted file mode 100644 index e0afe1c7fb6..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #6748 — ADR-0049 enforce-or-remove on the action-descriptor capability -// block. `isAsync` was a second spelling of `supportsPause` with ZERO -// readers on a fresh three-repo measurement; its sibling took the enforce -// leg in #6667 and this one takes the remove leg. Descriptors are published -// from executor TypeScript, not from stack metadata, so the D2 side is a D3 -// `SemanticMigration` (`action-descriptor-is-async-retired`) rather than a -// MetadataConversion — there is no stored source for `os migrate meta` to -// rewrite. The `EnhancedApiError.fieldErrors` precedent. -export const entry = 'automation/ActionDescriptor:isAsync'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts deleted file mode 100644 index edec2c08bd3..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #6815 — the per-aggregation DISTINCT flag, retired under ADR-0049 by -// maintainer ruling 2026-08-09. ONE key, and one entry, because -// `AggregationNodeSchema` is reused BY REFERENCE rather than `.extend()`ed: -// `QuerySchema.aggregations` and `EngineAggregateOptionsSchema. -// aggregations` are both `z.array(AggregationNodeSchema)`, so the walked -// shape has a single `data/AggregationNode` def and the baseline marks one -// line `[RETIRED]`. Contrast the `shared/FieldMapping:transform` trio in -// this same table, where two `.extend()`s copied the property into three -// walked shapes and each needed its own registration. -// -// Registered here but NOT in `src/conversions/registry.ts`, for the same -// reason as the `api/ListNotifications{Request,Response}:cursor` pair: -// `QueryAST` is a REQUEST surface — -// the client SDK builder's output and the `POST /data/:object/query` body -// — never stored in stack metadata, so there is no authored source or -// `sys_metadata` row for a D2 conversion to rewrite. The prescription -// reaches consumers as the D3 semantic entry -// `aggregation-node-distinct-retired` plus this tombstone, which is the -// disposition every other `data.query.*` retirement in this major already -// takes (`query-joins-retired` / `query-cursor-retired` / -// `query-distinct-retired` / `query-window-functions-retired`, #4286). -export const entry = 'data/AggregationNode:distinct'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__DataEngineUpdateOptions__upsert.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__DataEngineUpdateOptions__upsert.ts deleted file mode 100644 index 9ceab8374d4..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.data__DataEngineUpdateOptions__upsert.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8057 — the deprecated legacy update-options schema re-declared the -// never-implemented `upsert` flag, so it is tombstoned with the SAME -// prescription as `data/EngineUpdateOptions:upsert` (one string, both -// schemas plus the engine's unknown-option gate): a capability is never -// half-deleted. See that entry's comment for the full disposition; no D2 -// conversion, since an engine option bag is call-time only. -export const entry = 'data/DataEngineUpdateOptions:upsert'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__EngineUpdateOptions__upsert.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__EngineUpdateOptions__upsert.ts deleted file mode 100644 index 915d524e6b5..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.data__EngineUpdateOptions__upsert.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #8057 — the never-implemented upsert flag on `engine.update()`'s option -// surface, tombstoned on BOTH update-options schemas (this one and the -// deprecated `DataEngineUpdateOptions` sibling) with one prescription: -// `ENGINE_UPDATE_UPSERT_REMOVED` in `data/data-engine.zod.ts`, which the -// objectql engine's unknown-option gate quotes too. Declared-but-unenforced -// (ADR-0049): the key sat on the engine's update allowlist while no code path -// read it, so `{ upsert: true }` was accepted and silently dropped. -// -// Registered here but NOT in `src/conversions/registry.ts`, and that asymmetry -// is the point rather than an omission: a D2 conversion rewrites an authored -// source or a stored `sys_metadata` row, and an engine option bag is call-time -// only — nobody authors one and nothing persists one. The prescription reaches -// consumers as the D3 semantic entry `engine-update-upsert-retired` plus this -// tombstone (the `BatchOptions.validateOnly` / `ListNotificationsRequest:cursor` -// disposition). -export const entry = 'data/EngineUpdateOptions:upsert'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts deleted file mode 100644 index e6cf3b2e679..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7990 — sibling of `data/PostgresConfig:password`, same ruling, same -// disposition: tombstoned inline credential; the secret binder / -// `external.credentialsRef` is the mechanism. See that entry for the reasoning -// and the D3 semantic entry `datasource-config-inline-credential-refused` for -// the hand-migration prescription. -export const entry = 'data/MongoConfig:password'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts deleted file mode 100644 index bc872b8bf56..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7990 — sibling of `data/PostgresConfig:password`, same ruling, same -// disposition: tombstoned inline credential; the secret binder / -// `external.credentialsRef` is the mechanism. See that entry for the reasoning -// and the D3 semantic entry `datasource-config-inline-credential-refused` for -// the hand-migration prescription. -export const entry = 'data/MysqlConfig:password'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts deleted file mode 100644 index 1d6888a4f7c..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7990 (maintainer-ruled Option A, 2026-08-12) — inline datasource credentials -// refused at publish. The key is tombstoned (`z.never()` with the refusal -// prescription), not deleted: `sys_metadata` serves rows through the ordinary -// data API, so an inline `config.password` was cleartext at rest. The secret -// belongs to the datasource secret binder (`sys_secret` + -// `external.credentialsRef`), which already wins over an inline value at -// connect time. No D2 conversion: a stored cleartext credential cannot be -// mechanically rewritten into an encrypted `sys_secret` row at load — see the -// D3 semantic entry `datasource-config-inline-credential-refused`. -export const entry = 'data/PostgresConfig:password'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts deleted file mode 100644 index bde6b4d12d4..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7990 — the turso face of `data/PostgresConfig:password` (the credential key -// is `authToken`, a JWT, rather than a password — same inline-cleartext class, -// same ruling, same disposition). The standalone boot path is untouched: -// `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` are resolved by the host and -// handed to the driver factory directly, never through the authoring schema. -// See the D3 semantic entry `datasource-config-inline-credential-refused`. -export const entry = 'data/TursoConfig:authToken'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts b/packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts deleted file mode 100644 index 19556cac0b3..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'integration/ConnectorFieldMapping:transform'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts b/packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts deleted file mode 100644 index 193b153a54c..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #4914 — ADR-0049 enforce-or-remove on the plugin manifest's whole -// `loading` block (maintainer ruling 2026-08-04). ONE tombstoned key here, -// because `loading` was the single carrier: every schema underneath it -// (`PluginLoadingConfig` and the ten members it combined) leaves the -// published set as a whole-def removal and is registered in -// `RETIRED_DEFS_BY_MAJOR` below, not as ~27 individual key entries. -// -// Registered here but NOT in `src/conversions/registry.ts`, for the reason -// `automation/ActionDescriptor:isAsync` gives: the conversion chain -// walks a normalized STACK (`mapCollection(stack, 'objects' | 'views' | …)`) -// and `applyConversionsToStoredItem` maps a metadata type onto one of those -// collections. A package manifest is neither — there is no `packages` / -// `plugins` entry in `PLURAL_TO_SINGULAR`, so a manifest is not a stack -// collection member and a stored manifest row passes that seam through -// unchanged. A MetadataConversion here would be a transform with no seam -// that ever runs. The prescription reaches authors instead through the -// tombstone at the one place a manifest is parsed with an author present -// (`os plugin build` → `ManifestSchema.safeParse`, which exits non-zero), -// and through the D3 semantic entry `plugin-manifest-loading-retired`. -export const entry = 'kernel/Manifest:loading'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts b/packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts deleted file mode 100644 index 2163775b2bd..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'shared/FieldMapping:transform'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts deleted file mode 100644 index 4a1a70e60da..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #5775 — the SDUI component-props reconciliation. Three keys on the record -// picker (`displayField` was the REQUIRED one, and the synonym of the key -// the renderer actually reads) and the card's second spelling of the -// composition slot every other container calls `children`. -export const entry = 'ui/ElementRecordPickerProps:displayField'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts deleted file mode 100644 index bd1b691ba05..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/ElementRecordPickerProps:multiple'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts deleted file mode 100644 index 8a29d1eeff2..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/ElementRecordPickerProps:searchFields'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__bordered.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__bordered.ts deleted file mode 100644 index b9ed373fb19..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__bordered.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7176 — pass-through-only (ADR-0049 enforce-or-remove, maintainer ruling -// 2026-08-10): every measured reader copied the key forward and the chain ends -// at ObjectGrid, which never spells it; the grid frame is the renderer's own -// constant (`borderless`), never this key. Distinct from the LIVE -// `ui/PageCardProps:bordered`, a different surface on the page-card container. -// Conversion `view-list-passthrough-keys-removed` strips it from sources. -export const entry = 'ui/ListView:bordered'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts deleted file mode 100644 index e15969b6ac7..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7176 — pass-through-only (ADR-0049 enforce-or-remove, maintainer ruling -// 2026-08-10): every measured reader (react spec-bridge, plugin-list, -// plugin-view/app-shell) copied the key forward and the chain ends at -// ObjectGrid, which never spells it — copy-without-apply is dead in effect. -// Conversion `view-list-passthrough-keys-removed` strips it from sources. -export const entry = 'ui/ListView:striped'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__virtualScroll.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__virtualScroll.ts deleted file mode 100644 index 6a9d18d3da6..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__virtualScroll.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7176 — pass-through-only (ADR-0049 enforce-or-remove, maintainer ruling -// 2026-08-10): every measured reader copied the key forward and the chain ends -// at ObjectGrid, which never spells it. objectui's VirtualGrid genuinely -// virtualizes but is only exported, never instantiated by ObjectGrid, and its -// props carry no `virtualScroll` member — so no grid ever virtualized off this -// key. Conversion `view-list-passthrough-keys-removed` strips it from sources. -export const entry = 'ui/ListView:virtualScroll'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__bordered.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__bordered.ts deleted file mode 100644 index 9088e5a8156..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__bordered.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7176 — the `ObjectListViewSchema` copy of `ui/ListView:bordered`: the def is -// `ListViewSchema.omit({userFilters}).extend(…)`, so the tombstone lands in -// this walked shape too and `authorable-surface/` marks it `[RETIRED]` -// separately. Registered per key, as gate (b) reads them — nothing radiates -// from the base (the `shared/FieldMapping:transform` precedent). -export const entry = 'ui/ObjectListView:bordered'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__striped.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__striped.ts deleted file mode 100644 index 17180cbc8aa..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__striped.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7176 — the `ObjectListViewSchema` copy of `ui/ListView:striped`: the def is -// `ListViewSchema.omit({userFilters}).extend(…)`, so the tombstone lands in -// this walked shape too and `authorable-surface/` marks it `[RETIRED]` -// separately. Registered per key, as gate (b) reads them — nothing radiates -// from the base (the `shared/FieldMapping:transform` precedent). -export const entry = 'ui/ObjectListView:striped'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__virtualScroll.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__virtualScroll.ts deleted file mode 100644 index d6d046294ef..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__virtualScroll.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #7176 — the `ObjectListViewSchema` copy of `ui/ListView:virtualScroll`: the -// def is `ListViewSchema.omit({userFilters}).extend(…)`, so the tombstone lands -// in this walked shape too and `authorable-surface/` marks it `[RETIRED]` -// separately. Registered per key, as gate (b) reads them — nothing radiates -// from the base (the `shared/FieldMapping:transform` precedent). -export const entry = 'ui/ObjectListView:virtualScroll'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts deleted file mode 100644 index 7f2e4136b87..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #6946 — three SDUI page-component props, retired by maintainer ruling -// 2026-08-09 (decision-inbox round, 「全部接受」): objectui#3829 route (c) -// for the first two, objectui#3818 for the third. Registered per key, as -// gate (b) reads them — nothing radiates from a neighbouring key, and this -// family needs that literally: `ui/PageHeaderProps:actions` and -// `ui/RecordHighlightsProps:layout` are LIVE keys sharing these leaf names. -// -// The first two are the B class — declared here, read NOWHERE in objectui -// (the header resolves icons per action; the card renders -// title/bordered/children/footer and has no actions area), and carried in -// that repo's own `UNPUBLISHED_EXEMPTIONS` map as exactly that. -export const entry = 'ui/PageCardProps:actions'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts deleted file mode 100644 index 05f0bceeaad..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/PageCardProps:body'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts deleted file mode 100644 index d89b8880116..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -export const entry = 'ui/PageHeaderProps:icon'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts deleted file mode 100644 index 0f68b5c6afc..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// #6776 — #5775's count was incomplete. The tab strip's visual style is the -// one prop whose declared spelling collides with the page component's own -// dispatch key, so `type` could never be authored in a flat or JSX carrier -// and was skipped unvalidated by `sdui-parser`'s `BASE_PROPS`. Renamed to -// the `tabStyle` every carrier can express and the renderer already reads. -export const entry = 'ui/PageTabsProps:type'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts deleted file mode 100644 index aff9ae5215f..00000000000 --- a/packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -// The third is a sharper shape: `layout` IS read, but only against -// `inline`/`compact` — values its `auto | custom` enum never permitted — so -// both legal values took the same branch. Declared on BOTH sides with the -// same enum, which is why the declaration-parity ratchet (two declarations, -// never a declaration vs an implementation) reported agreement over it. -export const entry = 'ui/RecordDetailsProps:layout'; diff --git a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts deleted file mode 100644 index ef68073a0d8..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'action-descriptor-is-async-retired', - surface: 'ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)', - replacement: - 'nothing to re-declare — delete the key. Suspension is `execute()` RETURNING ' - + '`suspend: true`, and permission to suspend is `supportsPause: true` on the same ' - + 'descriptor (with the `resumeAuthority` its pauses need)', - reason: - 'ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow ' - + 'awaiting an external reply" and NOTHING read it: a fresh three-repo measurement ' - + '(taken when the key was filed for retirement, and re-run at pickup) found zero ' - + 'property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of ' - + 'five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. ' - + 'So declaring it never made a node suspend and omitting it never stopped one, ' - + 'which is the silently-inert declaration ADR-0049 exists to end. It was always a ' - + 'second, weaker spelling of the capability `supportsPause` states, and the two ' - + 'diverged in exactly the way a duplicated declaration does: `screen` declared ' - + 'both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing ' - + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — ' - + '`AutomationEngine` now refuses a suspension whose type does not declare ' - + '`supportsPause: true` — so the capability this key gestured at is now a real, ' - + 'enforced fact under one name. This one had no consumer to grow into and takes ' - + 'the remove leg. ' - + 'Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from ' - + "an executor's TypeScript, never stored in stack metadata — no stack, example or " - + 'template carries the key — so there is no source for the chain to rewrite and ' - + '`os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` ' - + 'and descriptor authors delete the key themselves; that rejection (a `tsc` error ' - + 'at the authoring site, and a parse error inside `defineActionDescriptor`) is the ' - + 'channel a third-party plugin author actually meets. The ' - + '`EnhancedApiError.fieldErrors` disposition, one layer down.', - acceptanceCriteria: - 'No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, ' - + '`wait`, `approval`, `approval_revise`), not a plugin\'s. Every node type that ' - + 'returns `suspend: true` from `execute()` declares `supportsPause: true` on its ' - + 'descriptor together with a `resumeAuthority`, and its runs still pause and resume ' - + 'as before: the behaviour never depended on `isAsync`, so deleting the key changes ' - + 'no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails ' - + '`defineActionDescriptor()` at runtime with the prescription, instead of parsing ' - + 'clean and being stripped.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts deleted file mode 100644 index 221a3159fdf..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'action-descriptor-resume-authority-default-flip', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). - surface: - 'automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing ' - + 'node descriptor (supportsPause: true, or any executor whose execute() returns ' - + 'suspend: true)', - replacement: - "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose " - + 'pauses really are meant to be continued through the generic resume route ' - + '(POST /automation/:name/runs/:runId/resume) — a screen-style collected-input ' - + "pause, or a signal wait an external producer resumes. Declare 'service' instead " - + 'if continuing is the tail of a decision your own service must authorize and ' - + 'record first. Either value is a one-line addition; only the silence changed ' - + 'meaning', - reason: - 'A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as ' - + "protocol 12's `rest-requireauth-default-flip`, and it is registered here for the " - + 'same reason: whether a given pause is genuinely open to the generic route is a ' - + 'trust judgment no transform can make. The generic resume route\'s authorization gate ' - + 'keys on the SUSPENDED ' - + "NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a " - + 'pausing node type shipped raw-resumable unless its author remembered the field. ' - + "It now resolves to `'service'` when absent: an unclaimed pause is refused on the " - + 'generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may ' - + 'continue it. The revise-window incident decided the direction — ADR-0044 pointed ' - + "an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and " - + 'the pause standing in a service-owned position inherited a fail-open value nobody ' - + 'chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote ' - + 'run. The two possible mistakes are asymmetric, which is the whole argument: ' - + "guessing `'any'` walks past a decision nothing recorded and is silent, while " - + "guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface " - + 'is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no ' - + 'source for a D2 conversion to rewrite and deliberately no schema tombstone — the ' - + 'disposition `data-driver-find-stream-retired`, `storage-service-list-retired` ' - + 'and `actor-user-roles-to-positions` already carry. It differs from ' - + 'those in one way a reader should not have to infer: nothing is REMOVED, so tsc ' - + 'reports nothing at all — the field was already optional after step one and an ' - + 'omission still compiles. The enforced channels are all run-time: a registration ' - + 'warning naming the node type (once per type per engine), the refusal message on ' - + 'the resume itself, and `check:resume-authority-declared` for executors living in ' - + 'this repo. For a third-party plugin the generated upgrade guide is the only ' - + 'channel that arrives BEFORE a user hits a run that will not continue. In-tree the ' - + 'flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, ' - + 'approval, approval_revise) declare their authority explicitly. ADR-0044 amendment ' - + '(2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam ' - + 'addendum to ADR-0019 (approval as a flow node).', - acceptanceCriteria: - 'Every action descriptor your plugin registers for a node type that can suspend ' - + 'declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but ' - + 'never declares resumeAuthority` warning naming one of your types, and a run parked ' - + 'on each of your pausing nodes can still be continued the way you intend: a resume ' - + "through the generic route succeeds for the ones you declared `'any'`, and answers " - + "403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue " - + 'through your own service API instead. ⚠️ `supportsPause` is no longer the ' - + 'declaration nothing enforced: an executor whose ' - + '`execute()` returns `suspend: true` while leaving `supportsPause` false is still ' - + 'warned about by neither warning channel, but ' - + '`AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the ' - + 'one seam every suspension passes through — a guard-class failure no `fault` edge ' - + 'routes — so it needs no hand-check. The residue that does: an executor registering ' - + 'NO descriptor declares nothing for either warning or the refusal to read, so its ' - + 'pauses are still created and refused only later, on the resume route.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts deleted file mode 100644 index 27b963603fc..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'action-session-roles-to-positions', - surface: 'ui.actionSession.roles', - replacement: 'ui.actionSession.positions (an action body reads `ctx.session.positions`)', - reason: - 'The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this ' - + 'step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed ' - + 'outright), while the ACTION body\'s `ctx.session` carries it ' - + 'produced-and-really-populated. `buildActionSession()` ' - + '(`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` ' - + 'into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under ' - + 'the one spelling that ADR bans — so a body author met two different answers to one ' - + 'key name on one platform: rejected in a hook, live and full of values in an action. ' - + 'The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare ' - + 'the shape as it stands first, then rename on the typed face): phase 1 declared the ' - + 'previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. ' - + '`positions` is now the canonical key on that schema and `roles` a deprecated alias of ' - + 'it; the producer emits both for one deprecation window (the runtime half of the same ' - + 'ruling), after which `roles` is removed on the path the v16 session-alias removal ' - + 'already walked (the hook session\'s `tenantId` alias: deprecated first, removed in the ' - + 'next major). ' - + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: ' - + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per ' - + 'dispatch and never persisted, so no `sys_metadata` row, example or template can ' - + 'carry the key — the `openApi31` / `activationEvents` / ' - + '`hook-context-session-roles-retired` shape. SECOND, the only place the key ' - + 'is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed ' - + 'script whose `ScriptContext.session` is still `unknown`. A declarative transform ' - + 'cannot safely rewrite an identifier inside free-form code — exactly the reason the ' - + 'ADR-0090 wave delegated `current_user.roles` to the author at step 13 ' - + '(`cel-current-user-roles-to-positions`) instead of substituting text. ' - + 'Note what is deliberately NOT done here: the alias is not tombstoned. A ' - + '`retiredKey()` REJECTS the key, and a deprecation window exists precisely so the ' - + 'old spelling keeps working while its readers move — tombstoning during the window ' - + 'would be the removal it is meant to defer. The tombstone (or the plain deletion the ' - + 'authorable-surface ratchet adjudicates) belongs to the release that closes the ' - + 'window. Until then this entry IS the channel: `spec-changes.json` and the generated ' - + 'upgrade guide are how a reader learns the rename before the removal reaches them. ' - + 'ADR-0090 D3, ADR-0087.', - acceptanceCriteria: - 'No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` ' - + 'and observes the same array (the rename is a rename — the VALUE is ' - + '`ExecutionContext.positions` on both sides, which the runtime pin ' - + '`action-session-shape-contract.test.ts` asserts independently of the key name). ' - + 'Privilege is NOT re-derived from either spelling: a read that was ' - + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security ' - + 'service (capability grants / placements / derived posture, ADR-0095), never ' - + 'renamed to `positions.includes(\'admin\')` — renaming that read migrates the defect ' - + 'rather than the code. Verify against a real dispatch, not a fixture: invoke an ' - + 'action as a caller holding positions and assert the body observed them under the ' - + 'canonical key. During the window both keys are present and equal, so a reader can ' - + 'be migrated and verified before the alias is removed; after it, `roles` is absent ' - + 'and a body still reading it sees `undefined` — which is why the read must be moved ' - + 'inside the window rather than at its close.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts deleted file mode 100644 index 9695095460b..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'actor-user-roles-to-positions', - surface: 'action body / AI route: ctx.user.roles (req.user.roles)', - replacement: - 'ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, ' - + 'under the one spelling ADR-0090 D3 sanctions', - reason: - 'The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose ' - + 'surface the spec never declared. `ActorUser` ' - + '(`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` ' - + 'envelope handed to an action body as `ctx.user` and to an AI route handler as ' - + '`req.user`; it declared `positions` and `roles` side by side and filled them from a ' - + 'SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical ' - + 'on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, ' - + 'published straight into author-written code. The maintainer ruled it closed IMMEDIATELY ' - + '(2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation ' - + 'window, no dual-emit, the alias simply gone in 17. ' - + '⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: ' - + '`action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached ' - + 'through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, ' - + 'same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while ' - + '`ctx.session.roles` still answers for the length of its window. ' - + 'What makes this entry different in KIND from both session-side siblings: `ctx.user` has ' - + 'no spec schema and never had one. It is a runtime TS interface, so unlike ' - + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` ' - + 'once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared ' - + 'contract-first, as it stood, as the first stage of the session rename, precisely so its ' - + 'key could be renamed), there is no schema key here to tombstone and no ' - + '`retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` ' - + 'through a `.parse()`, so a prescription there would have no one to reach. The enforced ' - + 'channel is tsc, and it reports at the READ site inside the author\'s own body; for an ' - + 'untyped or sandboxed body there is no enforced channel at all, which is exactly why ' - + 'this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide ' - + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / ' - + '`IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no ' - + 'tombstone, tsc at the call site — applied to a surface that lives one layer further ' - + 'out than either: those two are at least DECLARED in `packages/spec/src/contracts`, ' - + 'this one only in `packages/runtime`. ' - + 'Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent ' - + 'grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` ' - + 'is constructed per dispatch and never persisted, so no `sys_metadata` row, example or ' - + 'template can carry the key (the `openApi31` / `activationEvents` / ' - + '`hook-context-session-roles-retired` shape). SECOND, the only place the key is ' - + 'ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or ' - + 'a sandboxed script. A declarative transform cannot safely rewrite an identifier inside ' - + 'free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to ' - + 'the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting ' - + 'text. ' - + 'The removal\'s hard precondition was met before it landed, and the result is recorded ' - + 'here because the ledger is where an upgrading consumer meets it: the declaration\'s own ' - + 'comment claimed the alias was "kept for the REST/AI shapes", and that claim was ' - + 'DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all ' - + 'of them in the pins the removal flipped; the four `ActorUser` construction sites build ' - + 'server-side envelopes that never enter a response body; objectui\'s `.roles` reads ' - + 'belong to two unrelated producers (the better-auth session, and the ' - + '`/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and ' - + 'is the one consumer face left unverified — this entry, and the changeset\'s FROM/TO ' - + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; ' - + 'every such read is `.positions` and observes the SAME array — the value was ' - + '`ExecutionContext.positions` on both sides, so this is a pure key rename and no value ' - + 'has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was ' - + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security service ' - + '(capability grants / placements / derived posture, ADR-0095), never renamed to ' - + '`positions.includes(\'admin\')` — renaming that read migrates the defect rather than the ' - + 'code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is ' - + 'already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed ' - + 'one silently sees `undefined` — move the read AS you upgrade, not after it. Verify ' - + 'against a real dispatch rather than a fixture: invoke an action (and an AI route) as a ' - + 'caller holding positions, assert the body observed them under the canonical key, and ' - + 'assert the old key is ABSENT by key existence (`\'roles\' in ctx.user === false`) rather ' - + 'than by `undefined`, which cannot tell a removed key from one left behind holding ' - + 'nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts b/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts deleted file mode 100644 index 9588f665237..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'aggregation-node-distinct-retired', - surface: 'data.query.aggregations[].distinct', - replacement: - 'the `count_distinct` aggregation FUNCTION for a deduplicated count — the one ' - + 'deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on ' - + 'both SQL faces since it took the enforce leg of the 2026-08-07 ruling that kept it ' - + 'declared and retired `array_agg` / `string_agg`. ' - + '`SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no ' - + 'replacement: no backend ever computed them here, and a per-row measure that needs ' - + 'deduplicating before summing is a modelling problem to fix in the data', - reason: - 'A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the ' - + '`QueryAST` members no executor runs, the one that dispositioned every other ' - + '`data.query.*` member. That sweep asked which keys ' - + 'no executor reads; this one HAD an executor, exactly one out of six. The engine\'s ' - + 'in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the ' - + 'values before applying the function, while `SqlDriver.aggregate`, the Turso ' - + '`RemoteTransport.aggregate`, `driver-mongodb`\'s `buildAggregationStage`, ' - + '`driver-memory`\'s `computeAggregate` and service-analytics\' `AGGREGATE_SQL` all ' - + 'ignored the key. So `{ function: \'sum\', field: \'amount\', distinct: true }` ' - + 'answered a deduplicated sum when the engine fell back in memory and an ordinary sum ' - + 'on every SQL datasource: one query, two numbers, chosen by which backend happened ' - + 'to serve it — and unlike the divergences closed earlier on the same axis (an ' - + 'aggregate function name the remote Turso face compiled and the local face refused, and ' - + 'an unsupported function thrown as a bare error with no code on both SQL faces), the ' - + 'wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced ' - + 'it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — ' - + '`count` returned from its own branch before reaching the dedupe, `count_distinct` ' - + 'fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move ' - + '`min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): ' - + '`count_distinct` already covers the only spelling anyone has measured demand for, ' - + 'and lowering `SUM(DISTINCT …)` across five faces — two of them, driver-memory and ' - + 'driver-mongodb, then under the maintainer\'s 2026-08-05 investment freeze, a freeze ' - + 'lifted 2026-08-11, after this ruling — ' - + 'buys a shape that is near-universally a modelling mistake. A REQUEST surface — ' - + '`QueryAST` is the client SDK builder\'s output and the `POST /data/:object/query` ' - + 'body, never stored in stack metadata — so there is no source for the chain to ' - + 'rewrite and callers move their own queries: the disposition that sweep gave ' - + '`joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ' - + 'ADR-0049.', - acceptanceCriteria: - 'No caller sends `distinct` inside an `aggregations[]` entry, on the wire or through ' - + 'the SDK; a deduplicated count is written as `{ function: \'count_distinct\', field }` ' - + 'and reads the same number on every backend. A query still carrying the key fails to ' - + 'parse with the removal prescription — including through ' - + '`EngineAggregateOptionsSchema`, which reuses `AggregationNodeSchema` by reference — ' - + 'and `POST /api/v1/data/:object/query` answers `400 VALIDATION_FAILED` with a ' - + '`fields[]` entry at `aggregations..distinct` instead of serving a number. ' - + 'Authoring it is a `tsc` error at the call site. ⚠️ The observable NUMBERS change on ' - + 'exactly one path and that is the point of the change: a `sum`/`avg` that used to be ' - + 'deduplicated by the in-memory fallback now answers what every SQL face has always ' - + 'answered for the same query. Verify against the SQL answer, not against the ' - + 'pre-upgrade fallback answer — the two disagreed, which is why the key is gone.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts deleted file mode 100644 index 2b7cd6a578a..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'analytics-query-request-envelope-retired', - surface: 'api.analyticsQueryRequest.query', - replacement: 'bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)', - reason: - 'The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded ' + - 'analytics shim (the fallback that answered /analytics/query when no analytics service ' + - 'was installed, and dropped the caller\'s identity and its `where` filter at the door), ' + - 'never stored in stack metadata — there is no source for the ' + - 'chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the ' + - 'query.* fields to the body top level themselves.', - acceptanceCriteria: - 'Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and ' + - 'succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts deleted file mode 100644 index 50ac24ea568..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'analytics-query-request-format-retired', - surface: 'api.analyticsQueryRequest.format', - replacement: '(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)', - reason: - 'The `format` key was declared but never implemented (declared ≠ enforced): every ' + - 'response is the JSON envelope regardless of the requested value, so there is no ' + - 'behaviour to preserve and nothing stored to rewrite.', - acceptanceCriteria: - 'No /analytics/query or /analytics/sql call sends `format`; exports go through the ' + - 'export surface.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts b/packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts deleted file mode 100644 index a5e0b0c613c..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'api-runtime-create-withdrawn', - surface: 'PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)', - replacement: - 'Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) ' - + 'and ship it through `publishPackage`', - reason: - 'The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never ' - + 'honoured it. Measured on a real showcase boot: `PUT /api/v1/meta/api/' - + 'e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the ' - + 'declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` ' - + 'line, because the endpoint was never in the index to be excluded from. The serving ' - + 'criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> ' - + "`MetadataManager.listForIndex('api')`, which reads the manager's registry plus its " - + 'registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands ' - + 'in `sys_metadata`, which is in neither. A declared capability the runtime does not ' - + 'honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s ' - + 'forever is its most dangerous shape for the AI authors ADR-0033 targets. The ' - + 'maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because ' - + 'making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and ' - + "the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no " - + 'business pull for Studio-authored endpoints today (zero `.api.*` artifacts author ' - + 'them at runtime; showcase uses the artifact route, and its declared endpoints serve ' - + 'live). ' - + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored ' - + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an ' - + 'authorable one, and the artifact route it points authors toward is untouched — a ' - + '`**/*.api.ts` file valid before this change is valid after it, byte for byte. What ' - + 'changed is a runtime HTTP verdict, so it is one semantic TODO for operators and ' - + 'Studio callers rather than a stack conversion — the same disposition ' - + '`BatchOptions.validateOnly` takes. Consequently `gateApiDraftsForPublish` ' - + 'is retired with it: it gated a promotion into a state the matcher can ' - + 'never read, and with the inlet closed no `api` draft can exist for it to judge. ' - + 'Re-entry is recorded in the ruling: if the Studio metadata-coverage work promotes `apis` ' - + 'to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — ' - + 'implementation first, ' - + 'declaration second. The same refusal closes the direct-active write too, which had been a ' - + 'third path past the endpoint namespace and duplicate-path gates. ADR-0049 / ADR-0121.', - acceptanceCriteria: - 'No caller creates or updates an `api` item through the runtime metadata API. ' - + '`PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body ' - + 'naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the ' - + 'prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` ' - + 'as well as direct-active, because the gate runs before the draft/publish branch and ' - + 'does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole ' - + 'point of the change: a stack declaring `apis:` still compiles, still passes ' - + '`validateApiEndpointDeclarations` at publish (`publishPackage`) and at load ' - + '(`buildEndpointIndex`), and its endpoints still SERVE — that route was ' - + 'always the only one that served. An operator who genuinely needs the runtime door ' - + 'back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape ' - + 'hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and ' - + 'the endpoint still will not be served, which is why it is a diagnostic and not a ' - + 'workaround. Any `api` rows already sitting in `sys_metadata` from before this change ' - + 'were never served either; they can be deleted (`deleteMetaItem` is deliberately not ' - + 'gated by this refusal, so repair stays possible).', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts b/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts deleted file mode 100644 index 4d0b9f60433..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'apimethod-enum-shrink', - surface: 'data.object.enable.apiMethods (the eight legacy non-primitive values)', - replacement: - 'the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: ' - + 'replace each legacy value with the primitives it derives from, de-duplicate, and ' - + 'delete the key entirely if the result names all six', - reason: - 'The authored `enable.apiMethods` enum is now exactly the six primitives. The eight ' - + 'legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, ' - + '`import`, `export` — are no longer authorable, because they are DERIVED effective ' - + 'operations resolved by the server\'s single derivation table, and an enum that lets an ' - + 'author name both a primitive and something derived from it has two spellings for one ' - + 'fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; ' - + '`import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; ' - + '`history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, ' - + 'because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ' - + 'ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That ' - + 'last row is why this is a semantic entry and not a mechanical conversion, and the ' - + 'reason is a security one: the mapping WIDENS. An allowlist naming `history` was ' - + 'granting read of one record\'s audit trail; rewritten to `get` it grants ordinary ' - + 'record reads, and an allowlist naming `search` becomes a grant of full `list`. A ' - + 'transform that applied the table silently would broaden real API permissions without ' - + 'anyone reading the diff, so the rewrite is delegated to the author with the widening ' - + 'flagged. The reporter codemod exists for exactly that shape: `node ' - + 'scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact ' - + 'replacement per site, and FLAGS the allowlists the mapping would widen so the edit ' - + 'stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing ' - + '(permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what ' - + 'an author may newly write. Registered late, by the stock reconciliation that compared ' - + 'the breaking changesets already on the v17 release train against this ledger: the enum ' - + 'shrink (phase 2 of the programme that made UI action buttons agree with the ' - + '`apiMethods` allowlist) predates the gate that makes a breaking changeset state its ' - + 'ledger disposition. ADR-0087.', - acceptanceCriteria: - 'No authored `enable.apiMethods` array names a legacy value; `objectstack validate` ' - + 'passes. Run the reporter codemod first and read its widening flags before applying ' - + 'anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For ' - + 'every object where `history` became `get` or `search` became `list`, confirm the ' - + 'broader operation is one the API should genuinely expose; where it is not, the answer ' - + 'is not a different value in this enum but a permission set that withholds the ' - + 'operation. Where the six primitives are all present, prefer deleting the key: that is ' - + 'equivalent to default-open and it tracks future primitives, whereas a hand-listed six ' - + 'silently stops granting anything added later. `restore` / `purge` are deleted with no ' - + 'replacement — if trash-like behaviour was being relied on, that capability left in the ' - + '11.0 dead-property removal and this entry is not where it returns.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts b/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts deleted file mode 100644 index 80d278366a7..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'approval-escalation-enabled-default-flip', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code - // span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). - surface: - 'automation.ApprovalEscalation.enabled — an OMITTED value inside an approval ' - + "node's escalation block", - replacement: - 'nothing, for the common intent (escalate on timeout): an escalation block ' - + 'carrying timeoutHours is live by default. To declare an SLA OFF while keeping ' - + 'its configuration, write enabled: false explicitly — which is now the spelling ' - + 'the escalation sweep actually reads', - reason: - 'A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real ' - + "(maintainer ruling 2026-08-27, which moved the declared default to what the sweep had " - + "always done) — the same category as protocol 17's " - + '`import-run-automations-declared-default-corrected`: the schema promised ' - + '`enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never ' - + 'read the key at all — any escalation block with a positive `timeoutHours` ' - + 'escalated, and with `action: \'auto_approve\'` that silently approved requests ' - + 'their author had declared off the clock. The flip moves the default to `true` ' - + 'and, in the same change, the sweep starts honouring an explicit ' - + '`enabled: false`. The feature-level switch is whether an `escalation` block ' - + 'exists at all; within a block carrying `timeoutHours`, escalation is on unless ' - + 'explicitly turned off. Deployed metadata that OMITS `enabled` does not change ' - + 'behaviour: it escalated before (the sweep ignored the key) and escalates after ' - + '(the parse materializes `true`). Stored request snapshots written before the ' - + 'flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses ' - + 'config through the old schema before snapshotting), so the sweep keeps a ' - + 'read-side legacy window keyed on the snapshot\'s `created_at`: pre-flip ' - + 'snapshots keep escalating exactly as they do today, and the window retires ' - + 'itself as those pending requests drain. What DOES change is that an explicit ' - + '`enabled: false` finally binds — a flow that authored it (e.g. the console ' - + 'toggle switched off after a timeout was set) stops escalating on requests ' - + 'opened after the upgrade, which is the declared intent being honoured.', - acceptanceCriteria: - 'A flow whose approval node omits `enabled` inside `escalation` still escalates ' - + 'on timeout (no metadata edit needed). A flow that writes `enabled: false` ' - + 'stops escalating for newly opened requests — verify one such request stays ' - + 'pending past its `timeoutHours` with no `escalate` audit row and no ' - + 'auto-decision. Requests opened BEFORE the upgrade keep their pre-upgrade ' - + 'behaviour (they escalate) regardless of the stored `enabled` bit. Clients ' - + 'that parse metadata through the published JSON Schema now materialize ' - + '`enabled: true` where they materialized `false`; a client that needs the SLA ' - + 'off must write it explicitly.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts deleted file mode 100644 index 2444f9fe212..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'audit-log-action-enum-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - "sys_audit_log.action — the values 'export' and 'permission_change' left the select " - + 'enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/' - + 'sys-audit-log.object.ts). The same two values also left the shipped list-view ' - + "filters on that object: 'permission_change' from the auth_events view and 'export' " - + 'from the config_changes view', - replacement: - 'nothing, for either value — both are removed rather than renamed, because neither ' - + 'named an event this platform records. For permission changes, read the ordinary ' - + '`create` / `update` rows on the permission objects themselves: a grant or binding ' - + 'write is an ordinary record write and the generic audit writer already ledgers it, ' - + 'so a second semantically-duplicate row was never minted. For `export` there is no ' - + 'replacement and nothing is lost: no export feature ever wrote an audit row. A ' - + 'consumer filtering `sys_audit_log` on either value was reading an empty result set ' - + 'on every deployment, and still is — what changed is that the contract no longer ' - + 'promises otherwise', - reason: - 'Maintainer ruling 2026-08-12 on the audit log\'s writerless actions, the retirement half ' - + 'of a two-half verdict: the cheap writers get built (`login` / `logout` on the auth ' - + 'session hooks, `config_change` from the settings service) and the enum ' - + 'values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的' - + '过滤器是可见产品缺陷;审计面宁窄勿谎. ' - + 'The defect was false compliance on a COMPLIANCE surface, which is the sharpest form ' - + 'of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the ' - + 'platform captured permission changes and data exports, and the shipped list views ' - + 'and dashboard widgets showed them a filter and a tile for exactly those events. ' - + 'Both were permanently empty. Measured by enumerating every `sys_audit_log` writer ' - + 'in the repo — there are exactly two: plugin-audit`s generic hook writer, whose ' - + '`actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing ' - + 'else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or ' - + '`permission_change`. ' - + 'This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement ' - + 'in the two ways `hook-body-crypto-hash-removed`, ' - + '`dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` ' - + 'already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) ' - + 'and the four surface ratchets are expected to be byte-identical (no def changed). ' - + 'It differs from all three in being a SEMANTIC entry rather than a D2 conversion, ' - + 'and the reason is that there is no source to rewrite: `sys_audit_log` is a ' - + 'platform-owned, append-only object whose every field is `readonly: true`. Nobody ' - + 'authors an audit row and nobody authors this enum — the values appear only in rows ' - + 'the runtime writes and in queries consumers send. A conversion rewrites authored ' - + 'metadata or a stored `sys_metadata` row; this surface is neither, so the ' - + 'disposition is the one `BatchOptions.validateOnly` and the notification cursor ' - + 'already take in this major. ' - + '⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a ' - + 'row with either value keeps it, and keeps reading it back: the enum is not enforced ' - + 'on this object at all (`validateRecord` skips `readonly` fields, and every field ' - + 'here is readonly), so nothing rejects stored history and no backfill is required or ' - + 'wanted. Deleting audit history to satisfy a schema narrowing would be the one ' - + 'genuinely destructive reading of this change. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No consumer filters `sys_audit_log` on `action = "export"` or ' - + '`action = "permission_change"` expecting rows: both were empty everywhere before ' - + 'this change, so a query that returned data has not been identified and a query that ' - + 'returned nothing behaves identically. Concretely, check three places. (1) Saved ' - + 'queries, dashboards and reports over `sys_audit_log`: a filter naming either value ' - + 'should be deleted, not re-pointed — for permission auditing, filter the permission ' - + 'objects` own `create`/`update` rows by `object_name` instead. (2) Any code branching ' - + 'on the action string (a badge map, a label switch, an `if (row.action === ...)`): ' - + 'the arms for these two values are now unreachable and should go, and a `switch` with ' - + 'an exhaustiveness check over the enum type will now fail to compile if they stay — ' - + 'that compile error is the enforced channel for TypeScript consumers. (3) Custom ' - + 'objects or plugins inserting `sys_audit_log` rows with either value: this is the ' - + 'only case that needs a real decision, because the write will NOT be refused ' - + '(readonly fields are not validated) — it will simply be a row whose action the ' - + 'object no longer declares. Pick a declared value or open an issue for the action ' - + 'you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is ' - + 'append-only and stays exactly as written.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts deleted file mode 100644 index b4e5902acee..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -// The third and last value retired from `sys_audit_log.action` in this major, -// and a separate entry from `audit-log-action-enum-retired` on purpose: two -// cards registering DIFFERENT entries merge clean, two cards editing the SAME -// entry collide in git — which is the layout `entries/README.md` chose on a -// registry where a dropped entry produces no error anywhere. -export const entry: SemanticMigration = { - id: 'audit-log-action-restore-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - "sys_audit_log.action — the value 'restore' left the select enum declared by " - + 'plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). ' - + 'It also left the shipped writes_only list-view filter on that object, and the ' - + 'generated option label in all four plugin-audit translation bundles', - replacement: - 'nothing — the value is removed rather than renamed, because it never named an event ' - + 'this platform records. There is no undelete or restore capability to point at: ' - + 'deletes are hard deletes, and the record-level audit writer maps the ObjectQL ' - + 'lifecycle to `create` / `update` / `delete` only. A consumer filtering ' - + '`sys_audit_log` on this value was reading an empty result set on every deployment, ' - + 'and still is — what changed is that the contract no longer promises otherwise. If ' - + 'you were counting on a restore trail, the capability itself is the missing piece ' - + '(an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built ' - + 'yet), not this enum row', - reason: - 'The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one ' - + "value that ruling's own survey did not name (triage 2026-08-13). 原则记录:空 " - + 'widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. ' - + '`restore` is the least ambiguous member of the family: the record-level writer ' - + "could not have produced it even by accident, because `actionFor()` in " - + "audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller " - + 'early-returns on null. A tree-wide search finds no other producer. ' - + 'What made it a card rather than a tidy-up is that TWO shipped declarations ' - + 'asserted the opposite, so a declaration-reading audit scored the action as ' - + 'covered: the `writes_only` list view offered it as a filter value, and the module ' - + 'docblock of auth-event-audit.ts named it among the actions the writer emits. The ' - + 'comment is the ADR-0049 declared-≠-enforced shape in its purest form (the shape a ' - + 'credential-storage audit had to settle by re-measuring two "hashed at rest" comments) — a ' - + 'sentence next to a mechanism, contradicted by the type signature of that very ' - + 'mechanism, with nothing in CI able to tell. Both declarations are corrected in one ' - + 'change, and the invariant behind the comment (every declared action has a writer) ' - + 'now has a pin test under it rather than prose. ' - + 'Bookkeeping is identical to the sibling entry, for the same reasons: an ' - + 'enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY ' - + 'changed) and leaves the four surface ratchets byte-identical (no def changed), and ' - + 'it is a SEMANTIC entry rather than a D2 conversion because there is no source to ' - + 'rewrite — `sys_audit_log` is a platform-owned, append-only object whose every ' - + 'field is `readonly: true`, so nobody authors an audit row and nobody authors this ' - + 'enum. ' - + '⚠️ This is a statement about the WRITER, not a product stance against undelete. ' - + 'Soft delete/restore is parked, not rejected: the undelete / purge lifecycle and the ' - + 'recycle bin are both held open, not declined. If that capability lands, this value ' - + 'returns WITH its writer — the ' - + 'emission point, its tests, and the view that surfaces it — never as a bare enum ' - + 'row again. ' - + '⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: ' - + 'the enum is not enforced on this object at all (`validateRecord` skips `readonly` ' - + 'fields), so any stored row keeps parsing and reading back, and no backfill is ' - + 'required or wanted. Deleting audit history to satisfy a schema narrowing would be ' - + 'the one genuinely destructive reading of this change. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No consumer filters `sys_audit_log` on `action = "restore"` expecting rows: it was ' - + 'empty on every deployment before this change and behaves identically after it. ' - + 'Concretely, check three places. (1) Saved queries, dashboards and reports over ' - + '`sys_audit_log`: a filter naming `restore` should be deleted, not re-pointed — ' - + 'there is no action that carries the meaning, because the platform records no ' - + 'restore event. (2) Any code branching on the action string (a badge map, a label ' - + 'switch, an option list in an audit-log filter UI): the `restore` arm is ' - + 'unreachable and should go, and a `switch` with an exhaustiveness check over the ' - + 'enum type will now fail to compile if it stays — that compile error is the ' - + 'enforced channel for TypeScript consumers. An option in a FILTER dropdown is the ' - + 'user-visible half and matters most: it offers an operator a choice that returns ' - + 'nothing. (3) Custom objects or plugins inserting `sys_audit_log` rows with this ' - + 'value: the write will NOT be refused (readonly fields are not validated), so it ' - + 'silently becomes a row whose action the object no longer declares. Pick a declared ' - + 'value, or open an issue for the action you actually need. ⚠️ Do NOT migrate or ' - + 'delete existing rows: audit history is append-only and stays exactly as written.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts b/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts deleted file mode 100644 index 4f22291b7ae..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'auth-config-unadvertised-reserved-features', - surface: 'api.authConfig.features.passkeys / api.authConfig.features.magicLink', - replacement: '(removed — no replacement flag; the capabilities are not advertised)', - reason: - 'Both flags were served by `GET /api/v1/auth/config` from introduction and read by no ' - + 'client: no login UI anywhere renders a passkey or magic-link affordance off them, so ' - + 'the payload advertised two sign-in methods a user could never reach, and a deployer ' - + 'setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable ' - + 'effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove ' - + 'over keep-as-reserved, so that a deployer cannot flip a flag that does nothing ' - + 'anywhere). The two are not equally empty: nothing at all is wired behind ' - + '`passkeys`, whereas `magicLink`\'s better-auth endpoints are live and only their ' - + 'advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists ' - + 'an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema ' - + 'tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is ' - + 'conditional: both return to the payload in the change that ships the login UI ' - + '(flag-gated passkey and magic-link entry points, which objectui defers until the ' - + 'maintainer schedules them). ADR-0049.', - acceptanceCriteria: - 'No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; ' - + 'a client that gated UI on either now treats the capability as absent rather than ' - + 'reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` ' - + 'with either key fails to parse with its own prescription instead of being silently ' - + 'stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts ' - + '`/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call ' - + 'directly.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts b/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts deleted file mode 100644 index 94b2cc96ff3..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -// The protocol-17 leg of the #4001 unknown-key strictness wave, registered as ONE -// entry for the major rather than one per batch — the shape both existing -// precedents already take (`ui-schemas-strict-unknown-keys` at 15, -// `dashboard-widget-strict-unknown-keys` at 16), and the maintainer's ruling on -// #7630 (2026-08-12) against the two alternatives (eleven entries, or a -// per-surface grouping). An upgrader reads ONE prescription covering the whole -// wave; the per-batch trace lives here so an archaeologist can still walk it -// batch by batch. Batches folded into this entry, in landing order: -// -// 1. `unknown-key-strictness-tier-a` — #4001 Tier-A: `security/permission.zod.ts` -// (permission set / object / field / admin scope) and `automation/flow.zod.ts`'s four outer -// shapes (flow / node / edge / variable), plus the shared `strictUnknownKeyError` factory. -// 2. `unknown-key-strictness-step2` — #4001 step 2: `security/rls.zod.ts`, -// `security/sharing.zod.ts` (base + criteria + recipient), `identity/position.zod.ts`. -// 3. `strict-automation-control-flow-state-machine` — #4001 batch 10: five shapes in -// `automation/control-flow.zod.ts`, six in `automation/state-machine.zod.ts`. -// 4. `unknown-key-strictness-automation-batch11` — #4001 batch 11: flow's six NESTED blocks, -// `time-relative-trigger.zod.ts`, `flow-function.zod.ts`, `webhook.zod.ts`. -// 5. `unknown-key-strictness-ui-batch13` — #4001 batch 13: all four shapes in -// `ui/responsive.zod.ts` (strictness does not recurse, so `PageComponentSchema.strict()` -// had never reached them). -// 6. `unknown-key-strictness-ui-batch15` — #4001 batch 15: `ui/theme.zod.ts` 14/14, -// `ui/chart.zod.ts` 5/7 (two arms left open, measured `no gate`). -// 7. `unknown-key-strictness-ui-batch16` — #4001 batch 16: `AriaPropsSchema` -// (`ui/i18n.zod.ts`), carried as `aria:` on ~30 live shapes under six metadata roots. -// 8. `view-subblock-strictness-batch18` — #4001 batch 18: fifteen `ui/view.zod.ts` -// sub-blocks (view data arms, filter/gantt options, conditional formatting, empty state, -// subforms, submit behaviour). -// 9. `rare-jars-shave` — `ViewItemSchema` split into a strict -// authoring gate and a `.strip()` wire variant; `ViewFilterRuleSchema` and `ListView.sort[]` -// closed with it. -// 10. `user-filters-allow-add-tab-promote-and-close` — #5073: `userFilters.allowAddTab` promoted -// into the contract, `UserFiltersSchema` closed behind it, `ObjectUserFiltersSchema` -// rejecting the three page-only keys. -// 11. `view-union-identity-precondition` — #5599: the `view` write-path union stops -// matching every object — one level ABOVE the object schemas the ten batches closed. -// -// Two adjacent seams are deliberately NOT covered here and are recorded on #7630: the 45 `~` -// rows of the #6350 audit (a ledger file was touched by some commit, which does not prove the -// entry covers that face) and the 7 borderline candidates PR #7624 judged not-owed. -export const entry: SemanticMigration = { - id: 'authoring-schemas-strict-unknown-keys', - surface: - 'the protocol-17 authoring schemas closed against undeclared keys by the unknown-key ' - + 'strictness wave — `automation/` ' - + '(flow and its six nested blocks, control-flow, state-machine, webhook, time-relative ' - + 'trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and ' - + '`identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` ' - + 'sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition ' - + 'one level above them', - replacement: - 'declared keys only. Each rejection names the surface, echoes the offending key and — where ' - + 'the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a ' - + 'prescription where a rename would be wrong. A `view` body must additionally carry at ' - + 'least one key some union member declares, discounting the identity keys the write path ' - + 'stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)', - reason: - "zod's default `.strip` discarded any key these schemas did not declare and let the parse " - + 'SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata ' - + 'that quietly ignored what they wrote. Closing them turns that into a loud parse error ' - + '(ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly ' - + 'convertible for the same reason the two precedent entries at majors 15 and 16 are not: ' - + 'an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly ' - + 'the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix ' - + 'the typo, move it to the layer that owns it, or delete dead metadata. The named renames ' - + 'the errors carry are a help, not a transform: a large share of this wave is ' - + 'PRESCRIPTIONS rather than renames precisely because renaming would be wrong ' - + "(`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` " - + 'counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` ' - + 'bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint ' - + 'vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real ' - + 'on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on ' - + '`try_catch` and `context` on a state machine have no key at all). The eleventh member ' - + 'is not an unknown-key close but the same defect one level up — the `view` union had an ' - + 'arm that both stripped and required nothing, so it matched every object and `saveMetaItem` ' - + 'persisted garbage as an ACTIVE view overlay that read back badged valid. ' - + 'This is ONE entry for the whole major by the maintainer\'s 2026-08-12 ruling that the ' - + 'wave is registered one entry per major, not one per batch, mirroring the ' - + "registry's only two precedents of this shape; the eleven batches it folds are the " - + 'changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, ' - + '`-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, ' - + '`view-subblock-strictness-batch18`, `rare-jars-shave`, ' - + '`user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, ' - + 'each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before ' - + 'closing its block: `userFilters.allowAddTab` was already read by objectui, so the ' - + 'maintainer\'s 2026-08-04 ruling declared it in the spec rather than let a correct-looking ' - + 'refusal tell authors to delete a working capability. The entry was registered in the ' - + 'backfill of the v17 train\'s breaking changesets, which had never been compared against ' - + 'the ledger. ADR-0049 / ADR-0078 / ADR-0087.', - acceptanceCriteria: - '`objectstack validate` passes with no unknown-key parse errors on any authoring surface — ' - + 'the sweep is "fix until nothing raises", and every rejection carries its own fix. ' - + '⚠️ Parsing clean is the weaker half on three of these faces, because the key was being ' - + 'dropped rather than refused and a config that silently did nothing looked exactly like ' - + 'one that worked: re-check that responsive/theme/chart styling actually renders as ' - + 'authored, that every `aria` block still names the element it was written for, and that ' - + 'each state machine and loop config still carries the transitions and caps you declared. ' - + 'For stored `view` bodies, `GET /api/v1/meta/diagnostics?type=view` lists every overlay ' - + 'the identity precondition now rejects, one row per view with the reason; each is fixed by ' - + 'giving the body a real view shape or deleting an overlay that was never a view.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts b/packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts deleted file mode 100644 index 134ac8eb039..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'batch-options-validate-only-retired', - surface: 'api.batchOptions.validateOnly', - replacement: '(removed — no dry-run today; open an issue to design a no-commit batch preview)', - reason: - 'The `validateOnly` key promised a dry-run ("validate records without persisting") but no ' - + 'batch surface ever read it — updateManyData / deleteManyData / batchData persist ' - + 'regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ' - + 'ever appeared in an HTTP request body). Callers must stop sending it.', - acceptanceCriteria: - 'No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that ' - + 'includes it answers 400 VALIDATION_FAILED with the retirement prescription.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts b/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts deleted file mode 100644 index 42b38f48cfc..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'batch-row-result-schema-shape', - surface: - 'api.batchOperationResult — the per-row `results` entries of BatchUpdateResponse ' - + '(`POST /data/:object/batch`, `/updateMany`, `/deleteMany`)', - replacement: - '`errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on ' - + '`row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row\'s position ' - + 'in the request array)', - reason: - 'The rows the three bulk-write endpoints emitted had drifted from the schema that ' - + 'declared them: `BatchOperationResultSchema`, the client SDK\'s exported ' - + '`BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / ' - + '`data` / `index`, while the wire carried `error: string` / `record` and never sent ' - + '`index` at all. A TypeScript consumer written against the published type compiled, ' - + 'validated and read `undefined` at runtime — the declared-but-not-delivered shape this ' - + 'registry exists to close, on the response envelope (ADR-0119 D4 deferred the ' - + 'reconciliation off a bug fix; this is that tracked change, shipped in the 17 major ' - + 'window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and ' - + '`updateManyData` honour `atomic` carried to those two endpoints, is structured in the ' - + 'same move: the ' - + '`ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered ' - + '`ApiError.code` values (message keeps the human-readable cause and causal row index), ' - + 'so "attempted and undone" vs "never ran" is machine-readable instead of a regex ' - + 'convention. A RESPONSE surface — nothing stored in stack metadata carries a batch ' - + 'row, so there is no source for the chain to rewrite; consumers of the legacy keys ' - + 'move their reads themselves. Off-contract readers only: the legacy keys were never ' - + 'in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: ' - + 'the implementation moves to the schema\'s shape as a hard cut in the 17 major, with no ' - + 'dual-emit transition.', - acceptanceCriteria: - 'No consumer reads `row.error` or `row.record` on a batch result row; failures are read ' - + 'from `row.errors` (message via `errors[0].message`, rollback state via ' - + '`errors[0].code` — ROLLED_BACK / NOT_ATTEMPTED), records from `row.data`, and rows ' - + 'correlate to the request via `row.index`. Every row the three endpoints emit parses ' - + 'under `BatchOperationResultSchema` with those keys present.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts b/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts deleted file mode 100644 index a5306301aff..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'client-delete-result-success', - surface: 'client.DeleteDataResult.deleted (the return of `client.data.delete()`)', - replacement: '`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name', - reason: - '`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a ' - + 'declaration that contradicted it: the interface declared `deleted: boolean` while ' - + '`DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never ' - + 'been declared by any schema and no server path has ever returned it on ' - + '`/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the ' - + 'project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / ' - + '`_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of ' - + 'it, and the claim was false in the one direction that matters: the compiler endorsed ' - + 'the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the ' - + 'branch was never taken; `if (r.success)` was rejected by the compiler and correct on ' - + 'the wire. So this rename REVEALS a defect rather than breaking working code — every ' - + 'reader of the old key was already reading `undefined`, on every deployment and not ' - + 'just some, because the protocol path has always answered `success`. It is registered ' - + 'as a semantic entry rather than a mechanical conversion for the reason the rewrite ' - + 'itself does not capture: the key is one token, but a call site that branched on ' - + '`r.deleted` has been taking the FALSE branch unconditionally since it was written, and ' - + 'whatever that branch did — or skipped — is what actually has to be re-read. There is ' - + 'no authored source for the chain to rewrite either; this is a published TypeScript ' - + 'surface whose enforced channel is tsc at the call site, and for an untyped JS caller ' - + 'there is no constrained channel at all, which is why the ledger entry is the only ' - + 'notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one ' - + 'producer shape, and a consumer accepting two spellings is what contract-first exists ' - + 'to prevent (the same rule already moved the runtime\'s ObjectQL fallback from answering ' - + '`deleted: true` to the declared `success`, on the producer side). No deprecated ' - + '`deleted?: boolean` transition key ships, for the same reason — a transition period is ' - + 'for keys that WORKED, and this one never did. Registered by the stock reconciliation of ' - + 'the v17 train\'s breaking changesets, which had never been compared against the ledger. ' - + 'ADR-0087.', - acceptanceCriteria: - 'No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.' - + 'delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller ' - + 'must be swept by hand because nothing will report it. Nothing about the request, the ' - + 'route, the status codes or the error shapes changes, and no server needs upgrading — ' - + 'the value you may now read is the one that was already arriving. ⚠️ The real work is ' - + 'behavioural: every `if (r.deleted)` has been false since it was written, so re-read ' - + 'what each of those branches was supposed to do. Post-delete cleanup, cache ' - + 'invalidation, audit writes and UI refreshes guarded that way have never run, and ' - + 'switching to `r.success` turns them ON for the first time — verify that is what you ' - + 'want rather than assuming it restores prior behaviour. Any test that passed while ' - + 'asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts b/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts deleted file mode 100644 index aad5980205b..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'connector-inline-authentication-publish-refused', - surface: 'connector.authentication on AUTHORED entries (defineStack `connectors:`, ' + - '`PUT /meta/connector/:name`) — previously refused only on provider-bound instances ' + - '(ADR-0097 §3), now refused on catalog descriptors too', - replacement: 'a catalog descriptor drops `authentication` (or sets `{ type: "none" }`) ' + - 'and documents the auth scheme in `description`; a dispatchable instance declares ' + - '`provider` and references its credential with `auth: { type, credentialRef }` ' + - '(ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape ' + - 'still carries resolved secrets inline.', - reason: - 'A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` ' + - '/ `password` / `clientSecret` is cleartext at rest, readable through the data API (the ' + - 'class a credential-persistence survey measured: any authored artefact whose schema ' + - 'permits an inline credential lands it there). No mechanical rewrite exists: whether ' + - 'the entry should become a `none` descriptor or a provider-bound instance with a ' + - '`credentialRef` — and which secret ' + - 'store receives the credential — is a judgment about the connector, not a rename.', - acceptanceCriteria: - 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no ' + - 'authored entry carries a non-`none` `authentication`; formerly inline credentials are ' + - 'reachable through `credentialRef` resolution and the connector still materializes.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts b/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts deleted file mode 100644 index e9b0b20c30d..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'dashboard-widget-compareto-offset', - surface: "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", - replacement: "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`", - reason: - 'The widget declared three comparison arms; the analytics executor implements one shape, ' - + '`{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset ' - + 'path — the spec\'s single author-facing analytics shape — `{ offset }` was forwarded ' - + 'verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, ' - + 'taking the widget down; the arm ever only ran on the legacy inline chart path (measured ' - + 'when all three declared arms were found dead on the dataset path: two silently dropped, ' - + 'this one throwing). ' - + "The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every " - + 'other duration has NO faithful target: `previousPeriod` shifts by the length of whatever ' - + "window the widget's filter resolves to, which equals `7d` only when that window happens " - + 'to be seven days long. Rewriting mechanically would silently change which rows the ' - + 'comparison column counts — a wrong number rather than a missing one, which is strictly ' - + 'worse and exactly the class this convergence exists to end. Re-stating the intended ' - + 'window is a judgment about the presentation, not a transform.', - acceptanceCriteria: - 'No dashboard widget declares `compareTo.offset`. Each former offset comparison states its ' - + "window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` " - + "(or `'previousYear'`), and `dimension` is named wherever the selection dates more than one " - + 'time dimension. `objectstack validate` passes, and each affected widget renders a ' - + '`__compare` column over the window its author intended.', - relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts deleted file mode 100644 index 7c0a6794ea5..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'data-driver-find-stream-retired', - surface: 'contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream', - replacement: - 'find() with limit/offset — the paged read whose determinism IS enforced ' - + '(IDataDriver.find, data/pagination-conformance.ts)', - reason: - '`findStream` was a REQUIRED contract method documented as "optimized for large ' - + 'datasets to avoid memory overflow", and in two of its three implementations it ' - + 'delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for ' - + 'the ENTIRE result set and then yielded it row by row, so the peak memory a caller ' - + 'was promised protection from was already reached before the first yield. The third ' - + '(`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in ' - + 'that driver never routed through `buildFindOptions`, so it hardcoded ' - + '`projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever ' - + 'observed, because the method had NO caller in either repository: the engine exposes ' - + 'no stream entry, and the REST export, import and bulk-read paths all go through ' - + '`find()`. The ~20 driver test doubles that existed only to satisfy a required ' - + 'method almost all threw `not implemented`, and nothing ever noticed — which is the ' - + 'proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every ' - + 'test double with an implementation of a capability the platform does not have. ' - + 'Rather than build a caller to justify three implementations, the method is retired; ' - + 'a real cursor-based read should return WITH the caller that needs it (ADR-0049 ' - + 'enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never ' - + 'stack metadata — so there is no source for the chain to rewrite, and deliberately ' - + 'no schema tombstone either: nothing ever ran a driver object through ' - + '`DriverInterfaceSchema.parse()`, so a prescription there would have no one to ' - + 'reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ' - + 'ADR-0078.', - acceptanceCriteria: - 'No code calls `driver.findStream(...)`; large reads page through `find()` with ' - + '`limit`/`offset` (which guarantees a total order across the whole walk) or go ' - + 'through the export surface. Drivers and test doubles no longer implement the ' - + 'method — one left behind still compiles and is simply never reached, so removing ' - + 'it is cleanup rather than a break, while a CALLER of it no longer type-checks.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts b/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts deleted file mode 100644 index dadfdbc974c..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'data-driver-query-omit-object', - surface: - 'contracts.IDataDriver query parameter — find / findOne / count / updateMany / ' - + 'deleteMany / explain', - replacement: - '`DriverQuery` (`Omit`): delete the redundant `object:` key from the ' - + 'query literal at the call site — the object name is already the FIRST argument', - reason: - 'Every one of these methods takes the object name as its first argument, and then ' - + 'required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, ' - + 'with two places for it to disagree. The layers above had already paid for that ' - + 'ambiguity: the objectql engine deliberately writes its key order as `{ ...query, ' - + 'object }` so a smuggled `query.object` cannot override the resolved name, and the wire ' - + 'layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The ' - + 'driver side paid in blanket casts: a direct caller holding only a `where` could not ' - + 'name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / ' - + '`fields` along with it — 20 such sites were measured in the downstream cloud codebase, ' - + 'and a `$like` the type layer would have caught reached runtime there through exactly ' - + 'that hole. This is a TS contract surface with ' - + 'no authored source for the chain to rewrite, which is why it is a semantic entry and ' - + 'not a D2 conversion; for a typed caller the compiler names every site (TS2353 ' - + '`\'object\' does not exist in type \'DriverQuery\'`), and for an untyped JS caller ' - + 'there is no constrained channel at all, which is exactly why this ledger entry must ' - + 'exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it ' - + 'unchanged (excess properties are only rejected on fresh literals), and an ' - + 'implementation still declaring `query: QueryAST` keeps compiling under parameter ' - + 'bivariance. What an implementation may no longer do is READ `query.object` — callers ' - + 'are now entitled to omit it. Registered by the stock reconciliation that compared the ' - + 'breaking changesets already on the v17 release train against this ledger. This change ' - + 'was that audit\'s CONTROL sample, drawn to show that not every flagged candidate is an ' - + 'omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose ' - + 'inside other entries, and the only subject-level hit on this interface is ' - + '`data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver ' - + 'call-parameter changes both registered, and both cite this narrowing as ' - + 'background; the larger sibling they derive from never got its own entry. ADR-0087 ' - + '(backfilled by that reconciliation).', - acceptanceCriteria: - 'No `IDataDriver` call site passes an inline literal carrying `object:` — `driver.find(' - + '"account", { object: "account", where: … })` becomes `driver.find("account", { where: ' - + '… })`. `tsc` is the verify loop for typed callers and reports every remaining site by ' - + 'name; an untyped JS caller must be swept by hand, because nothing will report it. Any ' - + 'driver IMPLEMENTATION that read `query.object` is rewritten to use the object-name ' - + 'argument instead — that read now yields `undefined` whenever a caller exercises its ' - + 'new right to omit the key, and it fails at runtime rather than at compile time, so it ' - + 'is the one change on this surface a type check cannot find for you.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts deleted file mode 100644 index e8e6ba6ec5f..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'data-engine-batch-retired', - surface: 'contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema', - replacement: - '`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata ' - + "protocol's `batchData` with `options.atomic: true` for a batch over one object; " - + '`POST {basePath}/batch` on the wire', - reason: - '`batch?` was declared on `IDataEngine` for as long as that contract existed and was ' - + 'never implemented by any engine: `ObjectQL` has no `batch` method and there is no ' - + 'other engine in the tree. It also had no caller — `DataEngineRequest` was imported ' - + 'by exactly one file, the contract declaring the member. Its entire specification ' - + 'was a three-word doc comment ("Batch Operations (Transactional)"), which settles ' - + 'nothing about partial failure, ordering, cross-object references, rollback scope, ' - + 'or what `transaction: false` was supposed to mean — the questions a batch API ' - + 'exists to answer. Contrast its neighbours `getDefaultDriverName?` / ' - + '`getDriverByName?`, whose optionality is evidenced: each names its implementer and ' - + 'its probing caller. The tell that nobody ever designed against it is in the schema: ' - + '`DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a ' - + 'batch could contain batches, with no statement anywhere about what that meant for ' - + 'ordering or rollback. The only test was a type pin — an ad-hoc object literal ' - + 'carrying a `batch` property, asserting the property was defined — which could not ' - + "fail while the declaration existed and would have passed unchanged for the " - + "member's whole life with no engine implementing it. What it claimed is now covered " - + 'by members that are real, so the removal deletes a false affordance rather than a ' - + 'capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 ' - + "made `batchData`'s `atomic` honest, while the wire batch has always validated with " - + '`CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from ' - + '`api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces ' - + 'only: an engine is CODE, never stack metadata, so there is no source for the chain ' - + 'to rewrite. Deliberately no schema tombstone either — nothing ever parsed ' - + '`DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one ' - + 'to reach; its three `authorable-surface.json` baseline lines and its ' - + '`json-schema.manifest.json` entry are dropped in the same change, deliberately. ' - + 'The enforced channel is tsc. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No code calls `engine.batch(...)` and no type references `DataEngineBatchRequest`; ' - + 'in-process multi-write atomicity goes through `IObjectQLEngine.transaction(cb)`, a ' - + 'batch over one object through `batchData` with `options.atomic: true`, and a ' - + 'cross-object batch over the wire through `POST {basePath}/batch`. Because no engine ' - + 'implemented the member, an implementation left behind still compiles and is simply ' - + 'never reached; a CALLER of it no longer type-checks — and there were none.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts deleted file mode 100644 index 16ceceec282..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'data-field-changed-event-retired', - surface: "api.DataEventType 'data.field.changed'", - replacement: - "the `data.record.updated` event, whose payload already carries the per-field " - + 'detail: `changes` (the changed fields), plus `before` / `after`', - reason: - '`data.field.changed` was declared in `DataEventType` and emitted by nothing — the ' - + 'engine\'s `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since ' - + 'multi-record predicate writes were given events of their own) ' - + '`data.records.{updated,deleted}`, and no other producer exists in either ' - + 'repository. A subscriber that switched on it was waiting on an event no producer ' - + 'sends: the branch never ran, and because the surrounding `switch` still compiled, ' - + 'nothing anywhere reported the gap (ADR-0078\'s silently-inert declaration, on the ' - + 'event vocabulary). `DataEventSchema` could not have carried the semantics even if ' - + 'something had emitted it — the payload is record-shaped (`recordId`, `changes`, ' - + '`before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member ' - + 'promised a granularity the contract has no room for. Per-field detail is therefore ' - + 'not lost: it has always ridden on `data.record.updated` as `changes`, which is one ' - + 'event per write rather than N events on a wide table. This is a runtime EVENT ' - + 'surface — no stack, example or template authors an event name (webhooks subscribe ' - + 'through the separate authorable `WebhookTriggerType`, whose vocabulary was already ' - + 'trimmed to producers that exist) — so there is no source for the chain to ' - + 'rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a ' - + 'retiredKey() fix-it error the way an authorable object key can (the same limit the ' - + 'sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, ' - + 'which ' - + 'fails any consumer still naming the value in a `DataEventType` position, and the ' - + 'enum parse, which now rejects the name instead of accepting an event that never ' - + 'arrives. A genuine per-field stream, if one is ever wanted, gets its own honest ' - + 'contract the way bulk writes were given theirs. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No consumer subscribes to or switches on `data.field.changed`; per-field change ' - + 'detail is read from a `data.record.updated` event\'s `changes` map (with `before` / ' - + '`after` for the surrounding state). Deleting the dead branch changes no observable ' - + 'behaviour — it never executed — so the migration is removing code that could not ' - + 'run, not rebuilding a capability.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts deleted file mode 100644 index 4e0dbb3fba9..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'datasource-config-inline-credential-refused', - surface: 'datasource.config.password (postgres / mysql / mongo) and ' + - 'datasource.config.authToken (turso)', - replacement: "the datasource secret binder: the Setup → Datasources connection form's " + - 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + - 'or a direct `external.credentialsRef` secrets-store reference', - reason: - 'A datasource artefact is persisted whole into `sys_metadata`, which is served back by ' + - 'the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled ' + - 'on 2026-08-12 to close that per artefact: each schema that admitted an inline credential ' + - 'refuses it at publish and points at the secret mechanism the artefact already had, rather ' + - 'than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: ' + - 'moving the value requires ENCRYPTING it into a `sys_secret` row through a running ' + - "secret binder and deleting the cleartext, which a source-file transform cannot do — " + - 'auto-deleting the key alone would silently drop a live credential instead.', - acceptanceCriteria: - 'Every datasource parses with no `config.password` / `config.authToken` key; each ' + - 'affected datasource carries `external.credentialsRef` (or has its secret bound through ' + - 'the connection form) and still connects; no cleartext credential remains in any ' + - 'stored `sys_metadata` row or authored source.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts deleted file mode 100644 index cd708631200..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'datasource-config-placeholder-refused', - surface: 'connection-material string keys of the built-in driver configs — ' + - 'postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/' + - '`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), ' + - 'turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values ' + - 'containing `${…}` placeholder syntax', - replacement: 'the literal value. For secret material, the datasource secret binder: the ' + - "Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle " + - 'stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. ' + - 'For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` ' + - 'and friends are translated into driver config by the boot hosts and never pass through ' + - 'the publish door', - reason: - 'A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is ' + - 'stored verbatim in `sys_metadata` and handed verbatim to the database client at connect ' + - '(measured by the credential census while the inline-credential refusal was being built), ' + - 'so the connection fails, or connects somewhere ' + - 'unintended, with no error naming the unresolved placeholder — the masked-failure shape. ' + - 'The syntax looked supported: it parsed green, stored fine, and failed at a distance; two ' + - 'shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had ' + - 'to warn "do NOT substitute a placeholder" around the broken escape. The maintainer ruled ' + - 'on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; ' + - 'implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and ' + - 'zero measured pull for actual substitution. There is no mechanical rewrite: the ' + - 'placeholder names a value that exists only in the author\'s intended deployment ' + - 'environment, which a source-file transform cannot know — substituting anything would ' + - 'invent a connection target.', - acceptanceCriteria: - 'Every datasource parses with no `${…}` span in any connection-material config value; ' + - 'environment-driven deployments carry their DSN in the runtime environment ' + - '(`OS_DATABASE_URL` and friends) or bind secrets via `external.credentialsRef`, and ' + - 'still connect; no unresolved placeholder remains in any stored `sys_metadata` row or ' + - 'authored source.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts deleted file mode 100644 index b811c391f8b..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'datasource-config-url-userinfo-refused', - surface: 'datasource.config.url (postgres / mysql / mongo / turso) and ' + - 'datasource.config.syncUrl (turso) — the URL userinfo password segment ' + - '(`user:password@host`)', - replacement: 'the same URL with its userinfo password removed (a bare `user@host` stays ' + - "legal), plus the datasource secret binder: the Setup → Datasources connection form's " + - 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + - 'or a direct `external.credentialsRef` secrets-store reference', - reason: - 'The inline-credential closure refused the credential KEYS, and building it measured that ' + - '`config.url` still accepted the identical secret one syntax over — ' + - '`postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as ' + - '`config.password` did, and the key refusal itself steered authors there. The maintainer ' + - 'ruled on 2026-08-12 (Option A) to refuse the URL userinfo password at publish, through ' + - 'one value-level parse the driver schemas share. Runtime-environment DSNs ' + - '(`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected ' + - 'by construction. There ' + - 'is no mechanical rewrite, for the same reason as the sibling entry ' + - '`datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it ' + - 'into a `sys_secret` row through a running secret binder and stripping the cleartext, ' + - 'which a source-file transform cannot do — auto-stripping the userinfo alone would ' + - 'silently drop a live credential instead. Do not substitute a `${…}` placeholder into ' + - 'the URL: placeholders in authored metadata are resolved by nothing and reach the ' + - 'database client verbatim (measured when the inline-credential refusal was built).', - acceptanceCriteria: - 'Every datasource parses with a credential-free `config.url` / `config.syncUrl` (no ' + - 'userinfo password segment); each affected datasource carries ' + - '`external.credentialsRef` (or has its secret bound through the connection form) and ' + - 'still connects; no URL-embedded credential remains in any stored `sys_metadata` row ' + - 'or authored source.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts b/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts deleted file mode 100644 index f35a4e0842a..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'declarative-apis-endpoints-live', - surface: 'stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)', - replacement: - 'the same declarations, re-read as LIVE HTTP routes: `path` moved under ' - + '`/api/v1/apps//`, and every entry that declares ' - + '`authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying ' - + '`rateLimit: { enabled: true, … }`', - reason: - 'This is the one protocol-17 entry that turns metadata ON rather than off, so read it ' - + 'as a SECURITY review item and not as a rename. Before 17 the declarative endpoint ' - + 'surface executed NOTHING: no route was mounted for a declared `path`, no matcher ' - + 'existed, and every key — `authRequired` included — parsed green and gated nothing ' - + '(which is why the maintainer\'s 2026-08-04 ruling refused a non-empty `apis:` outright ' - + 'until an executor existed). Protocol 17 ships that ' - + 'executor and narrows the refusal to a per-endpoint ' - + 'publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as ' - + 'soon as the stack is published. So an `apis:` block written against an older major — ' - + 'or one restored from a source older than that ' - + 'refusal, or authored from a doc that predates the ' - + 'refusal — changes meaning without changing a byte: what used to be inert ' - + 'documentation becomes an execution entry point into the data and automation ' - + 'pipelines. Nothing about that transition can be applied mechanically, because the ' - + 'judgment it needs is "did the author of this endpoint mean for the internet to reach ' - + 'it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its ' - + 'schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT ' - + '`authRequired: false` is the only thing that opens anonymous access, and under ' - + 'ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key ' - + 'defaults to `false`, so a budget written without it meters nothing) or the stack ' - + 'refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with ' - + '`ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: ' - + '`const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the ' - + 'safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type ' - + '(defaults materialized, ADR-0122), where `authRequired` is required — annotating a ' - + 'declaration with it forces you to write the key out, and being made to think about a ' - + 'key whose only unrecoverable value is `false` is the one thing this entry is trying ' - + 'to avoid. Hold a parse RESULT with `ApiEndpointParsed`; write declarations ' - + 'as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you ' - + 'upgrade, delete the ones that were never meant to be public, and arm a budget on the ' - + 'ones that were. The path move is the mechanical-looking half and is still yours: ' - + 'ADR-0121 D1/D2 confine a declared path to your own namespace carve-out ' - + '(`/api/v1/apps//…`), the namespace comes from an explicit ' - + '`manifest.namespace` with no derivation fallback, and the subpath is the only part ' - + 'you name — rewriting it for you would silently change a URL third parties call.', - acceptanceCriteria: - 'You have READ every entry of every `apis:` block, not just the ones that fail to ' - + 'publish. Concretely: (1) each declared `path` is ' - + '`/api/v1/apps//` and the stack declares that ' - + '`manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is ' - + 'one you INTEND to be reachable without a session, and each carries ' - + '`rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not ' - + 'intended to be anonymous have the key removed so the safe default (`true`) applies; ' - + '(3) `objectstack validate` passes, which also proves no endpoint declares a shape ' - + '17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an ' - + '`object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, ' - + '`inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and ' - + '(4) after publishing, each endpoint answers as you expect — an anonymous request to ' - + 'a session-only endpoint returns 401 rather than data.', - relevantWhen: { kind: 'stack-declares', keys: ['apis'] }, -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts b/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts deleted file mode 100644 index e9fd30754a6..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'delete-by-id-before-hook-repoint-retired', - surface: - 'a `beforeDelete` handler on a BY-ID `delete()` assigning `ctx.input.id` a DIFFERENT id, ' - + 'to move the delete onto that row', - replacement: - 'delete the other row explicitly — `ctx.ql.delete(object, otherId)` / `ctx.api` — and let ' - + 'the addressed delete proceed or `throw` from the handler to stop it; to delete MANY rows, ' - + "have the CALLER pass `{ multi: true, where: … }`. Writing the SAME id back is unaffected " - + 'and stays legal.', - reason: - 'The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` ' - + 'handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table ' - + 'still answering differently: it HONOURED a repoint, re-resolving the new target by ' - + "re-reading its pre-image and rebinding `previous` (the fix that first made a single-row " - + "delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row " - + 'actually deleted. It now refuses with ' - + '`HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: \'by-id\'`, exactly as the ' - + '`update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\n' - + 'Read this as a RULING, not a defect report — that distinction is the reason the entry ' - + 'is worth its length. That re-resolution was internally CORRECT and nothing stale ' - + 'ever leaked from it; the case that retires a rebind on `update()` (the write landing on ' - + 'a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) ' - + 'simply did not apply to it. The engine change that dispatches `before*` hooks per matched ' - + 'row on a bulk write therefore left the asymmetry standing on purpose rather than folding ' - + 'a behaviour removal into an ordering change, and filed it as a finding of its own. The ' - + '2026-08-09 maintainer ruling on that finding closed it on three ' - + 'measured axes instead: compatibility cost zero (a repository-wide grep for assignments ' - + "into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL " - + 'SIX are this family\'s own pins — no consumer anywhere repoints); one rule across both ' - + 'verbs beats two individually-correct rules an author has to memorize, since the ' - + 'justification for the split lived in an ADR rather than at the call site; and "a hook ' - + 'silently redirects which row gets deleted" is a top-grade footgun for authored — ' - + 'especially AI-authored — handlers however correctly the redirect is implemented. ' - + 'Correctness of a mechanism does not justify the surface it exposes. Aligning the other ' - + 'way, by building `update()` the same re-resolution, stays excluded by the recorded ' - + 'ruling that extended per-row hook semantics to `before*` hooks on bulk writes ("do not ' - + 'silently pick re-resolution instead").\n\n' - + 'Why this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as ' - + '`hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at ' - + 'this step: FIRST, there is no source to convert — a `HookContext` is constructed per ' - + 'write and never persisted, so no `sys_metadata` row, example or template can carry the ' - + 'assignment. SECOND, the only place it is ever SPELLED is inside a handler body: ' - + 'author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative ' - + 'transform cannot safely rewrite an assignment inside free-form code, and the intent is ' - + 'not recoverable anyway — only the author knows whether the repoint meant "delete that ' - + 'row INSTEAD" or "delete that row TOO".\n\n' - + 'What makes this one cheaper to meet than its two siblings, and worth saying because it ' - + 'bounds the work: the removed capability has an ENFORCED channel at run time. The refusal ' - + 'throws before anything is written and its message NAMES the retired capability and the ' - + 'three replacement routes, so a handler that still repoints fails loudly and self-' - + 'describingly on its first execution rather than going quiet. This ledger entry is the ' - + 'channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2.', - acceptanceCriteria: - 'No `beforeDelete` handler assigns `ctx.input.id` anything but the id it arrived with — ' - + 'grep handler bodies for assignments into `input.id` and rewrite each into an explicit ' - + '`ctx.ql.delete()` for the other row, a caller-side `{ multi: true, where: … }`, or a ' - + '`throw`. A delete-heavy smoke run completes with no `HookTargetRebindError` ' - + "(`ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, `event: 'beforeDelete'`) — and any that does " - + 'raise names its `expectedId` and `observedId`, which identifies the handler that moved ' - + 'the target.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts deleted file mode 100644 index aaa21aa7de5..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'driver-aggregate-undeclared-key-aliases-removed', - // No backticks in `surface`: the upgrade-guide renderer wraps this string - // in a code span of its own, and a nested pair renders as literal ticks. - surface: "driver aggregate() call argument — query.aggregate and aggregations[].func", - replacement: - 'query.aggregations and aggregations[].function — the spellings QueryASTSchema and ' - + 'AggregationNodeSchema have always declared', - reason: - '`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the ' - + 'Query Protocol has never declared: `query.aggregations || query.aggregate` and ' - + '`agg.function || agg.func`. "Never declared" is measured, not assumed — `git log ' - + '-S` over `data/query.zod.ts` finds no commit that ever introduced either name, ' - + 'there is no `retiredKey()` tombstone and no alias-table entry for them (the file\'s ' - + 'only alias table is `SortNode`\'s `direction` → `order`), and neither appears in any ' - + 'upgrade guide or release note. So this entry does not record a declared surface ' - + 'being withdrawn; it records a LENIENCY being withdrawn, which is why it is here ' - + 'rather than behind a tombstone. The only writers in this repository were the two ' - + 'driver packages\' own fixtures — the family of the org-axis red-line gate that read ' - + 'only rejected aliases while its own fixtures spelt them, so its tests stayed green ' - + 'and the rule stayed dead: a fixture spelling the alias keeps the tolerant limb green ' - + 'forever and no test in existence can go red on its deletion — so ADR-0049 ' - + 'enforce-or-remove applies once those are re-spelt. ⚠️ Do ' - + 'NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical ' - + 'key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ' - + 'ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most ' - + 'likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two ' - + 'surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, ' - + 'never stack metadata — so there is no source for the D2 chain to rewrite and ' - + 'deliberately no schema tombstone: nothing ever ran a query through ' - + '`QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call ' - + 'site, once the parameter is `DriverQuery` — and for an untyped JS caller there is ' - + 'no enforced channel at all, which is exactly why this ledger entry has to exist: ' - + 'the generated upgrade guide is the only way such a reader learns of the rename. ' - + 'Same disposition, and the same reason, as `data-driver-find-stream-retired` ' - + '(`IDataDriver.findStream`, removed with no tombstone because nothing parses a driver ' - + 'object), `storage-service-list-retired` (the zero-consumer `IStorageService.list`, ' - + 'whose two adapters answered differently and both incompletely) and ' - + '`actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the ' - + 'maintainer\'s word rather than given a window). The removal ran in a fixed order — ' - + 'the fixtures re-spelt first, the two alias branches deleted second, the parameter ' - + 'narrowed to `DriverQuery` last — because the reverse order yields red nobody can ' - + 'explain. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No caller passes `aggregate:` to a driver\'s `aggregate()`, and no aggregation entry ' - + 'spells its function `func:`; both are written `aggregations:` / `function:`. An ' - + 'inline literal still using either old spelling no longer type-checks (TS2353 at the ' - + 'call site). An untyped JS caller that keeps writing `aggregate:` silently receives ' - + 'no aggregate column — the grouping still happens, the measure is simply absent — ' - + 'and one that keeps writing `func:` receives INVALID_QUERY / 400 naming the ' - + 'undeclared function, identically on the local driver and the Turso remote ' - + 'transport.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts deleted file mode 100644 index 577af41f14d..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'driver-capabilities-inert-bits-removed', - surface: - 'data.DriverCapabilities.create / data.DriverCapabilities.read / ' - + 'data.DriverCapabilities.update / data.DriverCapabilities.delete / ' - + 'data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / ' - + 'data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / ' - + 'data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / ' - + 'data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / ' - + 'data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / ' - + 'data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / ' - + 'data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / ' - + 'data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / ' - + 'data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / ' - + 'data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / ' - + 'data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / ' - + 'data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / ' - + 'data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / ' - + 'data.DriverCapabilities.queryCache', - replacement: - '(removed — delete the keys. A driver advertises a capability by implementing the ' - + 'corresponding IDataDriver method; the three bits that survive because method ' - + 'presence cannot carry the signal are `queryDateGranularity`, `autonumber` and ' - + '`batchSchemaSync`)', - reason: - 'Retiring `IDataDriver.findStream` (it had no production caller, and two of its three ' - + 'implementations read the whole result set into memory before yielding a row) left ' - + '`DriverCapabilities.streaming` pointing at a capability the contract no longer ' - + 'declares, and the follow-up audit checked ' - + 'every bit in the record the same way, across objectstack and cloud (objectui ' - + 'confirmed clean): of 34 declared bits, THREE have a decision-making reader — ' - + '`queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), ' - + '`autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ' - + 'ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` ' - + 'from a base whose transport batches while its own cannot) — and THIRTY-ONE were ' - + 'written by every driver and read by nothing. Their `.describe()` strings promised ' - + 'engine adaptation ("if false, ObjectQL will filter/sort/paginate in memory") that ' - + 'was never built, and zero readers let the values go WRONG unnoticed: SqlDriver ' - + 'declared `streaming: false` while implementing `findStream`; InMemoryDriver ' - + 'declared `streaming: true` over a full-table read (ADR-0078 false affordance, on ' - + 'the capability record itself). The real mechanism everywhere else is METHOD ' - + 'presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on ' - + '`typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the ' - + 'REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never ' - + 'stack metadata — `supports` literals live in driver classes and ' - + '`DriverConfig.capabilities` is plugin TS configuration, neither ever a ' - + '`sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was ' - + 'retired separately, as a whole block nothing read) — so there is no source for the ' - + 'D2 chain to rewrite ' - + 'and this entry is the D3 record. The keys are tombstoned rather than deleted ' - + 'because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed ' - + '(DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): ' - + 'a plain delete would silently strip a vendor\'s authored bit, replacing one ' - + 'silent no-op with another. `batchSchemaSync` also drops its `.default(false)` ' - + 'for `.optional()` — absence already meant false at both readers, and the default ' - + 'forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No `supports` literal or `DriverConfig.capabilities` object authors any of the 31 ' - + 'retired bits — a driver class that still writes one fails tsc against ' - + '`IDataDriver.supports` (the bit is `never`), and a parsed config fails with the ' - + 'per-key prescription. The three in-repo drivers (memory / mongodb / sql) declare ' - + 'only live bits; cloud\'s TursoDriver keeps compiling via its `...super.supports` ' - + 'spread (its stale explicit overrides are cleanup, tracked cloud-side). Engine ' - + 'behaviour is byte-identical: every removed bit had zero readers, and the three ' - + 'live bits keep their readers (engine.ts autonumber defer / aggregate dispatch, ' - + 'plugin.ts + engine.ts batched schema sync, verify date-bucket parity).', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts deleted file mode 100644 index 11601ea23ac..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'driver-sql-distinct-bare-filter-typed', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: 'SqlDriver.distinct() third argument — any value', - replacement: - 'a bare FilterCondition (@objectstack/spec/data) — the same value find() carries ' - + 'under query.where, never a query envelope', - reason: - 'This entry records a TYPE being added, not a surface being withdrawn, and it says ' - + 'so up front because the distinction decides who has to do anything. `distinct` is ' - + 'not declared on `IDataDriver`, so neither the narrowing of `IDataDriver`\'s query ' - + 'parameters to `DriverQuery` nor the follow-through that brought five drivers\' ' - + 'implementations in line ever reached it, and it kept ' - + '`filters?: any` while its body said something far more specific — ' - + '`applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` ' - + 'off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry\'s change: not one ' - + 'statement moved, so no upgrade breaks at run time and nothing that answered ' - + 'correctly stops. What the annotation removes is a compile-time hole, measured ' - + 'rather than assumed: a truthy NON-OBJECT third argument — ' - + '`distinct(\'orders\', \'product\', \'completed\')` — used to type-check and resolve ' - + 'the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy ' - + 'non-object, non-array filter. A call meaning "which products among completed ' - + 'orders" answered with EVERY product, silently. That spelling is now TS2345 at the ' - + 'call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there ' - + 'is no source for the D2 chain to rewrite and deliberately no schema tombstone, the ' - + 'disposition `data-driver-find-stream-retired`, `storage-service-list-retired`, ' - + '`actor-user-roles-to-positions` and ' - + '`driver-aggregate-undeclared-key-aliases-removed` already carry. ⚠️ It ' - + 'differs from those four in ONE measured way a reader should not have to infer: ' - + 'because nothing changed at run time, an untyped JS caller is not affected BY THE ' - + 'UPGRADE at all. The entry is here for a different reason — such a caller is exactly ' - + 'the one tsc can never reach, and the silent widening above is a defect they may ' - + 'ALREADY be sitting on, before and after this major. The generated upgrade guide is ' - + 'the only channel that reaches them, which is why the fix is written down rather ' - + 'than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can ' - + 'close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter ' - + 'key IS a field name, so a query envelope `{ object, where }` is structurally a ' - + 'valid filter — one constraining columns named `object` and `where` — and so is a ' - + 'FilterArray. Both reach `distinct` type-checked and are refused at run time, ' - + 'loudly, with INVALID_FILTER / 400. `driver-memory`\'s opposite half — where the ' - + 'BARE spelling returns the unfiltered set in silence — stayed open under the ' - + 'maintainer\'s 2026-08-05 investment freeze on driver-memory, which was lifted on ' - + '2026-08-11; it is still open, now unexcused rather than deferred (the measurement ' - + 'that found the two drivers reading this argument differently split the fix: the sql ' - + 'half is this entry, and the memory half was held back by that freeze). ADR-0087.', - acceptanceCriteria: - 'No caller passes a non-object to `distinct()`\'s third argument. A scalar there is ' - + 'now a compile error (`TS2345: Argument of type \'string\' is not assignable to ' - + 'parameter of type \'FilterCondition\'`); rewrite it as the bare filter it was ' - + 'always meant to be — `\'completed\'` becomes `{ status: \'completed\' }`. ⚠️ That ' - + 'is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the ' - + 'answer changes once fixed, and the changed answer is the one the call always meant. ' - + 'An untyped JS caller gets no compile error and no behaviour change — for them this ' - + 'entry is the only notice that the spelling never filtered anything. A query ' - + 'envelope or a FilterArray in that slot still compiles and is rejected at run time ' - + 'with INVALID_FILTER / 400.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.engine-dotted-projection-refused.ts b/packages/spec/src/migrations/entries/semantic/17.engine-dotted-projection-refused.ts deleted file mode 100644 index 60762a2618f..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.engine-dotted-projection-refused.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'engine-dotted-projection-refused', - surface: - 'engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a ' - + 'dotted entry (`account.name`) — the direct engine path, not the REST ingress', - replacement: - "read the related record with `expand` (`{ expand: { account: { object: '', " - + "fields: ['name'] } } }`), keeping the reference column itself in `fields` — the " - + 'relation is carried by that column and projecting it away leaves expansion nothing ' - + 'to resolve, the same silent no-op a nested projection omitting the related `id` ' - + 'produced before the engine began keeping that join key itself; or denormalise the ' - + 'value onto the queried object (a stored field, written when the source changes) ' - + 'and name that — the same remedy the REST ingress prescribes when it refuses a ' - + 'dotted projection, and the sort axis when it refuses a dotted sort', - reason: - "The REST ingress closed the PROJECTION axis' dotted leg first, refusing a dotted " - + 'entry instead of widening the response to every field ' - + '(`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything ' - + 'reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` ' - + 'DIRECTLY passed through none of it, and that caller set was measured, not assumed: ' - + "a flow `get_record` node's authored `fields: ['name', 'account.name']` " - + 'parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into ' - + "`data.find(...)`, cleared the engine's head-only projection filter on its head " - + 'segment (`account` IS a field), and reached the driver as a projection column — ' - + 'where SQL renders `"account"."name"` against a table that was never joined, the ' - + "DB answers `no such column`, and the driver's unknown-column recovery ladder — " - + 'there so an unknown column never reads as "no rows" — retries ' - + "`select('*')`. The caller asked to narrow and silently received EVERY field, " - + 'byte-identical to no projection at all, pointing away from both FLS and data ' - + 'minimisation.\n\n' - + 'Ruled by the maintainer on 2026-08-12: a dotted entry the engine cannot resolve is ' - + "refused loudly at the engine's own head-only projection filter, covering every " - + 'caller that reaches the engine. The check it replaces was justified by a comment ' - + 'claiming the engine resolves relationship paths "via populate"; a measurement found ' - + 'that NO populate step exists — once the spec and docs stopped prescribing a dotted ' - + '`fields` path, that comment was the last place in the repo asserting dotted-path ' - + 'resolution does — so what was removed is not a ' - + 'working feature but a path to widening, kept alive by a false premise. The ' - + 'unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown ' - + 'plain name still drops silently; an all-unknown projection still falls back to ' - + '`*`), a registry-less host gets no verdict (the driver-side recovery ladder remains ' - + 'its documented backstop, and a driver-side carve-out is measured-need only), and ' - + 'a dotted `fields` inside a nested `expand` degrades to an observable warning ' - + "rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation " - + '`catch` swallows every expand failure, the same posture the formula-sort refusal ' - + '(`engine-find-formula-order-by-refused`) records for the same catch.\n\n' - + 'This is a CODE-path API, not stored metadata, so — like ' - + '`engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` ' - + 'row for the D2 chain to rewrite and the ledger entry is the notification channel. ' - + 'No mechanical rewrite exists: the platform cannot decide between `expand` and ' - + 'denormalisation for the caller, and it must not resolve the path itself — no ' - + 'driver ever did, and inventing a join here is a feature decision, not a ' - + 'migration — the line analytics already takes for a relation-traversing dotted ' - + 'measure, refused with a 400 naming the caller\'s spelling rather than computed ' - + 'against the wrong column. ADR-0112.', - acceptanceCriteria: - 'No `engine.find` / `engine.findOne` call site passes a dotted `fields` entry, no ' - + "flow `get_record` config authors one, and no saved report's " - + '`query.fields` names one — grep flow definitions and report definitions for a ' - + '`fields` entry containing a `.`, and rewrite each to `expand` (keeping the ' - + 'reference column projected) or to a denormalised stored column. Reads complete ' - + 'with no `INVALID_FIELD` whose message says "follows the relationship" or "a ' - + 'dotted path", and no "Failed to expand relationship field" warning whose error ' - + 'text does.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-filter-refused.ts b/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-filter-refused.ts deleted file mode 100644 index 74b52cbc0c6..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-filter-refused.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'engine-find-formula-filter-refused', - surface: - 'a `where` / filter naming a `formula` field — at BOTH doors: the REST ingress ' - + '(`assertFilterFieldsExist`, covering everything that reaches `findData`) and the ' - + 'engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` / `update` / ' - + '`delete`), which saved reports, flows and dashboard widgets reach directly', - replacement: - 'denormalise the value onto the object (a stored field, written when the source ' - + 'changes) and filter that — deliberately the same remedy, in the same words, the ' - + 'SORT axis prescribes when it refuses a formula sort, at the ingress and at the ' - + 'engine, and the SEARCH axis prescribes when it refuses a formula search field; ' - + '`summary` and `autonumber` fields need NO action, because both get ' - + 'real maintained columns and filter correctly', - reason: - '`formula` is the one field type no driver materialises a column for, and FILTER was ' - + 'the last of the three query axes still fail-open on it: SORT refuses it (at the ' - + 'REST ingress and at the engine) and SEARCH refuses it by name, while a ' - + '`where` on a `formula` field cleared every gate precisely BECAUSE the object ' - + 'declares the field, reached a driver with no column behind it, and answered 200 ' - + 'with zero rows. Measured on a real `ObjectQL` with `is_open` a `formula` over the ' - + "stored `status` column: `where {is_open: true}` and `where {is_open: false}` each " - + "returned 0 rows with NO error, while the controls `where {status: 'open'}` returned " - + '4 rows and `where {subtask_total: 5}` (a `summary`, which HAS a column) returned 1 ' - + 'row.\n\n' - + 'BOTH directions are wrong and the `false` one is the dangerous one: the same ' - + 'predicate against a STORED boolean returns every matching row, so a filter meaning ' - + '"not yet done" silently became "no records at all" — a row SET changed under a 200, ' - + 'which no amount of inspecting the response can reveal, and the formula READS ' - + 'correctly in that very same response, so the field is visibly populated and ' - + 'simultaneously unfilterable. That is strictly worse than the sort axis it mirrors: ' - + 'a refused sort returns the same rows in a different order, a refused filter changes ' - + 'which rows exist.\n\n' - + 'Both doors now refuse it with `400 INVALID_FIELD`, naming the ' - + 'offending key path and carrying the remedy sentence — the ingress gate ' - + '(`assertFilterFieldsExist`, `@objectstack/metadata-protocol`) for everything ' - + 'reaching `findData`, and `assertFilterIsMaterializable` ' - + "(`@objectstack/objectql`, `filter-comparand-shape.ts`) at the engine's own filter " - + 'seam, which every caller-supplied `where` passes through whichever verb it arrived ' - + 'by. Both judge the field by the SAME `@objectstack/spec/data` predicate the SEARCH ' - + 'axis uses (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`, which holds `formula` ' - + 'and nothing else), so gate and drivers cannot disagree about which types have a ' - + 'column: a gate widened to the spec\'s `COMPUTED_VALUE_TYPES` (the WRITE contract) ' - + 'would refuse two working types. DOTTED filter paths are deliberately not judged on ' - + 'this axis at either door.\n\n' - + 'This is a CODE-path API, not stored metadata, so — like ' - + '`engine-find-formula-order-by-refused` and `engine-dotted-projection-refused` at ' - + 'this step — there is no `sys_metadata` row for the D2 chain to rewrite and this ' - + 'ledger entry is the notification channel. No mechanical rewrite exists in either ' - + 'direction: the platform cannot invent the stored column the remedy prescribes, and ' - + 'it must not filter post-hoc instead — `driver.find` has already applied `limit` / ' - + '`offset`, so a predicate applied after the formulas are evaluated would filter an ' - + 'ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment ' - + 'pagination is involved.\n\n' - + 'AUTHOR-REACHABLE SURFACES are why this is not merely a code-side note. A saved ' - + "report's `query.filter` (`sys_saved_report`) is forwarded VERBATIM into " - + '`engine.find` by `plugin-reports` (`report-service.ts`, `where: q.filter`), ' - + 'bypassing the ingress gate entirely; flow node `config.filter` and dashboard widget ' - + 'filters are author-written the same way. A report or flow authored to filter on a ' - + 'formula field used to run and quietly return the wrong row set; it now fails ' - + 'loudly, with the remedy in the message.\n\n' - + 'Registered on the ruling inherited from the SORT axis — its engine refusal was ' - + 'registered in this ledger although no stored row needs rewriting, because the ' - + 'ledger is the one channel that carries its rewrite instructions to the author — ' - + 're-affirmed for this axis at triage on 2026-08-13: the shape is identical to the ' - + 'sort axis and the consequence here is larger. ADR-0112.', - acceptanceCriteria: - 'No filter names a `formula` field on any surface — grep your saved report definitions ' - + "(`sys_saved_report.query.filter`), flow node `config.filter`, dashboard widget " - + 'filters and view filters for a filtered field whose object declares it as a ' - + '`formula`, and denormalise each onto a stored column written when the source ' - + 'changes. A `summary` / `autonumber` field needs no action: both have real ' - + 'maintained columns and filter correctly. Reads complete with no `INVALID_FIELD` ' - + 'naming a virtual `formula` field in a filter, at either door.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts b/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts deleted file mode 100644 index 452b8f525ac..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'engine-find-formula-order-by-refused', - surface: - 'engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a ' - + '`formula` field — the direct engine path, not the REST ingress', - replacement: - 'denormalise the value onto the object (a stored field, written when the source ' - + 'changes) and sort by that — the same remedy the REST ingress prescribes when it ' - + 'refuses a dotted or formula sort; a `summary` field is unaffected and still sorts, ' - + 'because it gets a real maintained column', - reason: - 'The SORT axis is closed at the REST ingress for an unknown field, a dotted path and ' - + 'a `formula` field alike ' - + '(`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching ' - + '`findData`: the list route, `POST /data/:object/query`, the export route and the ' - + 'RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY ' - + 'passed through none of it, and a `formula` ORDER BY there was dropped in silence. ' - + 'Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion ' - + 'order, under a success, with the rows carrying the very values they were asked to ' - + 'be ordered by. No column exists to order by (a formula is computed on read, so no ' - + 'driver materialises one), so the ORDER BY reached the driver, found nothing, and ' - + 'the unknown-column backstop returned the rows unordered.\n\n' - + 'Ruled by the maintainer on 2026-08-10: an ORDER BY the engine cannot apply is a 4xx ' - + 'with guidance prose at the public boundary, never a silent drop — the same direction ' - + "as the analytics dataset refusal envelope and the ingress sort hint's stored-field " - + 'prescription. The ' - + "engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s " - + 'docblock) was to survive only behind a pinned internal path, and only if a MEASURED ' - + 'internal call site relied on it. The sweep of every in-tree `orderBy` reaching ' - + 'the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata ' - + 'loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real ' - + 'stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, ' - + '`started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo ' - + 'declares a `formula` field at all. So no internal path shipped, and there is no flag ' - + 'to opt back into the drop.\n\n' - + 'This is a CODE-path API, not stored metadata, so — like ' - + '`hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` ' - + 'row for the D2 chain to rewrite and the ledger entry is the notification channel. ' - + 'No mechanical rewrite exists in either direction: the platform cannot invent the ' - + 'stored column the remedy prescribes, and it must not sort post-hoc instead — ' - + '`driver.find` has already applied `limit` / `offset`, so re-sorting after the ' - + 'formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small ' - + 'result sets and is wrong the moment pagination is involved.\n\n' - + 'ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a ' - + "code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded " - + 'verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A ' - + 'report authored to sort by a formula field used to run and return rows in an ' - + 'arbitrary order; it now fails loudly, with the remedy in the message. One further ' - + 'path is deliberately NOT a refusal: a nested `expand` sort raises this refusal ' - + 'inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` ' - + 'swallows every expand failure and retains the raw foreign keys — so that path moves ' - + 'from silent to OBSERVABLE (a warning naming the field and the fix) rather than ' - + 'refusing. Reversing that backstop is a separate decision on all expand failure ' - + 'modes. ADR-0112.', - acceptanceCriteria: - 'No `engine.find` / `engine.findOne` call site sorts by a `formula` field, and no saved ' - + "report's `query.orderBy` names one — grep your report definitions for an `orderBy` " - + 'field whose object declares it as a `formula`, and denormalise it onto a stored ' - + 'column written when the source changes. A `summary` / rollup field needs no action: ' - + 'it has a real maintained column and sorts correctly. Reads complete with no ' - + '`INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" ' - + 'warning whose error text names one.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.engine-update-upsert-retired.ts b/packages/spec/src/migrations/entries/semantic/17.engine-update-upsert-retired.ts deleted file mode 100644 index ad827654165..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.engine-update-upsert-retired.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'engine-update-upsert-retired', - surface: 'data.engine.update options.upsert', - replacement: - '(removed — never implemented; express create-if-absent explicitly: `findOne` first, then ' - + '`insert` or `update` on what you find)', - reason: - 'The `upsert` flag promised insert-if-absent on `engine.update()` but no engine or driver ' - + 'path ever read it: the key was declared on both update-options schemas and allowlisted by ' - + 'the unknown-option gate, yet `ObjectQL.update()` never referenced it and it was not a ' - + 'driver pass-through key — `{ upsert: true }` was accepted and silently dropped and the ' - + 'update stayed a plain update (ADR-0049 declared-but-unenforced). There is no behaviour to ' - + 'preserve and nothing stored to rewrite (it only ever appeared in a call-time option bag). ' - + "Any future first-class upsert must reconcile with the engine's not-found gate — a by-id " - + 'update whose id names no row throws RECORD_NOT_FOUND rather than inserting — which is why the ' - + 'flag is removed rather than implemented here.', - acceptanceCriteria: - 'No caller passes `options.upsert` to `engine.update()`; a call that includes it is refused ' - + 'loudly (the engine gate and both schemas quote one removal prescription) instead of ' - + 'succeeding with the option silently ignored.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts b/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts deleted file mode 100644 index 4129b5ab5d6..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'enhanced-api-error-field-errors-renamed', - surface: 'api.enhancedApiError.fieldErrors', - replacement: 'fields', - reason: - 'The wire has always carried `fields` — the validators, import coercion, ' + - 'validation-failure.ts, @objectstack/client and the console\'s field-error extractor ' + - 'all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it ' + - 'was reading a field no server sent (ADR-0078\'s silently-inert declaration, on the ' + - 'error envelope). This is a RESPONSE surface: no stack, example or template carries ' + - 'the key, so there is no source for the chain to rewrite — the schema tombstones it ' + - 'via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the ' + - 'field-level error code catalog).', - acceptanceCriteria: - 'No consumer reads `error.fieldErrors`; per-field validation detail is read from ' + - '`error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse ' + - 'with the rename prescription instead of silently losing the array.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts b/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts deleted file mode 100644 index 7efc5b7e2cf..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'etl-pipeline-layer-retired', - surface: - 'automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / ' - + 'automation.etlDestination / automation.etlTransformation (the whole L2 layer of ' - + 'automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported ' - + 'names)', - replacement: - '(removed — no protocol surface replaces it, deliberately. Layer by layer: ' - + 'connector-attached synchronisation is `ConnectorSchema.syncConfig` ' - + '(`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT ' - + 'EXECUTED — a declared shape, not a running sync. ' - + '`AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the ' - + 'parsed definition; nothing reads `syncConfig` back off it, and the key has no ' - + 'reader outside `packages/spec` at all — the same measurement that deleted ' - + '`syncConfig.schedule` in @objectstack/spec 17 under ADR-0049, with the other cron-typed positions ' - + 'nothing reads. What the platform DOES ' - + 'execute on a connector is its `actions`: a flow\'s `connector_action` node ' - + 'resolves the registered handler and awaits it, so an author who needs data ' - + 'actually moved drives it from there. Per-field value ' - + 'transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied ' - + 'row by row by the REST import path and recorded key by key in ' - + '`packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has ' - + 'NO replacement is multi-source, multi-stage movement with joins and aggregations — ' - + 'because it never had an implementation either. It returns through the ENFORCE route: ' - + 'the engine first, the vocabulary second)', - reason: - 'The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its ' - + 'automation copy deleted as dead), re-measured one layer up and ' - + 'identical: narrative-only. No engine ever parsed, scheduled or executed an ' - + '`ETLPipeline`. Measured on origin/main immediately before the removal: the only ' - + 'non-spec references in this repo are two fumadocs-generated documentation sources ' - + '(`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there ' - + 'is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading ' - + 'on it — while the same file family\'s EXECUTED half does have one ' - + '(`liveness/mapping.json`), which is the contrast that makes the absence meaningful ' - + 'rather than an oversight. The `etl` string in this registry was the one untested ' - + 'link the finding named, and it is not a loader path: it was the id of the ' - + 'retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the ' - + 'retry convergence had not covered), absorbed here. ' - + 'The layer was ADR-0078\'s asymmetry in its purest form — an author could write a ' - + 'complete ten-stage pipeline, get no error, and get no execution. It was also ' - + 'advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the ' - + 'recommended destination for authors displaced by the L1 retirement and ' - + 'listed ten transformation types with copyable examples down to ' - + '`script | Custom JavaScript/Python`. That document is rewritten in the same change; ' - + 'a retirement whose own doc still recommends the retired layer is self-contradictory, ' - + 'and forwarding L1\'s authors to a second layer with no executor was the defect ' - + 'compounding rather than closing. ' - + '⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the ' - + '`activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an ' - + 'earlier tombstone go with the shape that carried it: both land in the unreleased ' - + 'protocol 17, so composed, a ' - + 'rename of `retry.maxAttempts` on a shape that does not survive the major has no ' - + 'observable effect — and keeping both would tell an upgrader to rewrite a key on a ' - + 'schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes ' - + 'with the shape that carried it, which is strictly stronger than the tombstone: there ' - + 'is no longer a `retry` block to author the key into. Route 3 — no carrier key, no ' - + 'parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this ' - + 'entry are the declaration. ADR-0049, ADR-0078.', - acceptanceCriteria: - 'No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, ' - + '`ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, ' - + '`ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, ' - + '`ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or ' - + 'the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 ' - + 'on any that survives. Every author who was pointed at L2 has been re-pointed by ' - + 'name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends ' - + '`ETLPipeline` as L1\'s destination and no longer advertises a transformation-type ' - + 'table. The surviving layers still parse unchanged — a connector declaring ' - + '`syncConfig` and an import declaring `mapping.transform` both behave exactly as they ' - + 'did in 16.x.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts b/packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts deleted file mode 100644 index 98c872120fd..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'export-axis-opt-in', - surface: - 'security.permissionSet.objects[].allowExport (ABSENT — a permission set that never ' - + 'declared the key)', - replacement: - 'an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every ' - + 'permission set whose holders are meant to keep exporting', - reason: - 'A secure-default FLIP, not a shape change — the same class as ' - + '`rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and ' - + '`action-descriptor-resume-authority-default-flip`, and it is registered for the same ' - + 'reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will ' - + 'tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset ' - + 'inherited read; from 17 it denies. Reading a record and taking a bulk ' - + 'machine-readable copy of the whole table are different privileges — Salesforce ' - + '"Export Reports", Dynamics "Export to Excel", NetSuite "Export Lists" and SAP `S_GUI` ' - + '61 all separate them — and the axis now says so. This cannot be a mechanical ' - + 'conversion in either direction: writing `allowExport: true` wherever the key is absent ' - + 'would preserve today\'s behaviour while silently defeating the entire point of the ' - + 'flip, and writing `false` would revoke a capability the deployment may legitimately ' - + 'want. Whether a given set\'s holders SHOULD be able to take a bulk copy is exactly the ' - + 'segregation-of-duties judgement the axis exists to make explicit, and it belongs to ' - + 'the operator. Two details decide who is actually affected: package-shipped sets are ' - + 're-seeded on upgrade, so the built-ins are handled — at 17.0.0 `admin_full_access` and ' - + '`organization_admin` carried the grant explicitly, ON A `*` WILDCARD, and protocol 18 ' - + 'REMOVES it (see `admin-export-wildcard-removed`: the wildcard made the axis undeniable ' - + 'for an org admin, so from 18 an admin exports only what an app set grants — do not read ' - + 'this clause as a standing promise that the built-ins keep exporting) — while ' - + 'ENVIRONMENT-AUTHORED sets ' - + 'are not and must be edited by hand. `member_default` deliberately does NOT carry the ' - + 'grant, so ordinary authenticated users lose export until an admin grants it; that is ' - + 'the point of the flip, not an oversight. Merge semantics are unchanged and ' - + 'most-permissive, exactly like the CRUD bits: any set granting `true` grants export, ' - + 'and `false` is authoring intent rather than a veto, because permission sets are ' - + 'additive capability containers (ADR-0090). The super-user bits no longer confer it: ' - + '`viewAllRecords` / `modifyAllRecords` are "may see all data", not "may take a bulk ' - + 'copy". Registered (backfilled) by the stock reconciliation that compared the breaking ' - + 'changesets already on the v17 release train against this ledger: the export axis, and its ' - + 'extension to the CSV attachments scheduled reports mail out, both predate the gate that makes ' - + 'a breaking changeset state its ADR-0087 disposition. ADR-0087.', - acceptanceCriteria: - 'Every environment-authored permission set has been READ and decided, not just parsed: ' - + 'each object entry whose holders should keep exporting carries `allowExport: true`, and ' - + 'each one that should not is knowingly left without it. The verify loop is behavioural, ' - + 'because nothing fails at parse time — sign in as a holder of each affected set and ' - + 'confirm an export either succeeds or is refused as intended, including the report ' - + 'export path, which this change brought under the same axis. ⚠️ Silence is not success ' - + 'here: a deployment that upgrades without editing anything is VALID metadata whose ' - + 'ordinary users have quietly lost export, and the first sign will be a user report ' - + 'rather than an error. Check `member_default` explicitly — it is the set most likely to ' - + 'have been carrying export by inheritance.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts b/packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts deleted file mode 100644 index a19d239c22b..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'export-field-meta-constraints-retired', - surface: - '@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / ' - + '.min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, ' - + 'reached as `PreparedImport.metaMap` from `prepareImportRequest`)', - replacement: - 'the object schema you already hold — read `fields[name].required` / `.system` / ' - + '`.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off ' - + 'the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ' - + 'ENGINE reads them and therefore the only copy that cannot drift', - reason: - 'ADR-0049 enforce-or-remove. These eight were never a source of truth: ' - + '`buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller ' - + 'passed in, so the map carried a second copy of facts the caller already held. ' - + "They existed for exactly one consumer — the import dry run's hand-copied " - + 'pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, ' - + 'added when the dry run was found skipping the field-level validation the real write ran) ' - + '— and the maintainer\'s 2026-08-06 ruling D (a validate-only protocol operation, so the ' - + 'dry run\'s prediction is the engine\'s verdict by construction) retired that mirror: the dry run ' - + "now asks `DataProtocol.validateData` for the engine's verdict, which reads the " - + "object's own schema. That left all eight computed on every import and read by " - + 'NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint ' - + 'vocabulary standing next to the presentation one with no enforcer behind it is ' - + 'precisely the thing an AI-authored consumer mistakes for a contract. Verified ' - + 'zero-reader before removal, per key and by type, across this repo (`packages/rest` ' - + 'itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, ' - + "verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's " - + 'identity import forwards `prepared.metaMap` into `runImport` but reads only the ' - + 'presentation keys through `coerceRow`. ' - + 'Why this needs a ledger entry despite that sweep: it is the `findStream` / ' - + '`IStorageService.list` / `actor-user-roles-to-positions` ' - + 'disposition — a published TS surface with NO spec schema, so there is no ' - + '`retiredKey()` tombstone and no parse rejection that could carry a prescription, ' - + 'and the ledger is the only channel that reaches an upgrader. It is if anything ' - + 'blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` ' - + '14.5.0) and have been published in every release since, and because they were ' - + 'OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading ' - + '`meta.required` after the upgrade gets `undefined` with no error at all — tsc ' - + 'reports at the read site only for a typed consumer. ' - + 'Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored ' - + 'or stored metadata changes shape — `required` / `min` / `maxLength` and the rest ' - + 'remain fully authorable on a field definition and fully enforced by the engine, ' - + 'which is where they always lived. The only place these eight are ever spelled is ' - + "inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can " - + 'reach them. ADR-0049 / ADR-0087; this is the removal the dry-run change deliberately ' - + 'deferred to a sweep of its own.', - acceptanceCriteria: - 'No code of yours reads any of the eight off a `buildFieldMetaMap` / ' - + '`prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / ' - + '`.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an ' - + '`ExportFieldMeta`-typed value; each hit moves to the object schema you already ' - + 'passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, ' - + 'so an untyped or `any`-typed read compiles clean and silently becomes `undefined` ' - + '— assert that the constraint your code acts on is still observed on a real import, ' - + 'not merely that the build is green. Note `hasDefault` has no one-to-one ' - + "replacement key: it was the derived predicate `defaultValue != null`, mirroring the " - + "engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply " - + 'that same `!= null` test yourself.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts b/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts deleted file mode 100644 index 02776520e91..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'external-lookup-message-queue-families-retired', - surface: - 'data.externalLookup / data.externalDataSource / data.externalFieldMapping ' - + '(the whole of data/external-lookup.zod.ts — 3 defs, 8 exported names) and ' - + 'system.messageQueue (the whole of system/message-queue.zod.ts — ' - + 'MessageQueueConfig, MessageQueueProvider, TopicConfig, ConsumerConfig, ' - + 'DeadLetterQueue — 5 defs, 14 exported names)', - replacement: - '(removed — there is no replacement key, because there was never a key: neither ' - + 'family was reachable from any metadata-type binding, stack collection or /meta ' - + 'door, so no document could carry either. For external data: `object.external` ' - + '(`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference ' - + 'and connection credentials live in the datasource config — never inline in ' - + 'object metadata; `data/external-catalog.zod.ts` is that federated path\'s ' - + 'catalog surface and is untouched. For message queues: the LIVE surface is ' - + '`kernel/events/integrations.zod.ts`\'s `EventMessageQueueConfig` ' - + '(`EventBusConfig.messageQueue`), which deliberately carries NO credential ' - + 'field — broker connection and SASL credentials are runtime deployment ' - + 'configuration, not authorable metadata. Either capability returns via the ' - + 'ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin ' - + 'service first, the vocabulary second)', - reason: - 'Both families are the verdict of the 2026-08-12 census of spec schemas that ' - + 'permit inline credentials (fork (b): no `sys_metadata` door reaches them; ' - + 'accepted 2026-08-12): ' - + 'security-shaped declared surface with inline-credential sinks and ZERO ' - + 'consumers. `ExternalDataSourceSchema.authentication.config` is a record of ' - + 'unknown whose own docblock example wrote `"clientSecret": "..."` inline, and ' - + '`MessageQueueConfigSchema.sasl.password` was a required inline broker ' - + 'credential — the class of the `sys_metadata` cleartext-sink finding ' - + '(cleartext-at-rest credential sinks), except that unlike its two measured ' - + 'surfaces (driver config and connector `authentication`) nothing ever ' - + 'persisted these: no ' - + 'metadata-type binding (kernel/metadata-type-schemas.ts imports neither ' - + 'module), no stack collection, no object/field embedding (`object.external` ' - + 'binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/' - + 'columnMap, no authentication), and zero imports outside packages/spec ' - + 'repo-wide, with the corpus-reach control (`DatasourceSchema` under identical ' - + 'exclusions) returning hits in the same run. The consumed MQ near-namesake ' - + '`kernel/EventMessageQueueConfig` deliberately has no credential key, so the ' - + 'consumed shape had no credential and the credential-bearing shape had no ' - + 'consumer. A dead schema minus one field is still a dead schema, so the whole ' - + 'declarations go, not just the credential faces (the lesson of the plugin ' - + 'sandboxing config that was never wired to anything: an exported schema ' - + 'with no consumer reads as a capability to whoever finds it — here it read as ' - + 'an invitation to author secrets in cleartext). With no carrier key there is ' - + 'nothing to tombstone and no source or `sys_metadata` row for a D2 conversion ' - + 'to rewrite: route 3, the shape of the earlier removals of the dynamic ' - + 'plugin-loading family, the `ui/` interaction configs, the widget / i18n ' - + 'shapes and the sweep of five declared-but-inert surfaces — ' - + 'RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ' - + '⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping ' - + 'transform retirement (one of that retirement\'s three spellings; the whole ' - + 'transform union left because no runtime ever executed any of its five ' - + 'members) is SUBSUMED by the def retirement, the ' - + 'WidgetManifest.performance way: it goes with the shape that carried it. The ' - + 'base `shared/FieldMapping` tombstone and the `integration/' - + 'ConnectorFieldMapping` spelling are untouched and still reject `transform` ' - + 'with that retirement\'s prescription. ' - + '⚠️ The reopen trigger of the maintainer\'s 2026-08-12 ruling on the ' - + 'cleartext sink — it closed each artefact\'s contract (Option A) and parked ' - + 'the class-level `sys_metadata` write-boundary guard (Option B) until "a ' - + 'third measured artefact-type surface" — is NOT met by this census: that ' - + 'guard stays parked; this is the ADR-0049 leg of the fork the ' - + 'triage pre-agreed.', - acceptanceCriteria: - 'No code imports `ExternalLookup(Schema|Parsed)`, `ExternalDataSource(Schema)`, ' - + '`ExternalFieldMapping(Schema|Parsed)`, `MessageQueueConfig(Schema|Parsed)`, ' - + '`MessageQueueProvider(Schema)`, `TopicConfig(Schema|Parsed)`, ' - + '`ConsumerConfig(Schema|Parsed)` or `DeadLetterQueue(Schema|Parsed)` from ' - + '`@objectstack/spec`, `@objectstack/spec/data` or `@objectstack/spec/system` — ' - + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved ' - + 'symbol identity in `data/external-lookup-retirement.test.ts` and ' - + '`system/message-queue-retirement.test.ts`). No metadata document needs ' - + 'editing, because none could ever carry one of these shapes. ' - + '`kernel/EventMessageQueueConfig` (with its inline provider enum and no ' - + 'credential key), `data/external-catalog.zod.ts`, `object.external` and ' - + '`kernel/DeadLetterQueueEntry` survive unchanged.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts b/packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts deleted file mode 100644 index ab923ba76cc..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'field-runtime-create-withdrawn', - surface: 'PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)', - replacement: - 'Author the field inside its object and write the whole object — ' - + 'PUT /api/v1/meta/object/{object} with the new field in `fields` — ' - + 'or declare it in the object source (`**/*.object.ts`) and redeploy', - reason: - 'The `field` registry entry declared `allowRuntimeCreate: true` and the platform never ' - + 'built a read path for it. Measured end-to-end through the real HttpDispatcher -> ' - + 'ObjectStackProtocolImplementation -> SysMetadataRepository: ' - + "`PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with " - + '{"success":true,"state":"active","message":"Saved field …"}, the row persisted, and ' - + '`GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with ' - + 'zz_probe ABSENT — forever. The row is even self-readable by name ' - + '(`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which ' - + 'makes it well-formed and universally inert rather than malformed. The seam is that ' - + '`field` is the ONE declared type with no standalone existence: fields are authored ' - + 'inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ' - + "('field','.'), and nothing composes fragment rows into their parent — " - + "`applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` " - + '(`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot ' - + 'honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 ' - + 'rather than build the read path, which is a feature spanning at least three packages ' - + '(a composition step that does not exist, ~20 `gate.fields` call sites, physical ' - + 'schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a ' - + 'separate card — implementation first, declaration second. ' - + '⚠️ This is NOT the `api` withdrawal\'s rationale reused (`api-runtime-create-withdrawn`): ' - + 'that ruling rested on "zero ' - + 'business pull", and "add a field" is the opposite — a core Studio/CRM operation. The ' - + 'justification here is that the operation REMAINS AVAILABLE on the route that actually ' - + 'composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, ' - + 'broken SPELLING of adding a field, not the ability to add one. ' - + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored ' - + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an ' - + 'authorable one, and no authored source changes — an `**/*.object.ts` file valid before ' - + 'this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, ' - + 'so it is one semantic TODO for operators and Studio callers rather than a stack ' - + 'conversion — the same disposition `api` and `BatchOptions.validateOnly` take. ' - + 'ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No caller creates a standalone `field` item through the runtime metadata API. ' - + '`PUT /api/v1/meta/field/{object}.{name}` answers 403 with `code: "NOT_CREATABLE"` and ' - + 'a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the ' - + 'prescription `PUT /api/v1/meta/object/:object with the new field in `fields``. The ' - + 'plural spelling `PUT /api/v1/meta/fields/{object}.{name}` folds onto the singular ' - + 'and earns the same refusal — verify it, because it was a separate door until ' - + '2026-08-12. ⚠️ Verify the OBJECT route is UNAFFECTED, which is the whole point of the ' - + 'change: `PUT /api/v1/meta/object/{name}` with a new entry in `fields` still answers ' - + '200, and `GET /api/v1/meta/object/{name}` READS THE NEW FIELD BACK (assert on ' - + '`body.data.item.fields`, not `body.item`, which is undefined and makes an empty read ' - + 'look like a pass). Assert a DECLARED field is present in the same response, so a dead ' - + 'read cannot be what makes the check pass. ' - + '⚠️ The field overlay refusal is untouched and must stay: overwriting a field a code ' - + 'package ships is still 403 `NOT_OVERRIDABLE`, a different gate for a different ' - + 'question — making field OVERRIDES legal was never part of this decision. ' - + 'DISPOSITION OF EXISTING ROWS: `field` rows already written through the retired channel ' - + 'stay in `sys_metadata` and are INERT — they were inert before this change too, since ' - + 'no read path ever composed them into an object, so nothing that used to work stops ' - + 'working and no data is silently reinterpreted. They remain self-readable by name and ' - + 'still report `_diagnostics.valid: true`, which asserts only that the isolated document ' - + 'is well-formed (the envelope has no "in effect" axis). They may be deleted ' - + 'at leisure: `deleteMetaItem` is deliberately NOT gated by this refusal, so repair stays ' - + 'possible. An operator who needs the write door back on one deployment sets ' - + '`OS_METADATA_WRITABLE=field`; note this unlocks the WRITE only — the field still will ' - + 'not reach its object, which is why it is a diagnostic and not a workaround.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts b/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts deleted file mode 100644 index 635650a1d90..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'filter-regex-options-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'data.filter $regex / $options — in a STORED filter (dashboard widget filter and ' - + 'globalFilters, report runtimeFilter, page and component filter, solution-blueprint ' - + 'filter), and equally in the where clause of a query request', - replacement: - '$icontains for the case-insensitive substring match this was almost always used ' - + 'for, or $contains for a case-sensitive one — a pattern that genuinely needs a ' - + 'regular expression has no filter-level replacement', - reason: - 'Like `driver-aggregate-undeclared-key-aliases-removed` and ' - + '`driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being ' - + 'withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` ' - + 'and never a key on `StringOperatorSchema`. That is measured, not assumed — `git ' - + 'log -S\'$regex\'` over `packages/spec/src` returns only doc comments describing how ' - + '`$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: ' - + '$regex`), plus the retirement\'s own contract-half change, which added the name ' - + 'solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the ' - + 'disposition, so a reader should not have to infer it: those were driver CALL ' - + 'ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. ' - + '`FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) ' - + 'because a filter key is a field name, so a stored `{ name: { $regex: \'acme.*\' } }` ' - + 'parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open ' - + 'map, which is exactly why the ledger has to carry this. What such a stack used to ' - + 'get was four different answers from four backends: `driver-sql` and Turso\'s remote ' - + 'transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal ' - + '`a.b` and the regex was silently never a regex), `driver-memory` and objectql\'s ' - + '`having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an ' - + 'INVALID pattern was caught and answered `false` — zero rows, in silence), and ' - + '`driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. ' - + 'It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. ' - + 'There is deliberately NO D2 conversion and this sits in `semantic` rather than among ' - + 'the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in ' - + 'either direction — a regex metacharacter becomes a literal — so an auto-applied ' - + 'rewrite would silently change which rows a dashboard, report or permission filter ' - + 'selects, a wrong number rather than a missing one. Choosing the substring the ' - + 'pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers ' - + 'BOTH HALVES of the maintainer\'s 2026-08-06 ruling (option B: retire `$regex` loudly ' - + 'and add `$icontains`, rather than make five backends agree on one regex dialect), not ' - + 'just the driver one: the contract half ' - + '(the `$icontains` declaration, the `$contains` family pinned ' - + 'case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ' - + 'gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was ' - + 'never asked for a ledger entry; ' - + 'the driver half is where the refusal became executable. One surface, one ' - + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No stored filter and no request `where` spells `$regex` or `$options` — grep the ' - + 'stack for both. Each one is rewritten by asking what the pattern MEANT, not by ' - + 'transliterating it: a bare substring pattern becomes `$icontains` (or `$contains` ' - + 'when the match must stay case-sensitive), and its metacharacters are dropped rather ' - + 'than escaped, because they were never honoured as a regex on the SQL family in the ' - + 'first place. ⚠️ Expect the answer to CHANGE on any stack that ran on ' - + '`driver-memory`, `driver-mongodb` or objectql `having`, where the pattern really was ' - + 'evaluated as a regular expression; on the SQL family the rewritten filter returns ' - + 'what it always returned. A pattern that genuinely needs alternation, anchoring or ' - + 'character classes has no filter-level replacement — move that predicate into a ' - + 'formula field or a server-side view, or open an issue for it. Verify by loading the ' - + 'stack: a surviving `$regex` or `$options` is answered INVALID_FILTER / 400 with a ' - + 'message naming the replacement, on every backend.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts b/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts deleted file mode 100644 index ccccb64271b..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -// `etl-retry-converged-onto-retry-policy` (#4962) was registered in this step and -// ABSORBED by `etl-pipeline-layer-retired` (#6414), the §0 same-major -// rule: both land in the unreleased protocol 17, and composed, the rename -// `ETLPipeline.retry.maxAttempts` -> `maxRetries` has no observable effect -// because the shape carrying it does not survive the major. Leaving both -// would tell an upgrader to rewrite a key on a schema this same upgrade -// deletes, and would break the fixture-disjointness the replay contract -// asserts. The `agent.knowledge` / `WidgetManifest.performance` precedent: -// a tombstone goes with the shape that carried it, which is strictly -// stronger than the tombstone. -export const entry: SemanticMigration = { - id: 'flow-retry-max-retries-required', - surface: "flow.errorHandling.maxRetries (under strategy: 'retry')", - replacement: 'an explicit count >= 1 (e.g. maxRetries: 3), or strategy: \'fail\'', - reason: - 'maxRetries had two defaults — FlowSchema `.default(0)` and the engine\'s ' + - '`maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 ' + - 'times through a hand-built definition. With the engine\'s copy removed the ' + - 'unstated count is unambiguously 0, and retrying zero times is exactly ' + - "`strategy: 'fail'`, so the schema now refuses the combination instead of it silently " + - 'doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow ' + - 'got but contradicts what its author wrote, and any positive count is a NEW decision ' + - 'about re-running the whole flow with its side effects. That choice is the author\'s.', - acceptanceCriteria: - "Every flow declaring `errorHandling.strategy: 'retry'` also declares " + - '`maxRetries` >= 1, and each count was chosen knowing a retry replays the flow FROM ' + - 'THE START (records re-created, callouts re-fired); flows that never actually wanted ' + - "retries say `strategy: 'fail'`. No flow fails to register with the maxRetries " + - 'prescription.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts b/packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts deleted file mode 100644 index a75cfcb3e2b..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'hook-context-session-roles-retired', - surface: 'data.hookContext.session.roles', - replacement: - '(removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the ' - + 'security service, which reads `permissions` / `positions` / posture off the ' - + 'execution context, ADR-0095 D3)', - reason: - 'Declared on the runtime hook context, read by exactly two consumers, produced by ' - + 'nobody. The two readers were the approvals record lock and the delegation write ' - + 'guard, each opening with `session.roles?.includes(\'admin\')`; ObjectQL\'s ' - + '`buildSession()` builds the session field by field and has never written `roles`, ' - + 'and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud\'s hook ' - + 'consumers read `hookContext?.session?.userId`; objectui\'s `roles` are the ' - + '`/auth/me` user payload, a different surface; an ACTION body\'s `ctx.session` is a ' - + 'different untyped object that does carry `roles`, tracked apart and unaffected). ' - + 'Both branches were therefore dead on ' - + 'every real engine path — an authorization decision in shape only, and a second admin ' - + 'dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. An earlier fix removed ' - + 'both readers, returning the record lock and the delegation guard to the one permission ' - + 'vocabulary; this removes the declaration, per ADR-0049 enforce-or-remove. ' - + 'This is a RUNTIME context, not stored metadata: the engine builds a HookContext per ' - + 'operation and nothing persists one, so no `sys_metadata` row, example or template ' - + 'can carry the key and there is no source for the D2 chain to rewrite — the ' - + '`openApi31` / `activationEvents` shape, one semantic TODO rather ' - + 'than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately ' - + 'not `.strict()` — a plain delete would strip it silently, as a removed field key was ' - + 'measured to be, ADR-0104), so a ' - + 'consumer that parses a context it was handed still meets the prescription. ' - + 'ADR-0049.', - acceptanceCriteria: - 'No hook reads `ctx.session.roles`; caller gating uses `ctx.session.userId` / ' - + '`ctx.session.isSystem`, and privilege comes from the security service ' - + '(`permissions` / `positions` / posture). Constructing a HookContext session with ' - + '`roles` fails `tsc` (the input type is `never`) and fails `HookContextSchema.parse` ' - + 'with the retirement prescription instead of being silently stripped. Nothing ' - + 'regresses at runtime: the key had no producer, so no decision anywhere ever saw a ' - + 'value in it.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts b/packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts deleted file mode 100644 index 3996b301797..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'hook-register-empty-object-target-refused', - surface: - "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose " - + '`excludeObjects` cancels its `object` entirely', - replacement: - "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for " - + "a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen " - + '`object` or drop the overlapping names from `excludeObjects`', - reason: - 'An earlier breaking fix established that an empty hook target is not "no target" and ' - + 'closed the shape at the ' - + "two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s " - + '`normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so ' - + 'all three spellings still registered, each producing a defect the author did not ' - + "write: `''` is FALSY, so the allow face was skipped entirely and the entry became a " - + "GLOBAL hook (that fix's headline failure mode — blank intent taking the broadest " - + "possible blast radius); `[]` and `['']` are truthy but admit no object name, so the " - + 'entry could never fire. The later `excludeObjects` face (a hook global except for the ' - + 'objects it names) then brought a ' - + 'fourth shape reached by arithmetic rather than by one bad name: an `object` list ' - + 'every member of which is also excluded admits nothing, so that entry can never fire ' - + 'either. All four are ADR-0078 silently-inert declarations, and all four are now ' - + 'refused at REGISTRATION.\n\n' - + 'No mechanical rewrite exists, in either direction. The refused values carry no ' - + "recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a " - + 'specific object name the author forgot to fill in, and those are opposite ' - + 'registrations; choosing between them is a judgment the chain cannot make. Nor could ' - + "the MATCHING read be changed instead: teaching the matcher that `''` is an " - + 'unmatchable name would silently convert a hook firing on every object into one ' - + 'firing on none — the same class of defect pointing the other way, which is why ' - + 'the `excludeObjects` change declined to do it in passing.\n\n' - + 'This is a RUNTIME registration API, not stored metadata, so — like ' - + '`hook-context-session-roles-retired` at this step — there is no `sys_metadata` row ' - + 'for the D2 chain to rewrite and the ledger entry is the notification channel. One ' - + 'metadata surface reaches it INDIRECTLY and is the reason this is not purely a ' - + "code-side note: a `record-change` flow's start node forwards `config.objectName` " - + 'verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with ' - + 'a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now ' - + "fails to bind instead, loudly — the automation engine's per-flow bind guard warns " - + 'and the `kernel:bootstrapped` binding audit re-reports it — which is the correct ' - + 'end state, but it is an observable change for that flow. ADR-0078.', - acceptanceCriteria: - 'No `registerHook` call site passes an empty `object` target, and none passes an ' - + '`excludeObjects` list covering every name in its `object` list. Every `record-change` ' - + 'flow start node declares a non-blank `config.objectName`, or omits the key if the ' - + 'flow is genuinely meant to fire on every object. Boot completes with no ' - + '"[ObjectQL] Hook ... declares an empty `object` target" throw and no ' - + '"[record-change] ... not bound" warning naming a flow you expect to fire.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts b/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts deleted file mode 100644 index 25e185f655b..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'http-request-errors-total-retired', - surface: - 'observability.SEMCONV.httpRequestErrorsTotal (the published metric name ' - + 'http_request_errors_total{method,route}, and its emission from the runtime ' - + "dispatcher's per-route wrapper)", - replacement: - 'the 5xx rate is `http_requests_total{status=~"5.."}` — the TRANSPORT emits that family ' - + 'through the `IHttpServer.afterResponse` seam, so it covers every inbound surface; ' - + 'unhandled-exception rate specifically, which is the one thing the retired counter ' - + 'uniquely reported, is the `errorReporter` (Sentry / Datadog / your adapter), which ' - + 'still fires on every 5xx throw', - reason: - 'ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` ' - + 'published `http_request_errors_total` as part of a stable namespace declared "so ' - + 'hosts can wire alerts/dashboards against it", but the only emitter was ' - + '`@objectstack/runtime`\'s `instrumentRouteHandler`, applied only by the dispatcher\'s ' - + 'own route Proxy — so the series never saw auth\'s `getRawApp()` mount, the REST data ' - + 'API via `RouteManager`, or any other inbound surface. Its two siblings in the same ' - + 'family were moved to the transport seam (the request counter, then the latency ' - + 'histogram, both through the response-observing hook the transport was given) and this ' - + 'one could not follow: `HttpResponseObservation` carries ' - + '`{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every ' - + 'transport-side shape would have counted a DIFFERENT population rather than the same ' - + 'one more widely. The divergence was measured in both directions — the dispatcher ' - + 'answers its own errors through `errorResponseBase`, which sets a status and does not ' - + 're-throw, so the old counter MISSED those, while its `catch` incremented ' - + 'unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` ' - + 'already carries a `status` label, so a status-class error counter would be fully ' - + 'derivable from data the transport already publishes. Maintainer ruling 2026-08-20 ' - + '(option C of four presented, over B "move it to the transport as a status class" and ' - + 'D "keep it dispatcher-scoped and rename it"): RETIRE. A metric NAME is a RESPONSE ' - + 'surface, not authorable metadata — no stack, example or template carries it, so there ' - + 'is no source for a D2 conversion to rewrite and no schema to tombstone; a host names ' - + 'the series in its own dashboard or alert file, outside this repo. That is exactly why ' - + 'this entry exists: for an operator whose Grafana keys on the string, the ledger is the ' - + 'only notification channel there is. Same disposition, and the same reason, as ' - + '`runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ' - + 'ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No dashboard, alert rule or exporter config names `http_request_errors_total`: the ' - + 'series stops receiving samples the moment 17.2.0 is deployed, so a panel keyed on it ' - + 'draws a flat zero that reads as a healthy server rather than as a removed metric — ' - + 'the one failure mode this retirement can produce, and the reason the changeset ' - + 'announces it loudly. A 5xx-rate panel or alert is rewritten to ' - + '`http_requests_total{status=~"5.."}` and then PROVEN wider, not merely non-empty: ' - + 'make an auth route or a REST data-API route answer 5xx and confirm the new query ' - + 'moves, where the retired counter would not have moved at all. If the signal you were ' - + 'actually alerting on was "a handler threw rather than returning an error envelope", ' - + 'that is the `errorReporter`, not a counter — wire an APM adapter and assert one ' - + 'synthetic 5xx throw arrives. In code, `SEMCONV.httpRequestErrorsTotal` and ' - + '`RUNTIME_METRICS.httpRequestErrorsTotal` no longer resolve (tsc reports TS2339 at any ' - + 'surviving read) and no `metrics.counter` call names the string.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts b/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts deleted file mode 100644 index 84c5736db4f..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'http-server-runtime-vocabulary-retired', - surface: - 'system.serverEvent / system.serverEventType / system.serverCapabilities / ' - + 'system.serverStatus (the lifecycle-event, capability-report and status vocabulary of ' - + 'system/http-server.zod.ts — 4 defs, 8 exported names)', - replacement: - '(removed — there is no replacement key, because there was never a key. Server lifecycle ' - + 'is the transport plugin\'s own start/stop seam; per-request and per-server ' - + 'observability is `system/metrics.zod.ts` and `system/logging.zod.ts` (plus ' - + '`OS_SERVER_TIMING` for timings), and liveness is the `/health` endpoint. What a ' - + 'transport plugin can DO it states by implementing the kernel plugin contract — the ' - + 'seams it registers are the capability statement, and a self-described capability ' - + 'record can only disagree with them. Server-level configuration that IS authorable ' - + 'lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)', - reason: - 'The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed ' - + 'the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring ' - + 'entry); this removes the RUNTIME half — a 7-member lifecycle event union with a ' - + 'timestamped envelope, an eight-boolean capability report, and a five-state status ' - + 'record with connection and request counters. Nothing ever emitted, consumed or ' - + 'parsed any of them. ' - + 'This card was HELD for four days rather than queued, on a specific and legitimate ' - + 'doubt: a response/capability vocabulary can be a REFERENCE surface for host ' - + 'implementers, so "zero consumers in this repo" is weaker evidence for one of those ' - + 'than for an authorable key (the CSS-variable rebuttal). The hold was lifted by ' - + 'measuring the reference reader itself rather than by re-running the same grep: ' - + '`plugin-hono-server`, the one in-tree host implementation, neither implements nor ' - + 'reports any of the three — it names no capability record, no status shape and no ' - + 'event union, and what it registers is routes and middleware through the kernel ' - + 'plugin contract. A declaration-site grep put every declaration in this one file, a ' - + 'quoted-name sweep across objectstack and objectui found no reader outside it, and ' - + 'the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, ' - + 'resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in ' - + 'this file when there was one. ' - + 'With no carrier key there is nothing to tombstone, and with no author there is no ' - + 'source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR ' - + 'plus this entry are the declaration — route 3, the same shape as the config half\'s ' - + 'removal in this very file and the earlier removals of the dynamic plugin-loading family, ' - + 'the `ui/` interaction configs and the widget / i18n shapes. If host-implementer ' - + 'conformance becomes a real ' - + 'requirement it returns through the ENFORCE route: an adapter contract with a checker ' - + 'behind it, vocabulary second. ADR-0049.', - acceptanceCriteria: - 'No source imports `ServerEvent`, `ServerEventType`, `ServerEventSchema`, ' - + '`ServerCapabilities`, `ServerCapabilitiesSchema`, `ServerCapabilitiesParsed`, ' - + '`ServerStatus` or `ServerStatusSchema` from `@objectstack/spec/system` — a grep over ' - + 'consumer code resolves none of them, and `tsc` reports TS2724/TS2305 on any that ' - + 'survives. The route-registration half of the same module still resolves ' - + '(`RouteHandlerMetadataSchema`, `MiddlewareType`, `MiddlewareConfigSchema`, ' - + '`MiddlewareConfig`), and `StackServerConfigSchema` — the one authorable server ' - + 'surface — is untouched: a stack declaring `server: { trustProxy, security }` parses ' - + 'exactly as it did in 16.x.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts b/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts deleted file mode 100644 index a443ce5620d..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'import-run-automations-declared-default-corrected', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). - surface: - 'api.ImportRequest runAutomations — the declared default of the key on BOTH import ' - + 'bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST ' - + '/api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema ' - + 'object). It was declared default(false) and described as "off by default for ' - + 'bulk"; it is now default(true), which is what the server has always done', - replacement: - 'an explicit runAutomations: false on any import request that is meant to load rows ' - + 'without firing triggers/hooks. That spelling is unchanged and has always been the ' - + 'only one the server read — what changes is that omitting the key now DECLARES what ' - + 'it already DID. Callers who want automations on need write nothing', - reason: - 'A DECLARATION corrected to match a runtime that did not move — the inverse of a ' - + "behaviour flip, and registered here for the reason protocol 12's " - + '`rest-requireauth-default-flip` and this major\'s ' - + '`action-descriptor-resume-authority-default-flip` are: whether a given import was ' - + 'meant to fire triggers is a judgment no transform can make, so the prescription is ' - + 'a TODO rather than a rewrite. The server decides in import-prepare.ts with ' - + '`body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has ' - + 'since the flag was first honoured — automations always ran on import historically ' - + '(the engine ignored the flag entirely before then), ' - + 'so opt-out was made the explicit act, matching ' - + 'platform convention. The schema said the opposite in both machine-readable and ' - + "human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s " - + 'JSON Schema, and the describe prose in the published reference tables for both ' - + 'defs. ' - + '⚠️ Nothing in this repo reconciled the two and NO deployed caller changes ' - + 'behaviour: no request path parses an import body through this schema — the route ' - + 'reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is ' - + 'the declarative `ImportJobApiContracts` catalog entry, a declaration and not a ' - + 'parse. That is exactly why this needed a ruling rather than a docs edit: the ' - + 'divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. ' - + 'A client or SDK that validated its request through the published schema ' - + 'materialised `runAutomations: false` from the declared default and sent it ' - + 'explicitly, and the server honoured it — so the same request body produced ' - + 'opposite behaviour depending on whether the caller validated before sending, with ' - + 'the validating caller silently losing its triggers. Nothing rejected it, nothing ' - + 'warned, and the reference page told an author the wrong thing in the other ' - + 'direction. There is deliberately NO schema tombstone and no D2 conversion: no key ' - + 'is removed, and an HTTP request body is neither authored nor persisted — the same ' - + 'disposition `notification-list-cursor-retired` takes for the sibling ' - + 'default on this major, and `batch-options-validate-only-retired` before it. The ' - + 'declared move itself is recorded mechanically, per key, in ' - + 'DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped ' - + 'default was found invisible to every gate — whose `from`/`to` fingerprints are ' - + 're-derived on every build. Maintainer ruling 2026-08-09, disposition A: ' - + 'the spec follows the runtime. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'Every import request of yours that must NOT fire triggers sends `runAutomations: ' - + 'false` explicitly, rather than omitting the key and trusting the old declared ' - + 'default. The check is worth doing precisely where it looks unnecessary: if you ' - + 'build the body by parsing it through `ImportRequestSchema` (or the published JSON ' - + 'Schema) and then send the PARSED object, your bulk loads were running with ' - + 'automations OFF and will now run with them ON — that is the only class whose ' - + 'behaviour changes, and it changes toward what an unvalidated caller always got. ' - + '⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as ' - + 'such: a body that omits `runAutomations` fired triggers before this change and ' - + 'fires them after, and `runAutomations: false` turns them off before and after. ' - + 'Nothing starts being refused — the route never validated this body against the ' - + 'schema and does not begin to. `dryRun` is unaffected and still runs NO automations ' - + 'whatever the flag says: it asks the engine\'s validate-only write path for its verdict, ' - + 'and that path deliberately fires no hooks.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts b/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts deleted file mode 100644 index 2a4f0fa325b..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'job-retry-policy-constraints-tightened', - surface: 'job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)', - replacement: 'maxRetries <= 10, and backoffMultiplier >= 1', - reason: - 'The RetryPolicy converged onto one declaration from its automation and system copies ' - + 'keeps the automation side\'s bounds, which the job ' - + 'side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. ' - + 'Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a ' - + 'retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay ' - + 'that SHRINKS on each attempt — retrying a failing dependency ever faster, which is ' - + 'the opposite of backoff and was never a shape the engine meant to offer. Both now ' - + 'fail at parse time with the bound named, rather than being silently reinterpreted. ' - + 'Choosing the replacement count (or accepting the cap) is the author\'s call.', - acceptanceCriteria: - 'Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no ' - + '`backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a ' - + 'retry re-runs the handler with its writes and callouts. No job fails to register ' - + 'with the retry-policy bound prescription.', - relevantWhen: { kind: 'stack-declares', keys: ['jobs'] }, -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts deleted file mode 100644 index 408acc95e9d..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'notification-list-cursor-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). - surface: - 'api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications ' - + '(ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor ' - + 'argument of the client SDK call client.notifications.list(). The same entry covers ' - + 'the limit default: the request schema no longer declares default(20)', - replacement: - 'a larger `limit` — the route answers the newest N notifications and has no page 2. ' - + 'There is no replacement for `cursor`, deliberately: nothing ever minted one, so no ' - + 'caller holds a value to carry over. Callers that looped on it were re-reading the ' - + 'first window and should read one window sized to what they display (the Console ' - + 'bell polls exactly this way). For the removed `limit` default, send the number you ' - + 'want explicitly if you were relying on 20 — omitting it takes the server window, ' - + 'which is 50 on the platform inbox and clamped into 1..200, and has been since ' - + 'before the declaration existed', - reason: - 'One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, ' - + 'Option A, ruled jointly with the repair that made `unreadCount` really count the whole ' - + 'inbox). `cursor` was declared on the request and on ' - + 'the response and honoured on neither: the dispatcher domain reads `read` / `type` / ' - + '`limit` and nothing else, and no emit site has ever written the response key. It ' - + 'was worse than inert because it had a shipped PRODUCER — the SDK appended it to the ' - + 'query string — so a caller paginating by the published contract looped on page 1 ' - + 'forever, with no error and no 400. Measured over a real boot with 60 unread before ' - + 'the removal: page2 === page1, both parsing green against the response schema, which ' - + 'is why no conformance gate could see it. ' - + 'This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the ' - + 'same verdict for the same reason, down to deleting the SDK producer alongside the ' - + 'key. A first-class inbox cursor, if one is ever designed, will be a ' - + 'response-minted opaque token — a different API — so keeping this one preserved a ' - + 'wrong design rather than a roadmap. ' - + 'The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the ' - + 'number: no request path parses a query string through this schema (the fix for ' - + 'request bodies never checked against their declared schemas wired the ' - + "catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never " - + 'stamped anything onto anything, and the server has always applied its own 50. ' - + 'Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a ' - + 'declaration that does not execute and merely made it coincide with the ' - + 'implementation until someone moved the clamp; `.optional()` plus prose is true ' - + 'about both the schema and the server. No constraint (`.int()` / `.max(200)`) is ' - + 'declared either, because the service CLAMPS an out-of-range limit rather than ' - + 'refusing it, and declaring a rejection the wire does not perform is the same defect ' - + 'mirrored. ' - + 'Route 2, and the split is worth stating exactly because the two halves of the ' - + 'bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, ' - + 'so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept ' - + 'sending — a clean parse and a parameter that never takes effect, which is this ' - + "issue's own defect re-created one layer down (the silent strip measured when a field " - + 'key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So ' - + '`cursor` is ' - + '`retiredKey()` on both halves, typed `never` for tsc and raising the prescription ' - + 'at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is ' - + 'NO D2 conversion: a conversion rewrites an authored source or a stored ' - + '`sys_metadata` row, and these two shapes are HTTP-only — nobody authors a ' - + '`ListNotificationsRequest` and nothing persists one. Request AND response shapes: ' - + 'two semantic TODOs for API callers, no stack conversion — the same disposition ' - + '`BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the ' - + '`AnalyticsQueryRequest` envelope keys ' - + 'already take in this major. The `limit` default is declared separately and ' - + 'mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot ' - + 'where a default or constraint change on an authorable key was recorded by no gate), ' - + 'whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes ' - + 'it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess ' - + 'property), which is the enforced channel — the removal is loud at compile time for ' - + 'every TypeScript consumer. Reading `response.cursor` no longer type-checks either, ' - + 'and always answered `undefined` before. ⚠️ Behaviour on the wire is deliberately ' - + 'UNCHANGED and must be verified as such: a request still carrying `?cursor=…` is ' - + 'IGNORED, not refused — the domain reads three named query keys and no route ' - + 'validates this query against a schema, so an unknown key has never produced a 400 ' - + 'and does not start doing so here. The declaration stopped promising what the wire ' - + 'never did; the wire did not change. `unreadCount` is untouched (it was the jointly ' - + 'ruled repair\'s business) and still ' - + 'reports the total across the whole matching inbox rather than the window. A caller ' - + 'that omitted `limit` receives the same 50 rows it always received.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts b/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts deleted file mode 100644 index 6cd25d22a99..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'package-uninstall-explicit-all-tenants', - surface: 'protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)', - replacement: 'explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it', - reason: - 'An uninstall that named no organization matched EVERY organization\'s rows — measured ' - + 'at 5 of 5 deleted, including a foreign org\'s, while uninstall\'s orphaned-row defect was ' - + 'being repaired. That width was never ' - + 'chosen; it fell out of a missing argument, and the two transports of the same route ' - + 'disagreed because of it. In protocol 17 the call is REFUSED instead: neither ' - + '`organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and ' - + 'deletes nothing, as does supplying both (they are contradictory, not redundant). ' - + 'Whether a given caller meant "this tenant" or "every tenant" is an intent no ' - + 'transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an ' - + 'accidental org-less call and a deliberate environment-wide one are byte-identical ' - + 'at the call site — which is the whole reason the parameter had to become explicit ' - + 'rather than conventional. Nothing in authored metadata spells this: it is a runtime ' - + 'call-site contract, so it is one semantic TODO for operators and API callers rather ' - + 'than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) ' - + 'takes for its own default flip.', - acceptanceCriteria: - 'Every caller of `deletePackage` states its tenant scope. A caller that intends an ' - + 'environment-wide uninstall passes `allTenants: true`; a caller that intends a scoped ' - + 'one passes `organizationId`; no caller passes both. An explicit `allTenants: false` ' - + 'is treated as undeclared and refused, since it is not an affirmative request for ' - + 'cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 ' - + '`TENANT_SCOPE_REQUIRED` reaching a deploy script that previously "succeeded" means ' - + 'that script was relying on the cross-tenant reading and must now say so on purpose. ' - + 'The org-scoped path is unchanged — an uninstall carrying an `organizationId` still ' - + 'removes that org\'s rows AND the environment-wide (`organization_id IS NULL`) rows, ' - + 'exactly as the orphaned-row repair left it.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts b/packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts deleted file mode 100644 index efc4ec40690..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'plugin-activation-events-retired', - surface: - 'kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents', - replacement: - '(removed — delete the key. Every plugin activates immediately on load/registration, ' - + 'which is the only behaviour that has ever existed; `activate()` still runs at ' - + 'registration time. Lazy activation, if built, returns via the enforce route of ' - + 'ADR-0049 through a new ADR, with a vocabulary its executor actually honours)', - reason: - 'Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary ' - + 'they embedded (`onCommand` / `onRoute` / … / `onView`, once the kernel and studio ' - + 'copies had converged on the kernel\'s structured `{ type, pattern }` shape) — ' - + 'promised lazy plugin activation ("plugins remain dormant until an activation event ' - + 'fires") that no runtime in objectstack, cloud, cloud-v1 or objectui ever ' - + "implemented: nothing anywhere read the key, every plugin activates immediately, and " - + "cloud-v1's own ROADMAP recorded lazy activation as unimplemented (planned v0.4.0). " - + 'That is the ADR-0049 false-compliance shape in the semantically-lying direction: an ' - + 'author writing `activationEvents: [{ type: \'onMetadataType\', pattern: \'flow\' }]` ' - + 'expected deferral and got eager activation with a clean parse. Neither parent shape ' - + 'is stored metadata — `StudioPluginManifest` is TS configuration parsed by ' - + '`defineStudioPlugin` (a root schema, never part of a stack tree) and ' - + '`DynamicLoadRequest` is a runtime request shape with no caller — so no ' - + '`sys_metadata` row can carry the key and there is no source for the D2 chain to ' - + 'rewrite; this entry is the D3 record. The kernel key is tombstoned via ' - + '`retiredKey()` (its schema is not `.strict()`; a plain delete would strip an ' - + "authored value silently), the studio key is rejected by the strict manifest parse " - + 'with a guidance prescription (as are its former VS Code-flavoured aliases ' - + '`activation` / `events` / `onActivate`), and the orphaned `ActivationEventSchema` / ' - + '`ActivationEvent` exports are removed from `./kernel` and `./studio` with the keys ' - + '(the lesson of the unwired plugin sandboxing / integrity / approval config removed ' - + 'before this: an exported schema with no consumer is read as a capability). Both keys ' - + 'took ADR-0049\'s REMOVE answer, not ENFORCE, while protocol 17 was still unreleased. ' - + 'SUPERSEDED ON THE KERNEL SIDE by the maintainer\'s REMOVE ' - + 'ruling on the rest of the plugin-runtime family (same unreleased major): the whole ' - + '`DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — ' - + 'was removed, which took this key\'s `retiredKey()` tombstone with it. That is ' - + 'strictly stronger than the tombstone, not weaker: there is no longer a ' - + '`DynamicLoadRequest` to author the key INTO, so the prescription an author needs ' - + 'is no longer "delete this key" but "this request shape does not exist" (see ' - + '`plugin-runtime-family-retired`). The studio half of this entry is ' - + 'unaffected and still enforced by the strict manifest parse.', - acceptanceCriteria: - 'No `defineStudioPlugin` input authors `activationEvents` — authoring it is an ' - + 'unknown key on the strict studio manifest and a parse error carrying the ' - + 'prescription. On the kernel side the stronger criterion of the plugin-runtime ' - + 'family\'s removal applies instead: ' - + 'there is no `DynamicLoadRequest` type or schema left to author it into at all. No ' - + 'code imports `ActivationEventSchema` / `ActivationEvent` from ' - + '`@objectstack/spec/kernel` or `@objectstack/spec/studio` (TS2305 after upgrade). ' - + 'Runtime behaviour is byte-identical: plugins loaded eagerly before and after.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts b/packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts deleted file mode 100644 index dc6b9dbb553..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'plugin-manifest-loading-retired', - surface: - 'manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / ' - + 'initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)', - replacement: - 'nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` ' - + 'registers them and the kernel runs `init` then `start` in an order topologically ' - + "resolved from each composed plugin's own `dependencies` / `optionalDependencies` " - + '(`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation ' - + '`loading.sandboxing` appeared to configure, note that the plugin trust tier ' - + '(`manifest.runtime`, ADR-0025 §3.6) does not supply it either: that tier is enforced ' - + 'at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the ' - + '`node` tier is rejected with HTTP 422 and forced to manual review), while load-side ' - + 'enforcement is NOT implemented, so a locally installed plugin is not isolated by the ' - + 'tier it declares. ⛔ Nor do the manifest permission declarations give it back: ' - + 'the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load ' - + 'and queried by nothing, so it refuses no operation. Neither surface confines a ' - + 'plugin today — do not author either one expecting isolation', - reason: - 'ADR-0049 enforce-or-remove; the maintainer ruled REMOVE on 2026-08-04, on condition ' - + 'that a bare-name sweep of cloud and objectui came back clean first. The block declared a ' - + 'complete plugin loading policy and NOTHING read it. A bare-name scan of all three ' - + 'repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), ' - + 'each with a control probe proving the scan saw the tree — put every hit inside ' - + '`packages/spec` itself: this module\'s own declaration, its own unit tests, the ' - + '`Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero ' - + 'readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key ' - + 'parsed, entered the manifest, and changed nothing — an exported schema with no ' - + 'consumer, read as a capability, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared ' - + 'process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ' - + 'ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform ' - + 'isolates plugins, wrote the config, and received a clean parse and zero isolation. An ' - + 'inert security control is worse than an absent one because it is believed. Hot reload ' - + 'was additionally a TWO-SOURCE defect: the docs pointed at this dead ' - + '`PluginHotReloadSchema` while the only implementation body, `HotReloadManager` ' - + '(`packages/core/src/hot-reload.ts`), reads a different vocabulary — ' - + '`HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on ' - + 'the surviving side: that schema is KEPT as the starting point for a future enforce ' - + 'decision (it has an implementation body but no runtime composes it yet), and ' - + 'enforcing it is deliberately a separate decision, not this retirement. ' - + '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. A ' - + 'package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` ' - + 'entry, so a manifest is not a stack collection member and a stored manifest row ' - + 'passes that seam through unchanged. A conversion would be a transform with no seam ' - + 'that ever runs.', - acceptanceCriteria: - 'No `objectstack.plugin.json` and no stored package manifest carries a `loading` key. ' - + 'The enforced channel is the one place a manifest is parsed with an author present: ' - + '`os plugin build` runs `ManifestSchema.safeParse` and exits non-zero, printing the ' - + 'tombstone prescription, so a manifest still declaring `loading` fails its build ' - + 'rather than shipping. TypeScript authors get it earlier still — `loading` is typed ' - + '`never`, so assigning it is a `tsc` error. ⚠️ Runtime behaviour is deliberately ' - + 'UNCHANGED and must be verified as such: nothing ever read the block, so removing it ' - + 'removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries ' - + '`loading` keeps working — the registry\'s `validate()` is an explicit diagnostic and ' - + 'not a gate (it catches, logs `[metadata_spec_invalid]`, and registers the item ' - + 'anyway, deliberately, so bad metadata is never a data outage), so such a row ' - + 'degrades to one log line at registration rather than a boot failure. Clear it by ' - + 'deleting the key from the source manifest and reinstalling.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts b/packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts deleted file mode 100644 index 1897bb610b1..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'plugin-runtime-family-retired', - surface: - 'kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult ' - + '/ kernel.pluginSource / kernel.dynamicPluginOperation', - replacement: - '(removed — there is no replacement shape, because there is no operation to describe. ' - + 'Plugins are composed at boot: `defineStack` registers them and the kernel runs ' - + 'register → init → start; the set is fixed until the process restarts. Delete the ' - + 'import and the value. Runtime plugin loading, if it is ever built, returns via the ' - + 'enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)', - reason: - 'The five schemas declared the "Dynamic Loading" capability — runtime load / unload / ' - + 'reload of plugins without a kernel restart, with sandboxing, integrity hashes, ' - + 'drain strategies and dependent-cascade policy — and NOTHING implemented it. A ' - + 'bare-name scan of objectstack, cloud and objectui found zero references outside ' - + "this package's own declaration, its unit tests and the generated artifacts: no " - + 'runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced ' - + 'a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most ' - + 'inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the ' - + 'published IDE bundle as proof the platform hot-loads plugins and constructs a ' - + 'request that parses clean and is received by nobody (an exported schema with no ' - + 'consumer is read as a capability). The earlier removal of this module\'s ' - + 'discovery/sandbox config island — plugin sandboxing, integrity and approval settings ' - + 'that nothing read — left these five in place explicitly: ' - + '"operation contracts, not security promises; the enforce-or-remove call on them is ' - + 'a design decision rather than a correction" — but that suspension lived only in a ' - + 'changeset paragraph with no issue carrying it. The maintainer\'s ruling of ' - + '2026-08-03 is that decision, answered REMOVE: hot loading is a real future ' - + 'capability, but nothing is being built and nothing pulls it, and when it is built ' - + 'its vocabulary enters the schema with the implementation. `experimental` was ' - + 'considered and rejected: it is only `.describe()` prose ' - + 'and cannot stop an import, the weakest of the three ADR-0049 channels. None of the ' - + 'five is stored metadata — they are root request/result payload shapes embedded in ' - + 'no parent schema and parsed against no metadata document — so no `sys_metadata` ' - + 'row can carry one and there is no source for the D2 chain to rewrite; this entry ' - + 'is the D3 record. The removal also subsumes the kernel half of ' - + '`plugin-activation-events-retired`: that tombstone goes with the shape ' - + 'that carried it. ADR-0049.', - acceptanceCriteria: - 'No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, ' - + '`DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` ' - + 'or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, ' - + '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, ' - + '`DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from ' - + '`@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after ' - + 'upgrade, on every public entry (pinned by symbol identity in ' - + '`plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because ' - + 'nothing called anything: a caller that believed it was hot-loading a plugin was ' - + 'already only building an object. Boot-time composition through `defineStack` is ' - + 'unchanged.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts b/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts deleted file mode 100644 index e3dc2759e58..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'position-permissions-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_position.permissions — the "JSON-serialized array of permission strings" ' - + 'textarea column left the platform position table declared by plugin-security ' - + '(packages/plugins/plugin-security/src/objects/sys-position.object.ts), together ' - + 'with the clone_position copy entry that carried it between rows', - replacement: - 'nothing on this table — delete the key from any authored `sys_position` seed row ' - + '(stack `data` entries) or data-door write that still carries it. There are no ' - + 'direct position-level permission strings anywhere on the platform: capability ' - + 'reaches a position ONLY through permission-set bindings ' - + '(`sys_position_permission_set` rows, created in Setup or by an app\'s ' - + 'kernel:ready binder) and is resolved from the position `name` at request time. ' - + 'A value that was recording intent as documentation belongs in `description`, ' - + 'which remains declared', - reason: - 'Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ' - + 'ADR-0049 enforce-or-remove: REMOVE. The ' - + 'object-scoped census (all sys_position-naming files, with same-object positive ' - + 'controls resolving `active` / `delegatable` / `is_default` / `name` to real ' - + 'readers) measured the column at zero on both sides: the only row writers — the ' - + 'builtin and declared position bootstrappers — set label / description / ' - + 'managed_by / active / is_default, and position→grant resolution consults ' - + '`sys_position_permission_set` rows plus the position `name`, never this column. ' - + 'Its only in-repo reference was the clone_position action copying it between ' - + 'rows — a copy of a value nothing writes. objectui was searched under the same ' - + 'discipline (evidenceScope closure): no console surface names the column — the ' - + 'position pickers and Setup views read name / label / id only, so a designer ' - + 'preview consumer does not exist either. That left a declared free-text grant ' - + 'catalogue on a security object that no runtime enforced: an author — human or ' - + 'AI — who filled it believed they granted permission strings directly on the ' - + 'position, and nothing refused or honoured the value. ' - + 'This is a platform-object COLUMN retirement, not a spec-key retirement, so the ' - + 'bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands ' - + 'in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never ' - + 'declared `permissions`, and the surface ratchets are expected byte-identical), ' - + 'no liveness-ledger row is added (the ledger walks PositionSchema\'s shape, ' - + 'which never carried the key — a row would be an orphan), and the disposition ' - + 'is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain ' - + 'rewrites seed rows today and the measured author base is zero, while the loud ' - + 'channel already exists at runtime — the engine schema preflight refuses an ' - + 'undeclared field with 400 INVALID_FIELD before the driver or any hook runs — ' - + 'so this entry carries the prescription and the refusal carries the ' - + 'enforcement. The live-authoring half is the PositionSchema strict-parse ' - + 'guidance for `permissions`, which names the binding table in the rejection. ' - + '⚠️ Existing physical columns are deliberately untouched: schema sync is ' - + 'additive (ADR-0045), so a deployed database keeps the column; the platform ' - + 'stops declaring, projecting or accepting it. Zero producers means no rows are ' - + 'expected to carry a value; no backfill or destructive DDL is required or ' - + 'wanted. If position-level direct grants ever become a real need, the column is ' - + 're-declared then, WITH a runtime reader in the same PR — declare-and-enforce ' - + 'or do not declare.', - acceptanceCriteria: - 'No authored stack seeds `permissions` on a sys_position record, and no client ' - + 'write to that table carries the key. Concretely: (1) grep your stack sources ' - + 'for permissions next to sys_position — delete the key from any seed row; prose ' - + 'that was documenting intent belongs in `description`. (2) Boot and load your ' - + 'stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming ' - + 'the column — that refusal is the enforced channel, not a silent drop. (3) If ' - + 'you meant to grant capability, author it where it is enforced: bind permission ' - + 'sets to the position (`sys_position_permission_set` rows, created in Setup or ' - + 'by an app\'s kernel:ready binder) — the authz resolver then expands the ' - + 'bindings from the position name at request time.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts deleted file mode 100644 index 025164529d7..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'query-array-string-agg-retired', - surface: "data.query.aggregations[].function ('array_agg' / 'string_agg')", - replacement: - 'an ordinary `fields` query, shaped in the caller — or a stored field that materialises ' - + 'the roll-up. For a deduplicated COUNT the live spelling is unchanged: ' - + '`count_distinct` stays declared', - reason: - 'The stored half of this retirement is a conversion ' - + '(`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. ' - + '`QueryAST` is never stored in stack metadata — it is the client SDK builder\'s output ' - + 'and the `POST /data/:object/query` body — so there is no source for the chain to ' - + 'rewrite and callers move their own queries. Both values were declared-but-unlowered ' - + 'on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso ' - + '`RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller ' - + 'following the schema against a SQL datasource got a refusal, not an array. They did ' - + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes ' - + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation ' - + 'that worked on one backend and failed on another is exactly the unpredictability the ' - + 'ruling ended, and the maintainer\'s 2026-08-05 investment freeze on driver-memory and ' - + 'driver-mongodb had both of those backends frozen at the time (that freeze ' - + 'was lifted on 2026-08-11). `count_distinct` was ' - + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s ' - + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049.', - acceptanceCriteria: - 'No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style ' - + 'roll-ups are assembled by the caller from an ordinary `fields` query, or materialised ' - + 'as a stored field. A query still carrying either value fails to parse with the ' - + 'removal prescription naming it, and authoring it is a `tsc` error at the call site; ' - + '`count_distinct` continues to parse and is unaffected.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts deleted file mode 100644 index 1ac0e9c7296..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'query-cursor-retired', - surface: 'data.query.cursor', - replacement: - 'a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` ' - + 'with the matching `orderBy` (the documented manual-keyset pattern)', - reason: - 'The `cursor` key promised keyset pagination and no driver implemented it: the cursor ' - + 'was accepted and ignored, so every page came back identical — a caller looping ' - + '"until hasMore is false" never terminates. Worse than inert, it had a shipped public ' - + 'producer (`QueryBuilder.cursor()`, removed with the key). The caller-built ' - + '`Record` shape also leaks sort/storage detail and squats on the ' - + 'reserved REST parameter set; a first-class cursor, if ever designed, will be a ' - + 'response-minted opaque token — a different API, so keeping this one preserved a ' - + 'wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to ' - + 'rewrite. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep ' - + 'pagination expresses the keyset as a `where` predicate on the sort key. A query ' - + 'still carrying `cursor` fails to parse with the removal prescription, and authoring ' - + 'it is a `tsc` error.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts deleted file mode 100644 index b2a05c80313..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'query-distinct-retired', - surface: 'data.query.distinct', - replacement: - '`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated ' - + "counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values", - reason: - 'The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it ' - + 'was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list ' - + 'path treated a distinct query as not countable and silently degraded ' - + '`total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND ' - + 'worse pagination metadata, and a side effect that "confirmed" the flag was doing ' - + 'something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with ' - + 'the key). The count suppression is deleted in the same change — `total` is truthful ' - + 'for those queries again. A REQUEST surface, never stored; nothing to rewrite. ' - + 'ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; ' - + 'deduplication goes through `groupBy` / `count_distinct` / the drivers\' `distinct()` ' - + 'door. A query still carrying the key fails to parse with the removal prescription, ' - + 'and the REST list response reports a real `total` for queries that used to send it.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts deleted file mode 100644 index 47122d33cfd..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'query-field-node-object-form-retired', - surface: 'data.query.fields', - replacement: - "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested " - + "query selects the related record's own columns — keeping the foreign key in your own " - + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " - + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' - + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' - + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' - + 'the response to every field). Where the value is wanted on the queried object itself, ' - + 'denormalise it onto that object (a stored field, written when the source changes) — the ' - + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' - + 'or rollup field materialises no column to sort or select by', - reason: - 'The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that ' - + 'was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` ' - + '— objectql\'s formula projection and known-field filters, driver-sql\'s `select()` and ' - + 'driver-memory\'s projection all treat the list as `string[]`, driver-mongodb keyed its ' - + 'projection with the entry itself, and the REST ingress stringified it. Nested selection ' - + 'is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST ' - + 'surface — `QueryAST` is never stored in stack metadata (no view, dataset or report ' - + 'authors one), so there is no source for the chain to rewrite: the schema narrows to ' - + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller puts an object in `fields[]`; related records AND single related columns are ' - + 'read through `expand`, with the foreign-key column retained in the projection so ' - + 'expansion has something to resolve. A `fields` entry that is not a string ' - + 'fails to parse with the removal prescription, and the list/query/export routes answer ' - + '400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts deleted file mode 100644 index c9b6f2b79ee..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'query-joins-retired', - surface: 'data.query.joins', - replacement: - "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested " - + "query selects the related record's own columns — keeping the foreign key in your own " - + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " - + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' - + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' - + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' - + 'the response to every field). Where the value is wanted on the queried object itself, ' - + 'denormalise it onto that object (a stored field, written when the source changes) — the ' - + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' - + 'or rollup field materialises no column to sort or select by', - reason: - 'The `joins` array was declared-but-inert: no engine or driver read `query.joins` ' - + 'anywhere on the query path, so a query carrying it behaved exactly as if the key were ' - + 'absent — while the name squatted on the reserved REST parameter set. Related-record ' - + 'retrieval already has a live spelling (`expand`, resolved by the engine via batch ' - + '`$in` queries), so the removal deletes the second, broken spelling rather than the ' - + 'capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with ' - + 'the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there ' - + 'is no source for the chain to rewrite; callers move their own queries. ' - + 'ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `joins`; related records AND single related columns are read through ' - + '`expand`, with the foreign-key column retained in the projection so expansion has ' - + 'something to resolve. A query that still carries `joins` fails to parse with the removal ' - + 'prescription (even as an empty array), and authoring it is a `tsc` error at the call site.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts deleted file mode 100644 index 96678778769..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'query-window-functions-retired', - surface: 'data.query.windowFunctions', - replacement: - '`aggregations` + `groupBy` for request-level analytics; ' - + '`SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource', - reason: - 'The `windowFunctions` array was declared-but-inert on the query path: `find()` never ' - + 'applied a window function, so every OVER clause a caller declared was silently ' - + 'dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, ' - + 'a driver-level door that is not on the `IDataDriver` contract and whose flat input ' - + 'shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never ' - + 'matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door ' - + 'never read, so that cluster is removed with the key rather than left as a false ' - + 'affordance. A REQUEST surface, never stored; no source to rewrite. ' - + 'ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No caller sends `windowFunctions` in a query; request-level analytics use ' - + '`aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL ' - + "driver's `findWithWindowFunctions` door directly. A query that still carries the key " - + 'fails to parse with the removal prescription naming that door.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts b/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts deleted file mode 100644 index 8029d810579..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'record-details-sections-object-form', - surface: 'ui.RecordDetailsProps.sections (the `record:details` page component)', - replacement: - 'an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the ' - + 'string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the ' - + 'heading translatable, and the new sibling `hideFields` omits named fields from the body', - reason: - '`record:details` declared `sections` as a list of section IDs — `["overview", ' - + '"financials"]` — a shape nothing produced and nothing consumed, while every real page ' - + 'authored the object form. This is authorable metadata on the publish/parse path, so it ' - + 'is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical ' - + 'conversion because a string ID carries no field list and the chain cannot invent one — ' - + 'only the author knows which fields the section named `overview` was meant to render. ' - + 'The measurement that made the type change safe is also what makes the prescription ' - + 'unambiguous: the ID-list form had zero read paths and zero producers. objectui\'s ' - + '`RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` ' - + '/ `s.fields`) with no string branch at all — a string entry spreads into a character ' - + 'map and renders nothing; `@object-ui/types`\' `RecordDetailsComponentProps` mirror ' - + 'already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can ' - + 'only author `{ label, columns, fields }`; `packages/lint` has modelled it as ' - + '`nestedSections` all along; and every page in this repo — three showcase pages plus the ' - + '`sys_user` platform page — authors the object form. So the break lands only on stored ' - + 'metadata written against a declaration nothing ever honoured, and it lands at publish ' - + 'time rather than rewriting data at rest. The same change DECLARED `hideFields`, which ' - + 'the `sys_user` platform page had been authoring undeclared. Registered late, by the ' - + 'stock reconciliation that compared the breaking changesets already on the v17 release ' - + 'train against this ledger: the change that declared the object form predates the gate ' - + 'that makes a breaking changeset state its ledger disposition, so nothing asked it ' - + 'what it had done about the ledger, and the sibling key on the same def — ' - + '`ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone ' - + 'while this face carried none. ADR-0087.', - acceptanceCriteria: - 'Every `record:details` component in authored metadata spells `sections` as an object ' - + 'array: each entry names the fields it renders (`fields: [...]`), optionally with ' - + '`label` / `name` / `columns`. `objectstack validate` passes and each detail page ' - + 'renders the same sections, in the same order, with the same fields as before the ' - + 'upgrade — a page whose sections silently render EMPTY is the signature of an ID list ' - + 'left in place. A section that existed only as an ID, with no field list recoverable ' - + 'from the page it belonged to, is a judgement for the author: name the fields it was ' - + 'meant to show, or delete the entry. Fields previously hidden by a convention outside ' - + 'the schema move onto the declared `hideFields`.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts b/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts deleted file mode 100644 index 0f2e4e04ac1..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'rest-server-openapi31-block-removed', - surface: 'restServer.openApi31', - replacement: - '(removed — no replacement key exists. Delete the key; for a real outbound webhook use ' - + '`Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 ' - + 'webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 ' - + 'through a new ADR)', - reason: - 'The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / ' - + '`pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with ' - + '`OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 ' - + "document synthesis nothing delivered: the REST server's `normalizeConfig` forwards " - + 'only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the ' - + 'pre-generated @objectstack/spec contract enriched with the live server URL and the ' - + 'registered objects — a webhook declared here never appeared in any served document ' - + '(ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector ' - + 'webhook and event enums one layer up). There is no behaviour ' - + 'to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS ' - + 'configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never ' - + "a `sys_metadata` shape — the stack tree's `api` block declares only its four " - + 'scoping/auth knobs. The three schemas are removed with the key (zero import-level ' - + 'consumers in objectstack / cloud / objectui); the key itself is tombstoned because ' - + 'the schema is not `.strict()` and a plain delete would strip it silently.', - acceptanceCriteria: - 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` ' - + '`restConfig`) carries `openApi31` — a config that includes it now fails the parse ' - + 'with the retirement prescription instead of being silently stripped. No code ' - + 'imports `OpenApi31Extensions(Schema)`, `Callback(Schema)` or ' - + '`OpenApiWebhookEvent(Schema)` from `@objectstack/spec/api` (TS2305 after upgrade). ' - + 'The served /openapi.json is byte-identical before and after — the block never ' - + 'reached it.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts b/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts deleted file mode 100644 index 181209b6372..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'runtime-httpserver-wrapper-retired', - surface: 'runtime.HttpServer (the exported delegating wrapper class)', - replacement: - 'register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — ' - + '`HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one', - reason: - '`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its ' - + 'constructor, declared `implements IHttpServer`, and forwarded only the contract\'s ' - + 'REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / ' - + '`close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, ' - + '`setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs ' - + 'consumers to feature-detect exactly those members with `typeof server.X === ' - + '"function"` and to degrade when absent, so wrapping a capable adapter made every ' - + 'probe answer false and the capability vanish with the adapter underneath providing it ' - + 'the whole time. The sharpest consequence is worth writing down before anyone reaches ' - + 'for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered ' - + 'the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, ' - + 'because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints ' - + 'since publish stopped refusing them and 17 began executing them — was never forwarded. ' - + 'This is a TS/API contract surface: an HTTP server ' - + 'adapter is CODE, never stack metadata, so there is no authored source for the chain to ' - + 'rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a ' - + '`.parse()`. That is precisely why this entry must exist: for an untyped JS host the ' - + 'ledger is the only notification channel there is, and for a typed one tsc reports at ' - + 'the construction site. Same disposition, and the same reason, as ' - + '`storage-service-list-retired` and `data-driver-find-stream-retired`. ' - + 'Registered by the stock reconciliation that compared the breaking changesets already on ' - + 'the v17 release train against this ledger, not by the original change: the wrapper\'s ' - + 'removal landed before the gate that makes a breaking changeset state its ledger ' - + 'disposition existed, so nothing ever asked it what it had done about the ledger. ' - + 'ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of ' - + 'the name resolves — the export is gone, so a typed caller fails to compile at the ' - + 'construction site. A host that was wrapping an adapter registers the ADAPTER INSTANCE ' - + 'as `http.server` instead, and then proves the capability came back: `getPort()` returns ' - + 'the real bound port after `listen(0)`, `getRawApp()` returns the framework-native app, ' - + 'and a declarative `apis:` endpoint declared in metadata answers its route rather than ' - + '404 — the last of which is the failure a wrapper produced silently. An adapter that ' - + 'genuinely needs to intercept calls implements `IHttpServer` in full, forwarding the ' - + 'optional members too, rather than declaring `implements` and dropping them.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts b/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts deleted file mode 100644 index 7e3f7c207ba..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'sharing-execution-context-retired', - surface: - '@objectstack/spec: the exported type `SharingExecutionContext` ' - + '(`contracts/sharing-service`), and its re-export from ' - + '@objectstack/plugin-sharing — the six-field context shape ' - + '(`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / ' - + '`isSystem`) that sharing, approval and report enforcement signatures used to name', - replacement: - '`ExecutionContext` from `@objectstack/spec` — the complete ' - + '`resolveAuthzContext` envelope the sharing, approval and report contracts have declared ' - + 'since they converged onto it. Every one ' - + 'of the retired type\'s six fields exists on it under the same name and type, so a ' - + 'value that satisfied the old type already satisfies the envelope: only the ' - + 'annotation is rewritten, never the value', - reason: - 'ADR-0049 enforce-or-remove, completing the maintainer\'s ruling of 2026-08-07 on the ' - + 'share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site ' - + 'subset). This type was the ' - + 'declared context parameter of 36 signatures across three contracts — ' - + '`ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — ' - + 'and it omitted four fields those gates need: `accessible_org_ids` (under the ' - + '`group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, ' - + '`posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR ' - + 'direction of the share-link twin, which that same ruling moved onto the whole context: ' - + 'nothing trimmed the VALUES — ' - + "the engine middleware always handed the whole context down — it was the declared " - + 'TYPE that was narrow, so an implementation could not READ what it had been given ' - + 'without casting out of its own contract (`const posture = (context as any).posture` ' - + "in plugin-approvals' privileged-override gate). One change converged the " - + 'contracts, two more re-annotated the four implementations (sharing and audit, then ' - + 'approvals and reports), and this change removes the now-unreferenced declaration, the ' - + 'deletion that split had deferred. ' - + 'Why this needs a ledger entry despite nothing in-repo referencing it: it is the ' - + '`export-field-meta-constraints-retired` / `hook-context-session-roles-retired` ' - + 'disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no ' - + '`retiredKey()` tombstone and no parse rejection that could carry the prescription, ' - + 'and the ledger is the only channel that reaches an upgrader. ' - + 'Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. ' - + 'The name is only ever spelled inside a consumer\'s own TypeScript, so no ' - + '`objectstack migrate meta` transform can reach it, and no `sys_metadata` row ' - + 'carries it. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No source of yours imports `SharingExecutionContext` from `@objectstack/spec` or ' - + '`@objectstack/plugin-sharing`; each such import becomes `ExecutionContext` from ' - + '`@objectstack/spec` and the build is green. tsc IS a sufficient detector here, ' - + 'unlike the optional-key retirements at this step: the name is gone outright, so ' - + 'every remaining reference is a hard resolution error rather than a silent ' - + '`undefined`. ⚠️ Then check the direction tsc CANNOT see: widening an annotation ' - + 'never rejects a value, so an enforcement path that only ever received a hand-built ' - + 'six-field object still compiles and still under-adjudicates. Confirm each caller ' - + 'passes the context it was HANDED, unchanged, rather than a literal it assembled — ' - + 'and that any gate of yours reading `posture`, `accessible_org_ids`, `org_user_ids` ' - + 'or `tabPermissions` now reads them declared, with no `as any` in the path.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts b/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts deleted file mode 100644 index 0c04bdde69e..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'sharing-rule-recipient-reconcile', - surface: - 'security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules ' - + '(`type: owner` + `ownedBy`)', - replacement: - '`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose ' - + 'the records through a public form or a share link; `type: owner` → rewrite as a ' - + '`type: criteria` rule. `business_unit` is newly authorable for the single-unit case', - reason: - 'The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename ' - + 'and the enforced runtime, in both directions at once. It still offered the pre-rename ' - + '`group`, which the seed path silently SKIPPED, while omitting two recipients the ' - + 'runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and ' - + '`business_unit`). It also offered `guest`, which had no runtime recipient mapping at ' - + 'all. Each of those is a rule that validated and then materialised nothing — the ' - + 'ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: ' - + 'the author sees a valid rule and believes a set of people can reach the records, and ' - + 'no error ever contradicts them. Owner-type rules go for a different and sharper ' - + 'reason: they depend on live team / position membership, which the static materialiser ' - + 'cannot track, so they could not be made to work by fixing a name. They return as an ' - + 'enforced form only if membership-reactive re-materialisation is designed. This is a ' - + 'semantic entry rather than a mechanical conversion because only one of the three ' - + 'rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` ' - + 'have no target — the author has to decide who was actually meant to reach those ' - + 'records and say so in a form the runtime enforces, and a transform that guessed would ' - + 'be inventing an access grant. After this change every authorable recipient and rule ' - + 'type on the SharingRule surface is enforced; the `queue` recipient stays ' - + 'runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note ' - + 'the two neighbouring conversions cover DIFFERENT faces of this schema and not this ' - + 'one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and ' - + '`sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came ' - + 'out of the metadata property liveness audit, which found security properties parsed but ' - + 'never enforced, and was registered late, by the stock reconciliation that compared the ' - + 'breaking changesets already on the v17 release train against this ledger. ADR-0078 / ' - + 'ADR-0090 D3 / ADR-0087.', - acceptanceCriteria: - 'No sharing rule names `group` or `guest`, and none carries `type: owner`; stale ' - + 'definitions now FAIL parse with the valid options listed, so the sweep is "fix until ' - + 'nothing raises". ⚠️ Parsing clean is the weaker half — verify the SHARES, because a ' - + 'rule that was silently materialising nothing looked exactly like one that worked. For ' - + 'every rule that named `group`, confirm the `sys_team` it now resolves to has the ' - + 'membership you expected, and that records reach the people the rule was written for. ' - + 'Each former `guest` rule needs an explicit decision about anonymous access — a public ' - + 'form grant or a share link, or knowingly no access at all — and each former owner-type ' - + 'rule needs a `criteria` predicate that names the same population, checked against a ' - + 'representative record. Where a single business unit was meant, use `business_unit`; ' - + '`unit_and_subordinates` is the subtree and grants strictly more.', - relevantWhen: { kind: 'stack-declares', keys: ['sharingRules'] }, -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts b/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts deleted file mode 100644 index e5e9dbdad83..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'sort-node-direction-rejected', - surface: 'data.query.orderBy[].direction (SortNode)', - replacement: - '`order` — `orderBy: [{ field: "updated_at", order: "desc" }]`. One word, same values ' - + '(`asc` / `desc`)', - reason: - '`SortNodeSchema` was a plain `z.object`, so zod\'s default `.strip` applied and a sort ' - + 'node spelling its direction `direction` lost the key silently. Measured on `main` ' - + 'before the change: `SortNodeSchema.parse({ field: "updated_at", direction: "desc" })` ' - + 'returned `{ field: "updated_at", order: "asc" }` — the key discarded and `order` ' - + 'falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the ' - + 'request succeeded. Paired with `limit`, which is how a caller asks for "the latest N", ' - + 'that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary ' - + '200 with nothing in the response to distinguish it from the answer that was asked for. ' - + '`direction` is not a typo: it is the live vocabulary of a neighbouring contract, ' - + '`IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated ' - + 'between the two by hand — a translation known to be necessary and enforced nowhere, ' - + 'the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a ' - + '`strictObject` carrying `aliases: { direction: "order" }`, and `normalizeSortNodes` ' - + 'in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The ' - + 'alias is deliberate rather than left to the edit-distance fallback, because no edit ' - + 'distance bridges `direction` → `order` and a bare "unrecognized key" would leave the ' - + 'caller exactly where the silent strip did. This is registered as a semantic entry ' - + 'rather than a mechanical conversion for one reason worth stating: the rewrite itself ' - + 'is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the ' - + 'author may have written it meaning ascending and been silently GIVEN ascending, so ' - + 'the visible behaviour never contradicted them, and only they can say whether the ' - + 'sort they have been reading was the sort they asked for. Registered by the stock ' - + 'reconciliation of the v17 train\'s breaking changesets: the in-code alias tombstone ' - + 'shipped with the change that closed both doors ' - + '(maintainer ruling 2026-08-03), but the ledger half ' - + 'never did, and a retirement needs both — the tombstone is the proof the removal was ' - + 'declared, the ledger entry is what `spec-changes.json`, the upgrade guide and ' - + '`os migrate meta` project to consumers. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No authored `orderBy` entry — in metadata, in a saved view\'s `sort[]`, or in a REST / ' - + 'RPC request body — spells the key `direction`. The upgrade\'s own verify loop is that ' - + 'the failure is now LOUD: a stale `direction` raises a named parse error (or `400 ' - + 'INVALID_SORT` at ingress) quoting `order`, so a sweep is "fix until nothing raises" ' - + 'rather than a search. ⚠️ Check the RESULTS, not just the parse: every list, report and ' - + 'paged query that carried `direction: "desc"` has been silently serving ASCENDING order ' - + 'and, wherever it was paired with `limit`, a different set of rows. After the rename ' - + 'those pages change what they return — that is the defect being corrected, not a ' - + 'regression, and any downstream expectation baked against the old output has to be ' - + 're-read rather than restored.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts b/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts deleted file mode 100644 index 65c347b7906..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'spec-type-alias-input-suffix-retired', - // Plain text, no markdown: build-upgrade-guide.ts renders this field inside a - // code span AND inside a table cell, so backticks here break both. - surface: - 'type alias: the 102 XInput names of @objectstack/spec ' - + '(ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ' - + 'ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ ' - + 'identity/ integration/ kernel/ security/ system/ ui/)', - replacement: - 'the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` ' - + 'a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the ' - + '`Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held ' - + 'a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already ' - + 'declared for every schema whose two shapes differ, so the target name has existed for ' - + 'a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, ' - + '`CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare ' - + 'aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, ' - + '`ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or ' - + '`Partial`-shaped) types no bare alias denotes.', - reason: - 'This entry exists for the reason `data-driver-find-stream-retired`, ' - + '`storage-service-list-retired` and `actor-user-roles-to-positions` ' - + 'exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack ' - + 'metadata, so there is no source for a D2 conversion to rewrite and deliberately no ' - + 'schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, ' - + 'and no `.parse()` ever saw it. Measured and verified rather than assumed: ' - + '`json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL ' - + 'across this change, because those generators enumerate runtime `z.ZodType` exports ' - + 'and never read a type alias. So nothing left the published metadata surface and ' - + 'RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim ' - + 'the metadata contract shrank. The enforced channel is tsc: the name is gone, so every ' - + 'consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the ' - + 'replacement — a compile error says `ConnectorInput` does not exist, not that ' - + '`Connector` now means what it meant. The generated upgrade guide is the only channel ' - + 'that carries the second half, which is precisely the gap ADR-0087 registration ' - + 'exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and ' - + 'that entry had to land separately. ⚠️ Deliberately ' - + 'NOT registered alongside it: the 1384 bare aliases ' - + 'the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and ' - + 'still resolve; what moved is which of a schema\'s two shapes they denote, and only ' - + 'where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op ' - + 'there, pinned as such). A consumer holding an authored literal is made MORE correct ' - + 'by it, silently; one holding a parse result gets a tsc error at the first defaulted ' - + 'key it reads. Registering that as a rename would misdescribe it — no name was ' - + 'retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9 (its ' - + 'phase 2, which moved every bare name to the author state).', - acceptanceCriteria: - 'No source imports a name ending `Input` from `@objectstack/spec` except the nine listed ' - + 'above: `rg "\\b\\w+Input\\b" --type ts` over consumer code resolves only to those. A ' - + 'literal annotated with a bare spec type compiles while listing ONLY the keys the ' - + 'author means — `const c: Connector = { name, label, type }` type-checks, which it did ' - + 'not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name ' - + 'no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal ' - + 'that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every ' - + 'bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced ' - + '`XInput` synonym.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts b/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts deleted file mode 100644 index cbc057a0258..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'storage-service-list-retired', - surface: 'contracts.IStorageService.list', - replacement: - 'track the keys you wrote (sys_file / file-reference records, queryable through ' - + 'ObjectQL with real pagination) instead of enumerating the bucket — and where no ' - + 'such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this ' - + 'entry reserved, restored since, once cloud proved to be the first-party caller this ' - + 'repository could not see', - reason: - '`list(prefix)` was an OPTIONAL contract method documented as "List files in a ' - + 'directory/prefix", and the two shipped adapters answered the same call with two ' - + 'different semantics — both of them silently incomplete. `LocalStorageAdapter.list` ' - + 'was a single-level `readdir`, so a nested key `a/b/c` was invisible under ' - + '`list(\'a\')` (only `a/b` came back), and a subdirectory that `stat` succeeded on ' - + 'was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is ' - + 'a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` ' - + 'was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither ' - + '`IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a ' - + 'caller received was the first page, with no signal. One contract method, two ' - + 'dialects, both quietly incomplete — and the first feature that genuinely needed to ' - + 'enumerate a prefix (backup, orphan sweep, migration audit) would have got two ' - + 'different answers on two deployments without an error on either. The email plugin\'s ' - + 'large-attachment storage work was nearly that feature: it planned to drive attachment ' - + 'reclamation off ' - + '`list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one ' - + 'level down, and switched to queue-driven deferred work instead. Nothing consumed ' - + 'it afterwards: the only in-repo call site was the `SwappableStorageService` ' - + 'pass-through (which itself rejects when the active adapter has no `list`), and ' - + 'REST, CLI and the storage routes never called it. Remove was chosen over ' - + 'align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two ' - + 'dialects): aligning would grow a ' - + 'conformance surface nobody walks, while a prefix listing that cannot paginate is ' - + 'the wrong signature to inherit — when a real caller needs enumeration it returns ' - + 'cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases ' - + '(nested keys, directory entries, >1000 objects) proving both backends agree. This ' - + 'is a TS/API contract surface — a storage adapter is CODE, never stack metadata — ' - + 'so there is no source for the chain to rewrite, and deliberately no schema ' - + 'tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription ' - + 'there would reach no one. The enforced channel is tsc, and it reports at the call ' - + 'site. Same disposition, and the same reason, as ' - + '`data-driver-find-stream-retired`. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No code calls `storage.list(...)` on the `file-storage` service or on any ' - + '`IStorageService` value. Code that needed "which files are under this prefix" ' - + 'reads the records it wrote — `sys_file` / file-reference rows carry the storage ' - + 'key and page deterministically through ObjectQL — rather than asking the bucket, ' - + 'which is also the only form that stays correct past 1000 objects and across both ' - + 'adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra ' - + 'method is not an error on a class) and is simply unreachable through the ' - + 'contract, so deleting it is cleanup that can follow. The break is on the CALLER ' - + 'side: `storage.list(...)` no longer type-checks, and a PROXY typed against ' - + '`IStorageService` that forwards to `inner.list` is exactly such a caller — the ' - + 'one in `@objectstack/service-storage` goes with the adapters\' own `list` ' - + 'implementations, removed in the retirement\'s implementation half. ' - + '⚠️ AMENDED 2026-08-09, under the maintainer\'s 2026-08-08 ruling on cloud\'s ' - + 'storage-enumeration callers (option B: restore enumeration upstream, correctly shaped, ' - + 'rather than hand-roll S3 pagination one repository over): the ' - + 'RESERVED route in the paragraph above was taken. `list` exists again on the ' - + 'contract, cursor-shaped — `list(prefix, { cursor, limit })` returning ' - + '`{ items, nextCursor }` — because cloud had two first-party callers this repo ' - + 'could not see when the measurement said "nothing calls it" (tenant attachment ' - + 'reclamation, marketplace snapshot GC). This does NOT un-retire anything and the ' - + 'acceptance criterion above is unchanged for what it actually governs: the ' - + 'single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written ' - + 'against it still fails to compile, and the two dialects it had are now pinned ' - + 'against each other in `storage-adapter-list.conformance.test.ts` rather than left ' - + 'to diverge. What changed for an upgrader is only the destination: prefer the ' - + 'records you wrote, and reach for the restored member when there are none.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts deleted file mode 100644 index 33440edd382..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'tool-requires-confirmation-retired', - surface: 'ai.tool.requiresConfirmation', - replacement: - 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the ' - + 'flag the platform confirmation CONTRACT is written against, and that contract is ' - + 'ENFORCED. An AI-facing call on an action declaring the flag must carry the ' - + 'confirmation member `confirm: true` on the request and is REFUSED without it with ' - + '`ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact ' - + 'member to set. A gate, not a queue: nothing is parked, and a refused call did not ' - + 'run — no record was read and none was written. ⚠ Two bounds: the enforced set is the ' - + 'doors that enforce the author\'s `ai.exposed` opt-in, today the action door reached ' - + 'from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and ' - + 'sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the ' - + 'gate makes forgetting loud without proving a human approved', - reason: - '`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: ' - + 'not the LLM tool set (a tool reaches the model as name / description / parameters ' - + 'only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP ' - + 'bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a ' - + 'destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for ' - + 'a SAFETY property it is false compliance, the case ADR-0049 exists for — an author ' - + 'gates a destructive tool, sees the flag accepted, and ships believing a human is in ' - + 'the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries ' - + 'the same name and DOES work, so the mistake reads as correct in review. This is ' - + 'registered as a semantic entry rather than a mechanical conversion because the rewrite ' - + 'is not a rename at all — the replacement lives on a different metadata object at a ' - + 'different layer, and deciding which action should carry the gate (or whether the ' - + 'operation should be an action at all) is a judgement the chain cannot make. Deleting ' - + 'the key mechanically would be the worst possible transform here: it would leave the ' - + 'metadata parsing green while silently completing the removal of a safety gate the ' - + 'author believed was in place. `ToolSchema` was made `.strict()` in the same change, ' - + 'which is load-bearing rather than tidying — removing a key from a non-strict schema ' - + 'swaps one silent no-op for another, so the retired key now REJECTS and the parse error ' - + 'carries the prescription, that being the one channel every consumer bumping ' - + '`@objectstack/spec` is guaranteed to hit. Registered by the stock reconciliation of the ' - + 'v17 train\'s breaking changesets: the `retiredKey()` tombstone shipped with the change ' - + 'that executed ADR-0033\'s deletion of this unenforced key, and still stands in ' - + '`ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the ' - + 'tombstone is the proof the removal was declared, this entry is what `spec-changes.json`' - + ', the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ' - + 'ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'No tool definition carries `requiresConfirmation`; the key now raises a located parse ' - + 'error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The ' - + 'load-bearing half is what happens NEXT, and no gate can check it for you: for every ' - + 'tool that carried the flag, decide whether that operation genuinely needs a human in ' - + 'the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: ' - + 'true`, which is what the platform confirmation contract gates on — and that gate is ' - + 'PERFORMED: invoking the operation over an AI-exposed door without the confirmation ' - + 'member is REFUSED with `ACTION_CONFIRMATION_REQUIRED` (428) and nothing runs, so ' - + 'that call is a real check you can make rather than a destructive experiment. ⚠ Two ' - + 'bounds on what it proves: the enforced set is the doors that enforce the author\'s ' - + '`ai.exposed` opt-in — today the action door reached from the MCP `run_action` tool ' - + '— while REST `/actions` is not `ai.exposed`-gated and sits outside the gate, so an ' - + 'agent holding an API key on that route is still yours to put a human in front of; ' - + 'and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting ' - + 'loud without proving a human approved. The decision above is still the one this ' - + 'criterion asks you to make. If the operation does not need a human, delete the key ' - + 'knowingly. ' - + 'Deleting it without that decision leaves exactly the state the retirement exists to ' - + 'end: a destructive tool nobody is approving, now without even the false flag to show ' - + 'that somebody once meant to.', - relevantWhen: { kind: 'stack-declares', keys: ['tools'] }, -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts deleted file mode 100644 index 4dd530a47ea..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'ui-interaction-config-family-retired', - surface: - 'ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig ' - + '/ ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig ' - + '(the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ' - + 'ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)', - replacement: - '(removed — there is no replacement key, because there was never a key. Touch targets, ' - + 'drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN ' - + 'behaviour: the component library decides them, not a per-page metadata author. ' - + 'Offline is a platform capability, and its vocabulary belongs on the sync engine that ' - + 'owns the queue, the conflict policy and the cache — none of which exists yet. Delete ' - + 'the import and the value. Whichever of these earns real product pull returns WITH its ' - + 'own vocabulary and its executor — the way inbound rate limiting came back, as a new ' - + 'key carrying only what its executor consumes — not by un-retiring a declaration)', - reason: - 'Five `@objectstack/spec/ui` modules declared a full interaction-configuration ' - + 'vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, ' - + 'focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol ' - + 'carried them. This is the ADR-0049 false-compliance shape in its most inviting form ' - + 'for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: ' - + '`authorable-surface.json` listed 109 keys under these defs and ' - + '`content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them ' - + 'as authoring tables, so the published documentation advertised a vocabulary with no ' - + 'carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a ' - + 'page component was rejected by `PageComponentSchema` for an unrecognized key — the ' - + 'docs and the schema disagreeing about the platform (Prime Directive #10). Three ' - + 'independent measurements, each with its controls passing in the same run: (1) no ' - + 'module under `packages/spec/src` imported any of the five except the `ui/index.ts` ' - + 'barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph ' - + 'from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` (25 roots, ' - + '4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, ' - + '`WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic ' - + 'carrier flipped all 21 — so unreachability was a fact about the graph, not a broken ' - + 'walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud ' - + 'outside these modules\' own unit tests. objectui holds TYPE re-exports and parity ' - + 'ratchets, never validators, and says so (its types package deliberately dropped the ' - + 'spec/ui zod-validator re-exports and keeps type-only ones). The 2026-08-04 ruling ' - + 'retired the family — touch, drag-and-drop, keyboard and motion are renderer built-in ' - + 'behaviour and offline belongs to a sync engine, none of it per-page metadata — and weighed ' - + 'wiring a carrier key (option B) and rejected it: that is a feature with a renderer ' - + 'behind it, not ledger clean-up. It also weighed tightening the shapes to ' - + '`strictObject` and rejected that explicitly — strictness is a property of a PARSE and ' - + 'there is no parse, so it would spend a breaking change to leave "a precisely ' - + 'validated dead slot, the more convincing lie" (the lesson of the datasource capability ' - + 'flags: `readOnly` was precisely validated and read by nothing, while a shipped example ' - + 'called a datasource a read replica and wrote through it). Because there was no carrier ' - + 'key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 ' - + 'conversion to rewrite: this entry is the D3 record, the same route 3 as ' - + '`plugin-runtime-family-retired` (the kernel plugin-runtime family) and the ' - + '`HttpServerConfig` retirement (seven keys no runtime read and no authoring door ' - + 'reached, retired with their container). ⚠️ Not to be confused with the theme-token ' - + 'retirement (theme-driven typography is not a near-term capability, so nine token ' - + 'groups nothing consumed were retired), which retired the THEME `animation` block — a ' - + 'different file, different defs, and that one did have a carrier key and therefore a ' - + 'tombstone. ADR-0049.', - acceptanceCriteria: - 'No code imports any of the 64 retired names from `@objectstack/spec` or ' - + '`@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, ' - + '`SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, ' - + '`LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, ' - + '`TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, ' - + '`AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, ' - + '`MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, ' - + '`DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, ' - + '`FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, ' - + '`KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, ' - + '`ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, ' - + '`EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — ' - + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol ' - + 'identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs ' - + 'editing, because none could ever carry one of these blocks: a stack that parsed ' - + 'before parses byte-for-byte the same after. If you consumed the bare ' - + '`ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, ' - + 'declare that union locally — it is your client\'s policy, not the platform\'s. ' - + '`@objectstack/spec/integration`\'s `ConnectorConflictResolution` (connector sync) and ' - + '`@objectstack/spec/api`\'s `ConflictResolutionStrategy` (route merge policy) are ' - + 'different concepts and are untouched.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts deleted file mode 100644 index 9379092d840..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'ui-notification-action-embed-config-retired', - surface: 'ui.notificationAction / ui.embedConfig', - replacement: - '(removed — there is no replacement shape, because there was never a key to write ' - + 'either into. Delete the import and the value. Notification presentation is still ' - + 'described by the surviving `NotificationType` / `NotificationSeverity` / ' - + '`NotificationPosition` vocabulary; public access to a form is granted by the LIVE ' - + '`FormView.sharing` block (`SharingConfig`), which is untouched. Notification action ' - + 'buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 ' - + 'through a new ADR — carrier key and renderer first, vocabulary second)', - reason: - 'Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. ' - + 'The v17 unknown-key strictness sweep, which measured each ui/ ' - + 'file for an authoring door before closing any shape, measured them three ways on ' - + '2026-08-03 and this retirement re-ran all ' - + 'three against `origin/main` before removing anything, each with a positive control ' - + 'that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared ' - + 'a key of either type (`ui/notification.zod`\'s only non-test importer was the ' - + 'barrel; `ui/sharing.zod`\'s were the barrel and `ui/view.zod.ts`, which names its ' - + 'SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than ' - + 'substring-matching, because the repo holds two `sharing.zod` modules and a substring ' - + 'test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 ' - + 'metadata-type roots plus `defineStack`\'s `ObjectStackSchema`, over ' - + '`build-schemas.ts`\'s own walk including its derived-clone bridge, never reached ' - + 'either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` ' - + 'itself all resolved `root-graph` in the same run and an injected synthetic carrier ' - + 'flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside ' - + 'their own unit tests. So nobody could author one and nothing ever validated one: ' - + 'the shape of the unwired plugin sandboxing config removed before it, an exported ' - + 'schema with no consumer read as a capability, and the ' - + 'ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle ' - + 'as proof the platform serves iframes. Neither is stored metadata and neither has a ' - + 'carrier, so no `sys_metadata` row can hold one and there is no source for the D2 ' - + 'chain to rewrite; this entry is the D3 record. The sweep deliberately did NOT close ' - + 'them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing ' - + 'parses buys only "a precisely-validated dead slot, the more convincing lie" (the lesson ' - + 'of the datasource capability flags, whose `readOnly` was precisely validated and read ' - + 'by nothing) — and left the disposition to ADR-0049\'s enforce-or-remove, which came ' - + 'back REMOVE on 2026-08-04: a dead surface with no authoring door retires ' - + 'implementation-first, as three same-shape rulings that week had already decided. Each ' - + 'was orphaned by an earlier retirement one level up: `NotificationAction` lost its ' - + 'wrappers when the dual-source cleanup removed the `./ui` copies of `NotificationSchema` ' - + '/ `NotificationConfigSchema` (the same names declared differently on other entry ' - + 'points) — that retirement\'s published "zero consumers" evidence was later ' - + 'falsified for objectui, which re-exported both names, and is corrected on ' - + '`ui/notification.zod`\'s tombstone; the removal itself stands — and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 ' - + 'liveness audit retired `App.embed` (no iframe route ever read it) — that key still ' - + 'stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already ' - + 'meets a prescription; this removes the value shape that outlived it. ⚠️ The ' - + 'retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, ' - + 'a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount ' - + 'the anonymous form routes, and `ui/notification.zod` keeps its three presentation ' - + 'enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY ' - + '(never a parse) to pin its own hand-written `NotificationActionButton` interface — ' - + 'which is exactly why "has a consumer" never meant "has an authoring door" here; that ' - + 'pin is adapted objectui-side when it refreshes this dependency. ADR-0049.', - acceptanceCriteria: - 'No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` ' - + 'or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are ' - + 'TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in ' - + '`notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the ' - + 'same run, and that half is equally load-bearing: `NotificationTypeSchema` / ' - + '`NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` ' - + 'must still be exported from `./ui`, and both modules must still load — a retirement ' - + 'that deleted either file would satisfy the absence half while destroying working ' - + 'surface. Nothing regresses at runtime, because nothing ever ran: no notification ' - + 'action was ever parsed from metadata and no iframe route ever read an embed config. ' - + 'Public form sharing is unaffected — `FormView.sharing` still gates the anonymous ' - + 'endpoints on `allowAnonymous` + `publicLink`.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts deleted file mode 100644 index 32a1e8aa621..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'ui-widget-i18n-family-retired', - surface: - 'ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty ' - + '/ ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat ' - + '/ ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the ' - + 'five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)', - replacement: - '(removed — there is no replacement key, because there was never a key. A custom field ' - + 'widget is still named the same way it always was: `field.widget` is a plain string ' - + 'naming a component the RENDERER has registered, and objectui\'s registry has always ' - + 'carried its own runtime manifest for that (`RuntimeWidgetManifest` / ' - + '`RuntimeWidgetSource` in `@object-ui/types`, renamed off the spec\'s names under ' - + 'objectui\'s rule that a symbol named like a spec export must import it or take a name ' - + 'of its own), which models ' - + 'different keys and never derived from these. For localisation: write the ' - + 'default-language string on `label` / `description` — the framework generates the ' - + 'translation key at registration time from the naming convention — and put ' - + 'translations in translation files, which is the LIVE `system/translation.zod.ts` ' - + 'surface. Widget registration and locale formatting as authorable protocol metadata ' - + 'return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / ' - + 'formatter first, the vocabulary second)', - reason: - '`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest ' - + 'with lifecycle hooks, custom events, configurable properties and an ' - + 'npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a ' - + 'structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the ' - + 'protocol carried either. Three independent measurements, re-run on `origin/main` ' - + 'immediately before the removal with their controls passing in the SAME run: (1) no ' - + 'module under `packages/spec/src` imported `widget.zod` at all, and the only imports ' - + 'of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no ' - + 'schema declared a carrier key — `field.widget` is a `z.string()` naming a registered ' - + 'component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory ' - + 'Zod graph from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` ' - + 'reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` ' - + 'in the same run and a synthetic carrier flipped every one of them; (3) zero ' - + '`.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files\' ' - + 'own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key ' - + '(`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, ' - + 'so the subtree was `no door` rather than `no gate` and goes whole — leaving the two ' - + 'leaves behind would strand exported schemas with no consumer, which an author reads ' - + 'as a capability. ' - + '`I18nObjectSchema` was additionally superseded by its own file-neighbour: ' - + '`I18nLabelSchema`\'s documentation already says translation keys are generated at ' - + 'registration time and translations live in translation files, and the live ' - + 'translation surface is `system/translation.zod.ts`, which uses none of these shapes. ' - + 'The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that ' - + 'is a feature with a registry and a renderer behind it, not ledger clean-up. ' - + 'Tightening them to `strictObject` was rejected earlier and explicitly, by the batch of ' - + 'the v17 unknown-key strictness sweep that measured this file as having no authoring ' - + 'door — strictness is a property of a PARSE and there is no parse, so it would spend a ' - + 'breaking change to leave "a precisely validated dead slot, the more convincing lie" ' - + '(the lesson of the datasource capability flags, whose `readOnly` was precisely ' - + 'validated and read by nothing). With no carrier key there is nothing to tombstone and no `sys_metadata` row ' - + 'or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, ' - + 'the same shape as `ui-interaction-config-family-retired`, ' - + '`plugin-runtime-family-retired` and the `HttpServerConfig` retirement. ' - + '⚠️ `WidgetManifest.performance`\'s own `retiredKey()` tombstone (left by the ' - + 'close-out sweep that removed the inert `performance` keys no renderer applied) is ' - + 'SUBSUMED here, the way the kernel `activationEvents` tombstone went with the removed ' - + 'plugin-runtime family: it goes with the shape that carried it, which is ' - + 'strictly stronger than the tombstone, because there is no longer a manifest to ' - + 'author the key INTO. ' - + '⚠️ One of the nine widget sites is deliberately NOT retired. ' - + '`FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than ' - + 'authorable metadata (it never appeared in `authorable-surface/` or ' - + '`json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its ' - + 'design and not its defect, and it acquired a live cross-repo compile-time consumer ' - + 'one day before that sweep batch measured: an objectui fix of 2026-08-03, made to follow the ' - + 'spec, renamed ' - + '`@object-ui/fields`\' validation slot onto the spec\'s `error` with no alias, the ' - + 'form renderer began producing it, and ' - + '`packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against ' - + '`import type { FieldWidgetProps } from \'@objectstack/spec/ui\'` as an intentional ' - + 'tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049.', - acceptanceCriteria: - 'No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, ' - + '`WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, ' - + '`WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, ' - + '`NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or ' - + '`LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — ' - + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol ' - + 'identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs ' - + 'editing, because none could ever carry one of these shapes: a stack that parsed ' - + 'before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string ' - + 'is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / ' - + '`FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still ' - + 'resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion ' - + 'PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` ' - + 'asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the ' - + '"a workaround should not outlive its reason" half of the rename tripwire objectui ' - + 'added when it stopped declaring symbols under names the spec owns, designed to go red ' - + 'exactly here), and `packages/types/src/widget.ts`\'s ' - + '"Renamed off the spec\'s `WidgetManifest` name" comments now point at names that no ' - + 'longer exist. Both are prescribed responses to this removal, not collateral damage.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts deleted file mode 100644 index b617a51ba42..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'ups-delegated-from-column-retired', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span AND a table cell. - surface: - 'sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the ' - + 'platform grant table declared by plugin-security (packages/plugins/plugin-security/' - + 'src/objects/sys-user-permission-set.object.ts). The sibling declaration on ' - + 'sys_user_position is untouched', - replacement: - 'nothing on this table — delete the key from any authored `sys_user_permission_set` ' - + 'seed row (stack `data` entries) or data-door write that still carries it. ' - + 'Delegation semantics live on `sys_user_position`, where `delegated_from` remains ' - + 'declared AND runtime-enforced: the delegated-admin gate is what makes a position ' - + 'insert a delegation, and the explain engine attributes "via delegation from X, ' - + 'until Y". A permission-set grant that needs a provenance note keeps `reason` ' - + '(free text), which remains declared on both grant tables', - reason: - 'Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this ' - + 'column on this object, ADR-0049 enforce-or-remove: REMOVE. The ' - + 'runtime delegation gate is structurally scoped to sys_user_position ' - + '(`isDelegationWrite` returns false for every other object, so ' - + '`assertSelfDelegation` is unreachable for this table), and the explain engine ' - + 'reads delegation provenance from sys_user_position rows only. On ' - + 'sys_user_permission_set the column was therefore declared and data-door-writable ' - + 'while NO runtime consumer read it — its only enforcement was an authoring-time ' - + 'lint (the D3 "delegation row needs a reason" rule), which a row written through ' - + 'the generic data door never meets. That is declared-but-unenforced in its pure ' - + 'form, on a security object: an author who stamped delegated_from on a ' - + 'permission-set grant believed they constrained delegation, and nothing refused or ' - + 'honoured it. Producers measured at zero — the only object literals naming both ' - + 'the table and the column were lint test fixtures. ' - + 'This is a platform-object COLUMN retirement, not a spec-key retirement, so the ' - + 'bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in ' - + 'RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are ' - + 'expected byte-identical), and the disposition is a SEMANTIC entry rather than a ' - + 'D2 conversion. A conversion over stack `data` seed records would be mechanically ' - + 'expressible, but no conversion in the chain rewrites seed rows today and the ' - + 'measured author base is zero; the loud channel already exists at runtime — the ' - + 'engine schema preflight refuses an undeclared field with 400 INVALID_FIELD ' - + 'before the driver or any hook runs — so this entry carries the prescription and ' - + 'the refusal carries the enforcement. ' - + '⚠️ Existing physical columns are deliberately untouched: schema sync is additive ' - + '(ADR-0045), so a deployed database keeps the column; the platform stops ' - + 'declaring, projecting or accepting it. Zero producers means no rows are expected ' - + 'to carry a value; no backfill or destructive DDL is required or wanted. ' - + 'If delegation at permission-set granularity ever becomes a real need, the column ' - + 'is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce ' - + 'or do not declare.', - acceptanceCriteria: - 'No authored stack seeds `delegated_from` on a sys_user_permission_set record, and ' - + 'no client write to that table carries the key. Concretely: (1) grep your stack ' - + 'sources for delegated_from next to sys_user_permission_set — delete the key from ' - + 'any seed row; a row that was recording genuine hand-over provenance should say it ' - + 'in `reason` instead, which the platform stores on both grant tables. (2) Boot and ' - + 'load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD ' - + 'naming the column — that refusal is the enforced channel, not a silent drop. ' - + '(3) If you meant actual delegation-of-duty, author it where it is enforced: a ' - + 'sys_user_position insert with delegated_from = the writer, a mandatory future ' - + 'valid_until within the ceiling, and a mandatory reason (ADR-0091 D3) — the ' - + 'delegated-admin gate then validates the whole shape at runtime.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts b/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts deleted file mode 100644 index f0fc12c7bcc..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'view-filter-rule-value-shaped-by-operator', - // No backticks in `surface` — build-upgrade-guide.ts renders it inside a - // code span already, and a nested backtick would close it. - surface: - 'ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ' - + 'ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a ' - + 'related-list component filter and a lookup picker filter. It accepted any declared ' - + 'scalar or array for EVERY operator; the accepted shape is now decided by the rule ' - + 'operator — in / not_in require an array, between requires exactly two bounds, and ' - + 'every other operator is unchanged', - replacement: - 'an ARRAY for in / not_in (a single value becomes a one-element list: value: "won" ' - + 'becomes value: ["won"]), and a two-element [min, max] array for between. The empty ' - + 'list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a ' - + 'scalar operator carrying an array, a string operator carrying a number, and a unary ' - + 'operator carrying an ignored value all still parse', - reason: - 'A publish-time gate catching up to a query-time one, not a new rule. An earlier fix ' - + 'closed the RUNTIME half: `assertListComparandShapes` ' - + '(@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered ' - + '`{ stage: { $nin: "won" } }` with a named 400 INVALID_FILTER, and before that it ' - + 'was a 500. The authoring surface stayed silent, so the failure was two-stage: the ' - + 'view published cleanly and only broke when someone opened it. That file names this ' - + 'very schema as the reachable authoring source of the defect. The tightening MIRRORS ' - + 'that gate exactly — three constraints, one for one — and deliberately goes no ' - + 'further, because an earlier fix already settled the opposite error (the ordering ' - + 'operators\' comparand widened to the strings the platform itself produces): a schema ' - + 'stricter than ' - + 'the runtime "in ways the runtime deliberately allows" was the WRONG side and was ' - + 'widened to match. So `in: []` is still accepted (a declared predicate both drivers ' - + 'implement), `equals: ["a","b"]` is still accepted (it lowers to a deep-equality ' - + 'comparand), and `is_empty: ""` is still accepted (the null predicates take their ' - + 'direction from the operator NAME — convertComparison ignores the value position, ' - + 'and the ObjectUI client deliberately sends a truthy placeholder there). ' - + '⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. ' - + 'A D2 entry replays a shape the platform once WROTE and renamed; this shape was ' - + 'never written by any first-party producer (every in / not_in rule in this repo, in ' - + 'objectui and in the cloud repo already carries an array — measured) and has never ' - + 'EXECUTED, since it 400s on first render today. Coercing it at load would be the ' - + 'platform guessing intent rather than replaying a rename, and it cannot guess ' - + 'honestly: value: "" would become the predicate [""] (a real filter on the empty ' - + 'string) rather than the "not filled in yet" a console row means, and between: 5 has ' - + 'no defensible second bound at all. The read path does not re-validate stored rows ' - + '(applyConversionsToStoredItem never validates, by its own contract), so no stored ' - + 'view becomes unreadable; what changes is that RE-SAVING such a view is refused at ' - + 'the write gate naming `value`, instead of storing a filter that 400s. ' - + 'ADR-0049 / ADR-0078 / ADR-0112.', - acceptanceCriteria: - 'Grep your authored views, pages and related-list components for a filter rule whose ' - + 'operator is in, not_in or between (including the alias spellings nin / notIn / ' - + 'notin) and whose value is not an array of the right arity, then wrap or complete ' - + 'it. `os validate` / `os lint` now report each one by path with the operator, the ' - + 'received shape and the corrected shape, so the sweep is mechanical rather than by ' - + 'eye. Two checks are worth doing where it looks unnecessary: a rule reading ' - + '`operator: "in", value: ""` is an UNFINISHED row, not a filter — decide what it was ' - + 'meant to select rather than mechanically rewriting it to [""], which is a real and ' - + 'different predicate. And a view that already carried one of these shapes was never ' - + 'returning filtered rows: it answered 400 INVALID_FILTER on render, so ' - + 're-check what the view is supposed to show rather than assuming the old result set ' - + 'was correct.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts b/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts deleted file mode 100644 index 9a1f52e6a04..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'view-management-protocol-retired', - surface: - 'api.listViews / api.getView / api.createView / api.updateView / api.deleteView ' - + '(the ViewProtocol interface and its ten Request/Response schemas in ' - + 'api/protocol.zod.ts — 10 defs, 25 exported names)', - replacement: - 'the two view surfaces that are actually routed. For a view\'s STORED definition, the ' - + 'generic metadata methods with `type: \'view\'` — `getMetaItem` / `getMetaItems` / ' - + '`saveMetaItem` / `deleteMetaItem`, served at `/api/v1/meta/view/:name`. For the ' - + 'RESOLVED render-time view, `getUiView` (`GetUiViewRequest` / `GetUiViewResponse`), ' - + 'served at `/api/v1/ui/view/:object/:type`. Neither is addressed by a `viewId`, which ' - + 'is the one thing the retired surface offered and the one thing nothing implemented', - reason: - 'A complete viewId-addressed CRUD surface — list (with a list/form filter), read, ' - + 'create, patch, delete — with none of the three things a protocol method needs. ' - + 'Measured on origin/main immediately before the removal: no implementation ' - + '(`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / ' - + '`createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), ' - + 'no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing ' - + 'viewId-addressed is reachable over HTTP at all), and no caller (the only ' - + '`ViewProtocol` mention outside its own file was the services checklist, which ' - + 'already recorded the five as declared-and-unrouted). The look-alike hits a bare-name ' - + 'grep turns up are all different contracts: `metadata-manager.ts`\'s ' - + '`getView(name: string)` is another class, and objectui\'s ' - + '`getView(objectName, viewId)` resolves through `client.meta.getItem(\'view\', …)`, ' - + 'i.e. the metadata route. ' - + 'What makes this worth a removal rather than a note is that the cost is already ' - + 'measured. A declared surface that is name-identical and semantics-adjacent to a real ' - + 'one is an attractive nuisance in every grep, and it mis-directed a decision once: ' - + 'The issue asking what `GET /ui/view/:object/:type` answers AND its 2026-08-07 maintainer ' - + 'ruling both read ' - + '`GetViewResponseSchema` (zero implementations) as the contract of ' - + '`GET /ui/view/:object/:type`, whose declared response is `GetUiViewResponseSchema` — ' - + 'one word apart, 250 lines up. That ruling\'s reasoning happened to survive the ' - + 'mix-up ("nobody can consume `{object, view}` successfully today" was true, though ' - + 'not for the stated reason), which is the luck this removal stops relying on. ' - + 'Route 3: none of the ten was a key on an authorable shape, nothing parsed them, so ' - + 'there is no tombstone and no D2 conversion — RETIRED_DEFS_BY_MAJOR plus this entry ' - + 'are the declaration. If reading and writing ONE view by id becomes a real ' - + 'requirement it returns implementation-first. ADR-0049, ADR-0087, maintainer ruling ' - + '2026-08-07.', - acceptanceCriteria: - 'No source imports `ListViewsRequest(Schema)`, `ListViewsResponse(Schema)`, ' - + '`GetViewRequest(Schema)`, `GetViewResponse(Schema)`, `CreateViewRequest(Schema)`, ' - + '`CreateViewResponse(Schema)`, `UpdateViewRequest(Schema)`, ' - + '`UpdateViewResponse(Schema)`, `DeleteViewRequest(Schema)` or ' - + '`DeleteViewResponse(Schema)` from `@objectstack/spec/api`, and no host declares a ' - + '`ViewProtocol` member. Reading and writing views still works end to end through the ' - + 'surfaces that were always the live ones: `GET /api/v1/meta/view/:name` returns the ' - + 'stored definition and `GET /api/v1/ui/view/:object/:type` returns the resolved view, ' - + 'both unchanged by this removal. `GetUiViewRequestSchema` / `GetUiViewResponseSchema` ' - + 'still resolve — they are the shapes that ruling meant.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts b/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts deleted file mode 100644 index dda33ceed6e..00000000000 --- a/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -export const entry: SemanticMigration = { - id: 'workflow-service-slot-retired', - surface: - "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / " - + 'discovery routes.workflow / RestApiRouteCategory workflow', - replacement: - 'the live mechanisms the slot only ever pointed at: `state_machine` validation rules ' - + 'for record state machines, approval flow nodes on the approvals runtime (ADR-0019) ' - + 'for approvals, lifecycle hooks + `record_change` flows (service-automation) for ' - + 'record-triggered automation', - reason: - 'The workflow slot was declared end to end and implemented nowhere: no code in either ' - + 'repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches ' - + 'were plugin-dev\'s retired stub probe and the generic discovery walk), no ' - + 'implementation of any WorkflowProtocol method ever existed, and no host ever ' - + 'mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a ' - + 'stale list, named it among ' - + 'routes that never existed). Every part of it was ADR-0078\'s silently-inert ' - + 'declaration: a CoreServiceName nothing filled, a contract nothing implemented, a ' - + 'protocol nothing served, a discovery route field no builder could truthfully ' - + 'populate. These are TS/API surfaces and a discovery RESPONSE field — never stored ' - + 'in stack metadata, so there is no source for the chain to rewrite; consumers of the ' - + 'deleted types move their imports themselves. ADR-0049 / ADR-0078.', - acceptanceCriteria: - 'No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/' - + 'Transition types resolves; no code calls getService(\'workflow\') or reads ' - + 'discovery `routes.workflow` / `services.workflow`; record state machines, ' - + 'approvals and record-triggered automation go through the replacement mechanisms. ' - + 'Discovery output on a default boot is unchanged (the slot was always reported ' - + 'unavailable; now it is simply absent).', -}; diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 5b79a2413ef..131c36b9a5b 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -26,6 +26,7 @@ import { } from './spec-changes.js'; const CONVERSION_IDS = new Set(ALL_CONVERSIONS.map((c) => c.id)); +const CONVERSION_TO_MAJOR = new Map(ALL_CONVERSIONS.map((c) => [c.id, c.toMajor] as const)); /** * Ruling B (ADR-0087 D3; the contract is `SemanticMigration` in `./types.ts`): @@ -89,7 +90,12 @@ describe('migration chain (ADR-0087 D3)', () => { links++; if (!CONVERSION_IDS.has(id)) { dangling.push(`protocol ${major}: ${s.id} → ${id} (no registered conversion has this id)`); - } else if (!replayed.has(id)) { + } else if (!replayed.has(id) && CONVERSION_TO_MAJOR.get(id)! > MIGRATION_SUPPORT_FLOOR) { + // A conversion of a major AT or BELOW the floor has no step left + // to replay it: its step aged out with the floor (ADR-0087 + // amendment, #25767) while the conversion row stays registered + // for the stored-row door. Such a link pairs with the edit that + // door applies, so it is not dangling. dangling.push(`protocol ${major}: ${s.id} → ${id} (registered, but no step at or below ${major} replays it)`); } } @@ -215,232 +221,6 @@ describe('migration chain (ADR-0087 D3)', () => { }); }); - // The rationale is not decoration: `docs/protocol-upgrade-guide.md` is a pure - // projection of it (ADR-0087 D4, `gen:upgrade-guide`), so this string IS the - // page an author upgrading 16 → 17 reads. A stale present-tense claim here is - // published advice, which is why it gets pinned like a prescription. - describe('protocol-17 rationale — the app-area section states the CURRENT fact', () => { - const rationale17 = () => MIGRATIONS_BY_MAJOR[17]!.rationale; - - it('does not repeat the retired "the server does not walk `areas`" claim', () => { - // #4722 (same 17.0.0 window) made `filterAppForUser` run the same - // `filterNav` over every `areas[].navigation`. The sentence that survived - // #4651 told an upgrading author to restructure their navigation tree for - // a gate they can now write in place. Same pin as the schema-side - // prescriptions carry since #5336 (`packages/spec/src/ui/app.test.ts`). - expect(rationale17()).not.toMatch(/does not walk/i); - // The rationale still QUOTES the retired boundary — naming what changed - // is how a reader who remembers the old advice knows to drop it — so the - // pin is on the tense, which is the whole defect: the claim may appear as - // history ("was enforced by the shell only"), never as current fact. - expect(rationale17()).not.toMatch(/is enforced by the shell only/i); - expect(rationale17()).toMatch(/was CLOSED by/); - }); - - it('names the server-side fix in words and the two trees an item gate is now enforced in', () => { - const r = rationale17(); - expect(r).toMatch(/was CLOSED by a server-side fix inside this same 17\.0\.0 window/); - expect(r).toMatch(/`filterAppForUser` now runs the SAME `filterNav` over every/); - expect(r).toMatch(/BOTH trees/); - expect(r).toMatch(/areas\[\]\.navigation/); - }); - - it('does not read as reviving the area-LEVEL keys', () => { - // The retirement verdict is untouched: what #4722 enforces are the ITEMS - // inside an area, never a gate of the area's own. A rationale that merely - // went quiet about `areas[]` would leave the reader with the old boundary; - // one that over-corrects would read as an un-retirement. - const r = rationale17(); - expect(r).toMatch(/stay retired/); - expect(r).toMatch(/no gate of its own/); - }); - - it('keeps `visible` client-side only — the half the server-side item gate did NOT change', () => { - // The newly tempting false belief is "areas are gated now, so `visible` - // is fine". `visible` (CEL) is still evaluated in the browser at every - // level, so it hides an entry that has already been served. - const r = rationale17(); - expect(r).toMatch(/client-side ONLY/); - expect(r).toMatch(/`visible` \(CEL\)/); - expect(r).toMatch(/never in `visible`/); - }); - - it('still carries the area-gate removal history the step exists to explain', () => { - // The first half is a record of the state AT the retirement and of why - // route B (remove) beat route A (enforce). Correcting the caveat must not - // erase it — an upgrading author needs to know the keys were fail-open, - // not merely unread. - const r = rationale17(); - expect(r).toMatch(/areas\[\]\.visible/); - expect(r).toMatch(/FAIL-OPEN access gates/); - expect(r).toMatch(/At the time of the retirement/); - expect(r).toMatch(/must not invent an authorization mechanism/); - }); - }); - - // Same class as the block above, one field over. A `semantic` entry's `reason` - // is projected verbatim into `docs/protocol-upgrade-guide.md` and - // `spec-changes.json` (ADR-0087 D4), so a falsified premise recorded here is - // PUBLISHED advice — not a code comment. This entry (#5015) explained its two - // orphans by pointing one level up at #4610, and repeated #4610's stated - // evidence: the `./ui` notification wrappers were "deleted for zero consumers". - // objectui#3310 disproved that at 17.0.0-rc.1 — `packages/types/src/index.ts` - // re-exported both names with `export … from '@objectstack/spec/ui'` and - // `NotificationProtocol.ts` consumed them through the `@object-ui/types` - // barrel, two hops an import-statement-level scan cannot see (the third miss - // of that class, after #4667 / #4709). The RETIREMENT is untouched; only the - // sentence that justified it moves. - describe('protocol-17 NotificationAction / EmbedConfig entry — stops republishing the falsified zero-consumer claim', () => { - const entry = () => - MIGRATIONS_BY_MAJOR[17]!.semantic.find( - (s) => s.id === 'ui-notification-action-embed-config-retired', - ); - - it('finds the entry, and it still explains the dual-source orphaning (anti-vacuity)', () => { - expect(entry()).toBeDefined(); - // The orphaning is stated in words, not by tracker number: the reason is - // printed to the author by `os migrate meta`. - expect(entry()!.reason).toMatch(/dual-source cleanup removed the `\.\/ui` copies/); - expect(entry()!.reason).toMatch(/NotificationConfigSchema/); - }); - - it('never asserts the wrappers were deleted for having zero consumers', () => { - // Pinned on the ASSERTION, not on the words: the entry may still name the - // claim in order to correct it — going quiet about it would leave a reader - // who remembers the old guide believing the old reason. - expect(entry()!.reason).not.toMatch(/(deleted|removed) for (having )?zero consumers/i); - }); - - it('names the correction and keeps the removal itself standing', () => { - const r = entry()!.reason; - expect(r).toMatch(/falsified/); - expect(r).toMatch(/objectui, which re-exported both names/); - // ⛔ A correction to the evidence is not an un-retirement. - expect(r).toMatch(/removal itself stands/); - }); - }); - - // Third of the same class, one field over again: `acceptanceCriteria` is the - // "Done when" line `gen:upgrade-guide` projects verbatim, so a caveat that has - // been overtaken by an enforcement is a published instruction to hand-audit a - // mistake the engine now refuses on its own. #6746 (#6667) added - // `AutomationEngine.refuseUndeclaredSuspension` - // (`packages/services/service-automation/src/engine.ts`), called from - // `executeNode` on every `result.suspend === true`, which made this entry's - // "warned about by NEITHER channel — check those by hand" false in the - // direction that costs a reader work. #6749 fixed the TSDoc half; this is the - // registry channel it explicitly excluded (#6844). - describe('protocol-17 resumeAuthority default-flip entry — supportsPause is enforced now, so stop asking for a hand-audit', () => { - const entry = () => - MIGRATIONS_BY_MAJOR[17]!.semantic.find( - (s) => s.id === 'action-descriptor-resume-authority-default-flip', - ); - - it('finds the entry, and it still states the resumeAuthority criterion (anti-vacuity)', () => { - // Guards the whole block against passing because the entry vanished: every - // negative below is vacuously true on `undefined`, and a `.find()` that - // stops matching is exactly how that happens. - expect(entry()).toBeDefined(); - expect(entry()!.acceptanceCriteria).toMatch(/resumeAuthority/); - expect(entry()!.acceptanceCriteria).toMatch(/supportsPause/); - }); - - it('no longer tells the reader to hand-check the supportsPause mismatch', () => { - // Pinned on the INSTRUCTION, not on wording: the entry may still name the - // old gap in order to say it closed — going quiet would leave a reader who - // remembers the published guide still doing the audit by hand. - const a = entry()!.acceptanceCriteria; - expect(a).not.toMatch(/check (those|them) by\s+hand/i); - expect(a).not.toMatch(/is a declaration nothing\s+enforces/i); - expect(a).not.toMatch(/warned about by NEITHER channel/i); - }); - - it('names the enforcing mechanism and that a `fault` edge cannot route it', () => { - // Matched by idiom, not by sentence: the reader has to be able to FIND the - // guard, which is what distinguishes this from a bare "it is enforced now". - const a = entry()!.acceptanceCriteria; - expect(a).toMatch(/refuseUndeclaredSuspension/); - expect(a).toMatch(/refuses that suspension/); - expect(a).toMatch(/guard-class/); - expect(a).toMatch(/`fault` edge/); - }); - - it('does not over-correct into "nothing left to check"', () => { - // The gate deliberately does NOT judge a descriptor-less executor - // (engine.ts `refuseUndeclaredSuspension`, "What it does NOT judge → - // Silence"; pinned by `supports-pause-runtime-enforcement.test.ts`'s - // descriptor-less case). Claiming total coverage would be the same defect - // as the old over-claim, pointing the other way. - const a = entry()!.acceptanceCriteria; - expect(a).toMatch(/NO descriptor/); - expect(a).toMatch(/resume route/); - }); - }); - - // Fourth of the #5781 class, on the `replacement` field: a projected ledger - // string that asserted a runtime capability the tree does not deliver. The - // entry told an author displaced by the ETL layer's retirement that - // connector-attached sync "IS parsed and executed" — parsed is true - // (`AutomationEngine.registerConnector` runs `ConnectorSchema.parse`), - // executed never was: `syncConfig` has no reader outside `packages/spec`, the - // same measurement that retired `syncConfig.schedule` under ADR-0049. Because - // `replacement` is projected verbatim into `spec-changes.json` (which ships in - // the `@objectstack/spec` tarball) and into `docs/protocol-upgrade-guide.md`, - // the claim was published advice, not a code comment. ⚠️ Nothing in this repo - // cross-checks a projected ledger string against the tree it describes — the - // mechanism is a per-entry pin like this one, added after each defect is - // found. That gap is the recurring cause; this pin only closes THIS entry. - describe('protocol-17 `etl-pipeline-layer-retired` — states what happens to `syncConfig`, not a sync that never ran', () => { - const entry = () => - MIGRATIONS_BY_MAJOR[17]!.semantic.find((s) => s.id === 'etl-pipeline-layer-retired'); - - it('finds the entry, and it still routes the author layer by layer (anti-vacuity)', () => { - // Guards every negative below against passing on `undefined`, which is - // exactly how a `.find()` that stops matching reads as green. - expect(entry()).toBeDefined(); - expect(entry()!.replacement).toMatch(/Layer by layer/); - expect(entry()!.replacement).toMatch(/ConnectorSchema\.syncConfig/); - }); - - it('⛔ never claims connector-attached sync is executed', () => { - // Pinned on the CLAIM, not on the words: the entry may still name the - // execution question in order to answer it, which is what the corrected - // sentence does — going quiet would leave a reader who remembers the - // published guide still expecting a sync to run. - const r = entry()!.replacement; - expect(r).not.toMatch(/IS parsed and executed/); - expect(r).not.toMatch(/syncConfig[^.]{0,80}\bis executed\b/i); - }); - - it('says what `syncConfig` IS and what actually happens to it', () => { - // A false promise replaced by a vague one would be the same defect wearing - // a fix's clothes: the author has to come away knowing the block is a - // declared shape, and knowing where the parse happens. - const r = entry()!.replacement; - expect(r).toMatch(/PARSED AND VALIDATED but NOT\s+EXECUTED/); - expect(r).toMatch(/declared shape/); - expect(r).toMatch(/AutomationEngine\.registerConnector/); - expect(r).toMatch(/no\s+reader outside `packages\/spec`/); - }); - - it('names the surface that IS executed, so the author has somewhere to go', () => { - // The measurement behind this line: `connector_action` - // (`service-automation/src/builtin/connector-nodes.ts`) resolves the - // registered handler and awaits it. Without this the correction would - // leave the ETL-displaced author with no route at all. - const r = entry()!.replacement; - expect(r).toMatch(/`actions`/); - expect(r).toMatch(/connector_action/); - }); - - it('the retirement itself still stands — this is a correction to the advice', () => { - // ⛔ Correcting a projected sentence is not an un-retirement. - const r = entry()!.replacement; - expect(r).toMatch(/removed — no protocol surface replaces it/); - expect(entry()!.acceptanceCriteria).toMatch(/No source imports `ETLPipeline`/); - }); - }); - // The D3 half of a node-level refusal, and the one class of entry whose // ABSENCE is invisible to every other gate in this family: `check:spec-changes` // and `check:upgrade-guide` pin the registry to its PROJECTIONS, so an entry @@ -576,32 +356,38 @@ describe('migration chain (ADR-0087 D3)', () => { // hop's. Written against the constant rather than the literal 16: the next // floor move should re-point this replay, not delete it. const OLDEST_HOP = MIGRATION_SUPPORT_FLOOR + 1; + // Two protocol-17 shapes the oldest hop (17 → 18) rewrites: a job's + // `timeout` (renamed to `timeoutMs`) and an object's `externalSharingModel` + // (removed). The pair this used to drive (`execute` → `target`, + // `conditionalRequired` → `requiredWhen`) rode the 16 → 17 step, which + // aged out with the 16.x kits (#25767). const oldShape = () => ({ - actions: [{ name: 'convert', label: 'Convert', type: 'script', execute: 'convertHandler' }], + jobs: [{ + name: 'nightly_health_sweep', + schedule: { type: 'cron', expression: '0 1 * * *' }, + handler: 'sweepProjectHealth', + timeout: 300000, + }], objects: [ - { - name: 'crm_task', - label: 'Task', - fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, - }, + { name: 'portal_case', label: 'Case', sharingModel: 'public_read', externalSharingModel: 'private' }, ], }); it('migrates the oldest supported major\'s shapes to canonical', () => { const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); - const action = (result.stack.actions as any[])[0]; - expect(action).not.toHaveProperty('execute'); - expect(action.target).toBe('convertHandler'); - const field = (result.stack.objects as any[])[0].fields.due_date; - expect(field).not.toHaveProperty('conditionalRequired'); - expect(field.requiredWhen).toBe('record.stage == "closed"'); + const job = (result.stack.jobs as any[])[0]; + expect(job).not.toHaveProperty('timeout'); + expect(job.timeoutMs).toBe(300000); + const object = (result.stack.objects as any[])[0]; + expect(object).not.toHaveProperty('externalSharingModel'); + expect(object.sharingModel).toBe('public_read'); // Two mechanical rewrites, named — a count alone would survive one of // them being replaced by an unrelated conversion firing on this shape. expect([...new Set(result.applied.map((a) => a.conversionId))].sort()).toEqual([ - 'action-execute-to-target', - 'field-conditionalRequired-to-requiredWhen', + 'job-timeout-to-timeout-ms', + 'object-external-sharing-model-removed', ]); // Semantic TODOs are advisory per-major and always surfaced for the hop, // whatever the stack contains. @@ -626,7 +412,7 @@ describe('migration chain (ADR-0087 D3)', () => { const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); expect(result.hops).toHaveLength(1); expect(result.hops[0]!.toMajor).toBe(OLDEST_HOP); - expect((result.hops[0]!.stack.actions as any[])[0].target).toBe('convertHandler'); + expect((result.hops[0]!.stack.jobs as any[])[0].timeoutMs).toBe(300000); }); }); diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 0bb08269cd6..cf03ba5cf1c 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -9,12 +9,17 @@ * `semantic` list carries one entry per retirement family, even when a lossless * D2 conversion also exists for it — D2 carries the mechanical data repair only. * - * The chain is a **forever artifact**: every step back to - * {@link MIGRATION_SUPPORT_FLOOR} stays replayable, and CI replays the full chain - * from the oldest supported major's fixtures to current on every release - * (`migrations.test.ts`). The support floor is an explicit release-policy knob — - * how far back `migrate meta --from N` reaches — revisitable per major, never an - * accident of deletion. + * The chain is a **rolling artifact** (ADR-0087 amendment 2026-10-26, #25767): + * every step back to {@link MIGRATION_SUPPORT_FLOOR} stays replayable, and CI + * replays the full chain from the oldest supported major's fixtures to current + * on every release (`migrations.test.ts`). A retirement kit — the tombstone, its + * `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` entries, its semantic entry + * and its conversion — lives ONE protocol major past the one it retired in and + * is then removed whole: at major 18 the 17.x and 18.x kits stay and the ≤16.x + * ones went. After the removal the key is refused as a strict unknown key and + * the refusal names the upgrade guide; nothing converts it. The support floor is + * the same knob, read the other way — how far back `migrate meta --from N` + * reaches — and it rises with each major so that the two never disagree. * * ## ⚠️ `registry.ts` is GENERATED WHOLE and git-ignored (#7297, #22554) * @@ -64,1098 +69,27 @@ import type { MigrationStep } from './types.js'; * * > 升级只需要支持从 16.0版本开始。 * - * Raising it is what RETIRES the steps below it: `step11`–`step16` went with - * this move, because a step the floor no longer reaches is replayed by nothing - * and CI stops proving it still works — dead code that reads as a promise. - * `applyMetaMigrations(doc, N)` now throws `MigrationFloorError` for - * N ∈ 10..15; those consumers reach 16 by another path first. - * - * ⚠️ The floor scopes the CHAIN and nothing else. {@link RETIRED_KEYS_BY_MAJOR} - * and {@link RETIRED_DEFS_BY_MAJOR} are keyed by major but are NOT floor-scoped: - * their only consumer (`scripts/build-schemas.ts`) folds every major into one - * set, so a row below the floor is still the live proof that its retirement was - * declared. ⛔ Never drop rows from either table when this constant moves. - */ -export const MIGRATION_SUPPORT_FLOOR = 16; - -/** - * Protocol 17 step. + * Raised 16 → 17 by maintainer ruling, 2026-10-26 (#25767, ADR-0087 amendment, + * D2 = A): a retirement kit lives one protocol major past the one it retired + * in. At major 18 that keeps the 17.x and 18.x kits and removes the ≤16.x ones, + * and the floor follows: `os migrate meta --from 16` (and below) is refused + * with the upgrade-guide link and converts nothing. The next major repeats the + * move — `CURRENT_MAJOR - 1` is the floor, not a number chosen per release. * - * Mechanical: the three deprecated aliases that a schema transform used to - * fold into a canonical key and drop from the parsed output (`action.execute`, - * `field.conditionalRequired`, `agent.knowledge.topics`) are removed from the - * spec. Each is a pure key rename with an unchanged value, so each replays - * losslessly as a D2 conversion. None of the three carries a D3 entry: this - * step shipped before every retirement family was required to carry one - * (`SemanticMigration`, `./types.ts`), and it was not back-filled when that - * rule landed. The step's `semantic` list is NOT empty — its later retirements - * carry their D3 entries in the generated region below. + * Raising it is what RETIRES the steps below it: `step11`–`step16` went with + * the first move and `step17` with the second, because a step the floor no + * longer reaches is replayed by nothing and CI stops proving it still works — + * dead code that reads as a promise. `applyMetaMigrations(doc, N)` throws + * `MigrationFloorError` for N < 17; those consumers reach 17 by another path + * first (a release that still carried the step, e.g. @objectstack/spec 18.1). * - * The three conversions are `retiredFromLoadPath` from the day they land: 17 - * gives the aliases no acceptance window at all, and each schema tombstones its - * key with a fix-it error instead. This step is what makes that affordable — - * `os migrate meta --from ` lists the consumer's edits site by site rather - * than leaving them to hand-edit against a changelog. + * ⚠️ The floor scopes the CHAIN; {@link RETIRED_KEYS_BY_MAJOR} and + * {@link RETIRED_DEFS_BY_MAJOR} are keyed by major but are not read through it: + * their consumer (`scripts/build-schemas.ts`) folds every major into one set. + * Their rows leave on the same one-major clock as the tombstones they date — + * see each table's "Lifecycle" — never because this constant moved. */ -const step17: MigrationStep = { - toMajor: 17, - rationale: - [ - 'Protocol 17 removes the last three deprecated authorable aliases: action ', - '`execute` (use `target`), field `conditionalRequired` (use `requiredWhen`), and ', - 'agent `knowledge.topics` (use `knowledge.sources`). Each was already lowered into ', - 'its canonical key at parse time and dropped from the parsed output, so no runtime ', - 'behaviour changes — only the authorable surface shrinks to one spelling per slot. ', - 'All three are pure key renames with unchanged values and replay losslessly; the ', - 'schemas reject the removed spellings with a fix-it error naming the replacement.\n\n', - 'It also removes the sharing-rule access level `full`: declared as ', - '"Full Access (Transfer, Share, Delete)" but never enforced as anything but ', - '`edit` — both gates matched `edit`/`full` alike, so Setup promised admins a ', - 'delete grant it never issued (ADR-0078). Unlike the OWD `sharingModel: \'full\'` ', - 'alias retired at step 13, this one HAS a lossless target precisely because it ', - 'was inert — old and new shapes are behaviourally identical — so it converts ', - 'mechanically and leaves no semantic residue. It is the one protocol-17 ', - 'conversion that keeps a load-path acceptance window: it had no prior ', - 'deprecation, and a removed enum value cannot carry the fix-it error the three ', - 'key renames tombstone theirs with.\n\n', - 'Finally it removes agent `tools`: the legacy inline ', - '`{type,name,description}[]` fallback, which the runtime resolved against the ', - 'FULL tool registry with no surface check — the one seam that broke ADR-0064\'s ', - '"an agent reaches exactly its surface-compatible skills\' tools, nothing falls ', - 'through to the global registry". Unlike the renames above this has NO lossless ', - 'target: each entry has to become a reference inside a skill, which is a human ', - 'decision about which skill. The conversion therefore drops the dead key (the ', - 'cloud runtime had stopped reading it, so it already contributes nothing) and ', - 'emits a notice per agent so the author knows where capability must be ', - 're-declared; the schema tombstones the key with a fix-it error naming `skills`.\n\n', - 'Beyond those spec-surface removals, it graduates the seven flow-node config key ', - 'aliases the executors still ', - 'tolerated: the CRUD nodes\' `object` (use `objectName`) — the last tenant ', - 'of the `readAliasedConfig` executor shim, which is deleted with it — plus the six ', - 'open-coded fallbacks that never went through that shim: notify `to`/`subject`/', - '`body`/`url` (use `recipients`/`title`/`message`/`actionUrl`) and script ', - '`functionName`/`input` (use `function`/`inputs`). All are pure key renames with ', - 'unchanged values and replay losslessly. Like the sharing-rule access level above ', - 'they keep a load-path acceptance window: none carried a prior deprecation ', - 'warning, and `FlowNodeSchema.config` is an unconstrained record, so no schema ', - 'tombstone can reject them — the conversion layer is the only seam that can ', - 'declare, convert, and retire them.\n\n', - 'The same graduation covers `wait`, whose fallback was not a config-to-config ', - 'rename. `wait` keeps its contract in the declared `waitEventConfig` ', - 'block, not in `config` at all — yet the executor also read six loose `config` ', - 'keys, two of them (`duration`, `signal`) spellings the spec never declared. ', - 'The conversion lifts them onto the declared block in the executor\'s own `??` ', - 'precedence, so a value already declared wins and its loose counterpart is left ', - 'shadowed. One wrinkle makes this a rewrite rather than a delete: ', - '`waitEventConfig.eventType` is required once the block exists, and the loader ', - 'parses the CONVERTED flow — so a source carrying only `config: { duration }` ', - 'is stamped with `eventType: \'timer\'`, the exact default the executor applied ', - 'to that shape. Behaviour-preserving in both directions.\n\n', - '`connector_action` gets the same lift for the opposite reason. Its ', - 'contract also lives in a declared sibling block (`connectorConfig`), and the ', - 'executor never read `config` at all — but the node\'s descriptor published a ', - '`configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys, and ', - 'the Studio inspector derives its form from a published schema, so schema-driven ', - 'authoring wrote the trio to the wrong place and produced nodes that refused to ', - 'dispatch. The conversion lifts the trio onto the declared block (declared keys ', - 'win; a lift that cannot complete the required connectorId+actionId pair leaves ', - 'the node untouched rather than turning a step-time refusal into a load ', - 'failure), and the descriptor stops publishing the mis-rooted schema.\n\n', - 'The reconciliation that found those also found `map`, whose executor read a ', - 'bare `cfg.flowName ?? cfg.flow` for an undeclared `flow` spelling no schema ', - 'ever described. A pure rename, graduated the same way, so the ', - 'executor reads only the canonical `flowName`.\n\n', - 'And it removes the RLS-policy key `priority` (found by the liveness ledger\'s security re-verification): promised ', - '"conflict resolution" that cannot exist, because no outcome depends on an order: ', - 'applicable policies OR-combine on reads, and a write\'s check is chosen once per ', - 'operation across the applicable policies, then OR-combined (see ', - '`RowLevelSecurityPolicySchema.check`) — there is never a conflict to order, and nothing ever ', - 'read the key (call graph closed across the collection site, the projection ', - 'round-trip and the compiler). A pure lossless delete: outcomes are identical ', - 'with or without it; the schema tombstones the key with the same prescription.\n\n', - 'The same close-out retires the four inert tool authoring keys (`category`, ', - '`permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no ', - 'execution path read them. Two were misleading in the dangerous direction — ', - '`permissions` promised an invocation gate nothing enforced, and `active: false` ', - 'read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless ', - 'deletes; the strict ToolSchema rejects each with its prescription.\n\n', - 'The AppSchema sheds its seven dead authoring keys (2026-06 liveness audit, ', - 'the unknown-key strictness wave\'s app step): `version` (apps are versioned by manifest.version), `aria`, ', - '`objects`/`apis` (the self-described "config file convenience" — nothing read ', - 'them; the chatbot derives an app\'s objects from its nav items), `sharing`/', - '`embed` (a declared-but-unenforced public surface — the only live path is ', - 'FormView.sharing; ADR-0049), and `mobileNavigation` (fully unimplemented). ', - 'Pure lossless deletes — none ever had a runtime effect; each key is ', - 'tombstoned with its prescription.\n\n', - 'ADR-0113 splits the `required` tri-binding: post-17, `required` is ONLY the ', - 'write-time contract (insert must provide; update may not null out; legacy null ', - 'rows rest), and the physical NOT NULL is the explicit `storage.notNull`. ', - '⚠️ NOTHING converts the column half for you, and nothing tightens a column you ', - 'already have. A `field-required-notnull-explicit` conversion did stamp ', - '`storage.notNull: true` onto every `required: true` field; it was WITHDRAWN ', - '(maintainer ruling 2026-09-08), because stamping the constraint wherever ', - '`required: true` appears is exactly the implication the ADR abolished — and ', - 'because the artifact-ingestion door replays retired conversions, so the LOADER ', - 'applied it to 17-authored sources that deliberately omit it and then told their ', - 'authors to write the same tightening into the source, which on a populated ', - 'database is a destructive `tighten_not_null` migration prescribed as the remedy ', - 'for a deprecation notice. Post-17 a column is NOT NULL because its author wrote ', - '`storage: { notNull: true }`, and for no other reason. If you are upgrading a ', - 'pre-17 source whose columns ARE NOT NULL and you want them to stay that way, ', - 'add `storage: { notNull: true }` to those fields yourself — deliberately, and ', - 'knowing that doing it to a field whose column is currently nullable is a ', - 'destructive migration with a backfill ceremony.\n\n', - 'On the wire contract it also retires the `/analytics/query` request ENVELOPE ', - 'outright: `AnalyticsQueryRequestSchema` used to describe `{ cube, query: {...}, ', - 'format }` — the dialect of the degraded analytics shim (retired for serving unscoped aggregates) that the ', - 'real engine never understood (an envelope body inferred a column-less cube and ', - 'died as an SQL syntax error). The canonical request body is now the BARE ', - 'AnalyticsQuery — `cube` + `measures` at the top level — which is what every ', - 'real caller already sends; the schema tombstones `query`/`format`, and the ', - 'dispatcher entry validates bodies and answers 400 with the prescription. No ', - 'stored metadata carries this shape (it was HTTP-only), so the change is two ', - 'semantic TODOs for API callers rather than a stack conversion.\n\n', - 'The close-out sweep finishes the enforce-or-remove worklist across the ', - 'remaining types: action `shortcut`/`bulkEnabled` (no keydown path; the ', - "multi-select toolbar reads the view's bulkActions), flow `active`/`template`/", - 'node `outputSchema`/errorHandling `fallbackNodeId` (`active: false` never ', - 'stopped a flow — `status` is the enforced lifecycle; faults route via ', - 'per-node fault edges), the inert view keys (list `responsive`/`performance`, ', - 'form `data`/`defaultSort`/`aria` — list aria/data stay live), dashboard and ', - 'widget `aria`/`performance`, `agent.knowledge` (declaring sources never ', - 'scoped retrieval — absorbs the former topics→sources rename), and ', - "`skill.triggerPhrases` (phrases were never matched; routing is ", - 'triggerConditions + the agent allowlist). All pure lossless deletes, each ', - 'tombstoned at its schema with the prescription.\n\n', - 'One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` ', - '— it carried two defaults: `.default(0)` in FlowSchema and ', - "`maxRetries ?? 3` in the engine's retryExecution — and because `??` fires ", - 'only on `undefined`, an unstated count meant 0 retries for a flow parsed by ', - 'the schema and 3 for a definition handed to the engine directly: the retry ', - "count was a function of the route in, not of the authored flow. The engine's ", - 'copy is deleted (it reads the parsed block, no fallback), which makes an ', - "unstated count unambiguously 0 — and `strategy: 'retry'` that retries zero ", - "times is `strategy: 'fail'` under another name, the declared-not-delivered ", - 'shape ADR-0049 exists to close. The schema therefore requires `maxRetries` ', - "at least 1 under `'retry'`, in both spellings (omitted, and an explicit 0). ", - 'This is the one v17 flow change the chain cannot apply for you: choosing the ', - 'count is a judgment about re-running the WHOLE flow with its side effects, ', - 'so it is a semantic TODO rather than a rewrite.\n\n', - 'It also retires `api.requireAuth`: the deployment-wide opt-out that let a ', - 'stack serve its ENTIRE data plane anonymously with one boolean. Auth is a kernel ', - 'concern, not a deployment posture — anonymous access to object data is now denied ', - 'unconditionally on every HTTP surface. Every surface that legitimately serves a ', - 'session-less caller derives its own narrow authorization from a DECLARATION instead: ', - 'the control-plane allowlist, `publicFormGrant` (public form views), share-link tokens ', - "(read as SYSTEM), and `book.audience: 'public'` (ADR-0046 §6.7). The key is dropped ", - 'with a notice rather than mapped — there is no replacement value, only a different ', - 'way to publish (by declaration). A stack that mounts no auth at all now fails at boot ', - 'when it would serve a data API, instead of receiving an implicit fail-open.\n\n', - 'The same major retires `BatchOptions.validateOnly`: a batch "dry-run" flag that ', - 'was declared but never implemented — every batch surface (`updateManyData` / ', - '`deleteManyData` / `batchData`) persisted regardless, so a caller sending it to PREVIEW a ', - 'mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag ', - 'lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the ', - 'key with the prescription. It is HTTP-only (never stored in stack metadata), so the ', - 'change is one semantic TODO for API callers rather than a stack conversion.\n\n', - 'The batch response rows converge on their declared schema in the same window: ', - 'the per-row results of `/batch`, `/updateMany` and `/deleteMany` used to carry a legacy ', - 'implementation shape — `error: string`, `record`, no `index` — while ', - '`BatchOperationResultSchema`, the published client SDK type and the reference docs all ', - 'declared `errors: ApiError[]` / `data` / `index`. A consumer written against the ', - 'declaration read `row.errors` and got `undefined` at runtime — the exact "photographed ', - 'from the schema, dead on the wire" failure the enhanced-api-error rename above records. ', - 'The wire now delivers the declared shape, and the ADR-0119 D4 rollback marking is ', - 'structured with it: `ROLLED_BACK:` / `NOT_ATTEMPTED:` message prefixes become ', - 'first-class `ApiError.code` values, so clients branch on `errors[0].code` instead of ', - 'regexing message strings. A RESPONSE surface, never stored in stack metadata, so there ', - 'is no source for the chain to rewrite — one semantic TODO for readers of the old row ', - 'keys.\n\n', - 'It also narrows `QueryAST.fields` to field names: the `FieldNode` union carried a ', - 'second `{ field, fields, alias }` nested-select member that nothing produced and nothing ', - 'consumed — every reader on the path treats the list as `string[]`, so the object form was ', - 'dropped by the SQL and memory drivers, projected as a column named "[object Object]" by ', - 'MongoDB, and refused by the REST ingress as an unknown field of that name. `expand` is the ', - 'one spelling for nested selection (ADR-0049 enforce-or-remove; Prime Directive #12: one ', - 'capability, one contract). Like the two above it is a request shape, never stored, so the ', - 'chain has no source to rewrite.\n\n', - 'The `QueryAST` liveness sweep applies the same method to the rest of the request surface: `query.joins` ', - 'and `query.windowFunctions` are tombstoned — no engine or driver ever read either on the ', - 'query path, so every join and OVER clause a caller declared was silently dropped. Joins ', - 'were the second, broken spelling of related-record retrieval (`expand` is the live one; ', - 'the whole JoinNode cluster goes with the key), and window functions only ever ran behind ', - '`SqlDriver.findWithWindowFunctions()`, a driver-level door whose flat input shape the ', - 'spec vocabulary never matched (it declared `field`/`over`/`frame` members the door never ', - 'read — that cluster goes too). Request shapes again: two semantic TODOs, no source ', - 'rewrite.\n\n', - 'That sweep\'s close-out settles the remaining three. `having` is ENFORCED, not removed — ', - 'the engine applies it after aggregation on both paths, so the clause every SQL-literate ', - 'author expects now works (no migration; queries that carried it were silently returning ', - 'every group and now filter as written). `cursor` and `distinct` are tombstoned WITH ', - 'their shipped SDK producers (`QueryBuilder.cursor()` / `.distinct()` are deleted): no ', - 'driver ever implemented keyset pagination or SELECT DISTINCT, `cursor` re-served page 1 ', - "forever, and `distinct`'s only observable effect was mis-wired — it suppressed the REST ", - 'list count, which is now truthful again. Both are request shapes; two more semantic ', - 'TODOs, no source rewrite.\n\n', - 'The same kind of retirement covers `wait`\'s timeout pair. `waitEventConfig.onTimeout` ', - 'had ZERO readers — no path ever inspected it, so neither `fail` nor `continue` ever ', - 'happened, while its `.default(\'fail\')` stamped a decision nothing made onto every wait ', - 'node. `waitEventConfig.timeoutMs` said "maximum wait time before timeout" and its only ', - 'reader used it as the timer DURATION when `timerDuration` was absent: it did something, ', - 'just not what it said. Together they declared a timeout `wait` does not have — the run ', - 'resumes when its timer elapses or its signal arrives, never on a deadline. Rather than ', - 'retrofit an implementation to fit two keys that happened to be declared, the pair is ', - 'retired and real timeout semantics are left to be built to a requirement. `timeoutMs` ', - 'converts to `timerDuration` (stringified — the target is `z.string()` and ', - '`parseIsoDuration` reads a bare numeric string as milliseconds, so the wait is unchanged); ', - 'with `timerDuration` already set it is dropped, having been dead metadata. Like the other ', - 'keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the ', - 'load path: absorbing them silently would let an author keep believing they configured a ', - 'timeout.\n\n', - 'Closing the same audit on the data side, `datasource.readReplicas` is removed. ', - 'It described replica connections nothing ever opened: `ConnectableDatasource` and ', - '`DatasourceConnectionSpec` carry no replicas field, the driver factory never reads the ', - 'key, and no query path distinguishes a read from a write — read/write splitting does not ', - 'exist in the platform, so every statement always went to the primary. A lossless delete ', - 'with no target to move to; front replicas behind one endpoint (pgpool, ProxySQL, an RDS ', - 'reader endpoint) and point `config` at it. Notable as the case that shows how a key gets ', - 'MORE convincing as it stays dead: the change that closed the datasource-config gap taught the ', - 'schema to validate each replica entry against the declared driver\'s config contract, so ', - 'sources written in between carry replica blocks that were genuinely checked — precise ', - 'hosts, correct port types, typos rejected. Precision applied to an inert slot reads as ', - 'evidence the slot is live, which is why ADR-0049 asks for a consumer rather than for ', - 'rigor. Retired from the load path with the rest of the keys that misdescribed themselves.\n\n', - 'The datasource close-out also graduates the four legacy `datasource.config` spellings the ', - 'shared driver factory still tolerated via undeclared read-side `??` fallbacks (a follow-up to that ', - 'validation change): sqlite `file`/`database` (use `filename`), postgres/mysql ', - '`connectionString` (use `url`) and `user` (use `username`), and mongo `uri` (use `url`) ', - 'and `user` (use `username`). That validation change made the authoring gate reject each with a rename ', - 'hint, but a runtime datasource persisted in `sys_metadata` before the gate kept working ', - 'only because the factory read leniently — and deleting that tolerance without a ', - 'conversion would have silently moved data (a stored sqlite `file:` row falls back to ', - '`:memory:`). The `datasource-config-driver-key-aliases` conversion rewrites the stored ', - 'shape to the canonical keys at every rehydration seam, the factory now reads exactly one ', - 'spelling per key, and the four `??` chains are deleted. Driver-aware by construction: ', - '`database` renames only under sqlite, where it aliased the file path — for every other ', - 'driver it is a canonical key and is untouched. Retired from the load path not for lying ', - 'but because the authoring gate already rejects the spellings loudly; the chain and the ', - 'stored-row replay are the seams that accept them.\n\n', - 'Finishing the same datasource surface, the canonical driver id `mongo` is renamed to ', - '`mongodb`. The two spellings have both been accepted since `datasource.config` was first ', - 'parsed against its driver\'s own contract, and both still ', - 'are, so no boot breaks and no data moves — what changed is which one is CANONICAL, and ', - 'that string is published as `DRIVER_CATALOG.id` and is what the Studio connection form ', - 'writes into `datasource.driver`. Every row written before the rename therefore carries ', - '`mongo` while the form now emits `mongodb`, leaving one deployment with two spellings of ', - 'one driver and any reader that matches a stored driver against the published catalog id ', - 'silently missing the older rows. The `datasource-driver-mongo-to-mongodb` conversion ', - 'converges the stored value at every rehydration seam; it stays on the LIVE load path ', - '(unlike the config-key aliases beside it) precisely because `mongo` is still legal — ', - 'there is no loud rejection for it to pre-empt, and nothing to lose by converging early. ', - 'The rename is what let the driver-selection id and the config-contract id become one ', - 'string: `packages/spec`\'s driver vocabulary is now a single table both boot hosts read, ', - 'which closed the last fork where `OS_DATABASE_DRIVER=pg` booted under `os start` and was ', - 'refused by `os migrate`. `turso`/libSQL joins the same table with a real config contract, ', - 'so a libSQL `config` is validated instead of waved through.\n\n', - 'The `script` flow node converges on its one real path. It had four ways to name ', - 'what it ran and only one of them ran anything: `config.actionType: \'email\' | \'slack\'` ', - 'were logger-backed stubs that wrote a line, reported success and delivered nothing under ', - 'any configuration — with `config.template` / `.recipients` / `.variables` feeding a ', - 'message no channel ever sent; inline `config.script` was recognized and never executed ', - '(the built-in runtime has no server-side JS sandbox), so the node warned and no-op\'d; and ', - 'every other `actionType` value was shorthand for a registered-function name, a second ', - 'spelling of `config.function`. All five keys are retired and `function` becomes required, ', - 'which is also what finally made the contract PARSEABLE: while the legal key set depended ', - 'on `actionType`, a flat parse would either reject valid shapes or wave everything through, ', - 'so `script` (with `subflow`) now runs through the same execute-time config `parse()` the executor wiring ', - 'gave the flat builtins. A shorthand `actionType` CONVERTS into `function` — that is what ', - 'it meant — unless `function` is already set, in which case it was dead metadata the ', - 'executor never reached. The other four are dropped outright: nothing read them, so there ', - 'is no value to preserve, and rebuilding the intent is an authoring decision the tombstones ', - 'prescribe per branch (a `notify` node for mail — it delivers through the messaging ', - 'service, the in-app inbox by default and real email once `@objectstack/plugin-email` is ', - 'installed; a `connector_action` with the Slack connector, or an `http` node posting to a ', - 'webhook, for Slack; a registered function for an inline body). Retired from the load path ', - 'for the same reason as the rest: absorbing `actionType: \'email\'` silently would let an ', - 'author keep believing the flow sends mail.\n\n', - 'The same audit reaches the driver contract itself: `IDataDriver.findStream` is removed ', - 'from the contract. It was REQUIRED — every driver and every test double had to implement it — and ', - 'documented as the read "optimized for large datasets to avoid memory overflow", while ', - 'two of its three implementations awaited `find()` for the whole result set and then ', - 'yielded it row by row, reaching exactly the peak it promised to avoid; the third ', - 'streamed for real but was the one read in that driver that skipped `buildFindOptions`, ', - 'so it dropped `query.fields`. Nothing anywhere called it, which is why a contract ', - 'method could carry an inverted guarantee for this long and why ~20 test doubles could ', - 'satisfy it by throwing `not implemented`. Paged `find()` is the read that exists and is ', - 'enforced (its total-order guarantee is checked by the shared pagination-conformance ', - 'cases); a cursor-based read is worth building when a caller asks for one, which is the ', - 'honest order. A TS/API surface, never stored — one semantic TODO for driver authors, no ', - 'source rewrite, and no tombstone: `DriverInterfaceSchema` describes a contract that ', - 'code IMPLEMENTS and nothing ever `.parse()`d a driver, so tsc is the only channel that ', - 'could carry the prescription, and it carries it where it matters — at a call site.\n\n', - 'Separately, `object.managedBy: \'system\'` is retired in favour of `\'system-data\'`, ', - 'finishing the split ADR-0103 began in v16. That split was deliberately ADDITIVE: the 20 ', - 'engine-owned objects moved to the new explicit `engine-owned`, and the 8 admin/user-', - 'writable ones — the RBAC link tables, `sys_user_preference`, the three messaging config ', - 'grids — stayed behind on `system`. What was left is a value whose name describes the half ', - 'that had already moved out: "system" sitting on precisely the objects a user writes. That ', - 'is not a cosmetic complaint. An author choosing between `system` and `engine-owned` had ', - 'nothing in the vocabulary to choose on, so the bucket was re-overloadable by anyone ', - 'reading the name in good faith — a model author most of all. `system-data` states both ', - 'boundaries: the SCHEMA is the platform\'s (versus `platform`, which is tenant-modelled), ', - 'the DATA is the admin\'s or the user\'s (versus `engine-owned`, where the engine owns both). ', - 'Reusing `config` was considered and rejected — `sys_user_preference` is user-owned rather ', - 'than admin-authored, and `config` suppresses CSV import — as was `platform-data`, which ', - 'sits one word away from the unrelated `platform` in the same closed enum and would ', - 'reintroduce the confusion at the point of choosing. Because v16 already drained the ', - 'engine side, the conversion is a ONE-TO-ONE mechanical value rename with no judgement ', - 'call. One deliberate consequence: `system` defaulted LOCKED and each object re-opened its ', - 'writes through `userActions`, while `system-data` defaults WRITABLE on create, edit, ', - 'delete and exportCsv, so those blocks become redundant and are deleted (keep ', - '`userActions` only to NARROW). CSV `import` is the one verb that default deliberately ', - 'withholds (maintainer ruling 2026-08-03): it stays opt-in per object via `userActions: { import: true }`, so a ', - 'v16 `system` object — which resolved `import: false`, because the re-open blocks only ', - 'ever named create/edit/delete — keeps resolving `import: false` after the rename. The ', - 'reason is leverage, not authorization: three of the eight members are the RBAC link ', - 'tables, and a bulk-grant entry point on the permission model\'s grant surface should be ', - 'a per-object declaration rather than something inherited by being filed in the right ', - 'bucket. No enforcement ', - 'moves — the engine write guard, the DelegatedAdminGate, RLS and permission sets all ', - 'adjudicate off resolved affordances and the principal, never off the bucket name; ', - '`system-data` simply joins `platform`/`config` as a bucket the guard does not cover, ', - 'because a writable default has nothing to fail closed on. Retired from the load path: ', - 'the enum rejection is what teaches the new spelling, and absorbing `\'system\'` silently at ', - 'load would leave every author writing the name this rename exists to retire.\n\n', - 'Finally, five keys retire because the advisory lint could never have warned about them ', - '— mapping `extractQuery` / `errorPolicy` / `batchSize`, and app ', - '`contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, ', - 'and a default materialises at parse time — so the liveness lint cannot tell a value the ', - 'author wrote from one the schema supplied, and marking them would have warned on every ', - 'mapping and every selector in existence. For a key in that state removal is not the ', - 'escalation after a warning; it is the only channel that ever reaches the author, which ', - 'is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What ', - 'they claimed: `extractQuery` promised an export path no exporter implements (exports go ', - 'through the ordinary query API); `errorPolicy` offered skip/abort/retry where error ', - 'handling belongs to the import REQUEST; `batchSize` sized batches the write path sizes ', - 'itself; `placement` offered a topbar that places nothing. `includeAll` is the one worth ', - 'reading twice — it was not unread but deliberately DISOBEYED, because context selectors ', - 'are mandatory-scope and an "All" row would clear the scope: on Studio\'s package selector ', - 'that means listing the platform\'s own system/cloud kernel packages to a developer who ', - 'scoped to their package. `STUDIO_APP` authored `includeAll: true` against a renderer that ', - 'ignored it. The mapping prescription for `batchSize` deliberately offers no rename: ', - 'bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor `batchSize` are all ', - 'live, but each is a different key sizing its own path — the same trap `datasource.', - 'retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier.\n\n', - 'The sharpest removal in this step is two keys wide: `app.areas[].visible` and ', - '`app.areas[].requiredPermissions`. Read the class before the count — these were ', - 'not inert authoring keys but FAIL-OPEN access gates. At the time of the retirement the ', - 'server-side authority (`filterAppForUser`) checked the app\'s `requiredPermissions` and ', - 'then walked ONLY the top-level `navigation` tree; it never read `item.areas` at all, and ', - 'the client rendered every area in the switcher. So an author writing ', - '`requiredPermissions: [\'sales.admin\']` on an area got a clean parse, a stored value, and ', - 'an area visible to everybody — and had every reason to believe otherwise, because the SAME ', - 'key names are genuinely enforced one ', - 'level up and one level down: app-level `requiredPermissions` drops the whole app ', - 'server-side, and a navigation ITEM\'s `requiredPermissions` / `requiresService` are ', - 'stripped server-side and re-checked in the shell, whose item-level `visible` is a real ', - 'CEL gate. Three layers, of which the middle one was theatre. Enforcing instead was ', - 'weighed and deliberately not taken here: it needs semantics decided first (does ', - 'filtering an area remove its items everywhere? does the server bind `user` for area ', - 'CEL?), and a retirement must not invent an authorization mechanism — while shipping a ', - 'major with the gate still declared would have kept authors writing it for all of 17.x. ', - 'The rewrite is lossless in outcome (the keys changed nothing), so what an upgrading ', - 'author has to re-decide is only where the gate really goes: onto the items inside the ', - 'area, or onto the app — and BOTH of those destinations are server-enforced. The caveat ', - 'this prescription used to carry, that per-item gating INSIDE an area was enforced by the ', - 'shell only because the server did not walk `areas`, was CLOSED by a server-side fix inside this same ', - '17.0.0 window: `filterAppForUser` now runs the SAME `filterNav` over every ', - '`areas[].navigation`, so an ITEM\'s `requiredPermissions` / `requiresService` is stripped ', - 'server-side in BOTH trees and a gated entry never ships in the `/meta` body at all. Read ', - 'that as the boundary closing, NOT as the area-LEVEL keys coming back: those stay retired ', - 'and that fix gave an area no gate of its own — what it enforces are the items inside one. ', - 'The other half of the asymmetry is unchanged and is why `requiredPermissions` is the key ', - 'to reach for: `visible` (CEL) and `requiresObject` are still evaluated client-side ONLY ', - 'at every level, because server-side CEL needs a bound `user` context the read layer does ', - 'not have — so anything that must never reach the browser goes in `requiredPermissions`, ', - 'never in `visible`.\n\n', - 'The same window converges the retry policy. `@objectstack/spec/automation` and ', - '`@objectstack/spec/system` each exported a `RetryPolicy`/`RetryPolicySchema` resolving ', - 'to a DIFFERENT declaration, so which shape a consumer got depended only on the import ', - 'path — yet both computed `delay = base * multiplier^(retry-1)` and both ', - 'executors implemented that same formula. One declaration now serves both entries with ', - 'the union of their capabilities, so `job.retryPolicy` gains the `maxRetryDelayMs` ceiling ', - 'and `jitter` (both enforced in `runWithPolicy`, not merely declared — jitter is what stops ', - 'a fleet of jobs that failed on one outage from retrying in lockstep). The single ', - 'authorable casualty is the automation spelling of the base delay: `retryDelayMs` → ', - '`backoffMs`, a pure rename that replays losslessly and is what the already-enforced ', - 'retry policies (`job.retryPolicy`, `hook.retryPolicy`) call it.\n\n', - 'The subtle half is the defaults, and it is worth stating because no gate can see it: ', - '`job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` while the automation ', - 'shape defaulted 0 / 1, and the authorable-surface gate compares KEY SETS — a changed ', - 'default is invisible to it, to the tombstone mechanism and to `spec_changes` alike. The ', - 'merged declaration takes 0 / 1 (retry replays side effects, so it is opt-in — the same ', - 'reading already recorded in `flow-retry-max-retries-required`), and the conversion writes ', - 'the pre-17 numbers into every existing `job.retryPolicy` that omitted them. Deployed ', - 'stacks therefore keep their exact behaviour; what changes is only what a NEWLY authored ', - 'omission means.\n\n', - 'That convergence then had to be finished twice more, and WHY it was incomplete is the ', - 'part worth carrying forward. It was driven by the dual-source instrument, ', - 'which asks "how many declarations publish the same exported NAME?" — so it could not see ', - 'the two encodings of the identical policy that have no exported name at all, being ', - 'anonymous inline blocks nested in a bigger schema: `flow.errorHandling` and ', - '`ETLPipeline.retry`. The instrument was not broken and answered its own question exactly; ', - 'that question was simply not "how many shapes does this ONE concept have?", which is what ', - 'everybody read off it. The cost of the gap is concrete and falls on the author who did the ', - 'right thing: `shared/retry-policy.zod.ts` tombstoned `retryDelayMs` and told them to write ', - '`backoffMs`, and `flow.errorHandling` then rejected `backoffMs` and demanded `retryDelayMs` ', - '— reading the newer file was punished. Both blocks now build from one shared shape. ', - '`flow.errorHandling` costs nothing beyond the same `retryDelayMs` → `backoffMs` rename ', - '(every other key, bound and default already matched, which is exactly why it looked ', - 'reviewed), and the conversion covers it. `ETLPipeline.retry` costs a rename of the COUNT ', - '— `maxAttempts` → `maxRetries`, same number, do NOT subtract one: that adjustment belongs ', - "to `integration/connector.zod.ts`'s identically-spelled `RetryConfig.maxAttempts`, which ", - 'INCLUDES the first attempt — plus the same default flip (3 → 0) and three keys it never ', - 'had (`backoffMultiplier` / `maxRetryDelayMs` / `jitter`, so a nightly warehouse pipeline ', - 'can stop retrying flat, uncapped and unjittered every 60s). The ETL half gets a tombstone ', - 'and no conversion step, deliberately: an ETL pipeline is not a `defineStack` collection ', - 'and `etl.zod.ts` has no parse site in any of the three repos, so there is no stored ', - 'document to walk and a step for it would advertise coverage it does not have. Nothing ', - 'deployed moves; the migration surface is empty and this is the cheapest this convergence ', - 'will ever be.\n\n', - 'The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` drops ', - '`data.field.changed`. It had no producer anywhere — the engine emits ', - '`data.record.{created,updated,deleted}` and, since predicate writes got their own bulk event, `data.records.{updated,', - 'deleted}` — so a subscriber switching on it held a branch that could never run, and ', - 'the `switch` still compiled, which is why an empty member could sit in a public enum ', - 'this long. It could not have been implemented against this contract as written: ', - '`DataEventSchema` is record-shaped and has no `field` / `oldValue` / `newValue` slot, ', - 'so the member advertised a granularity the payload has no room for. Nothing is lost — ', - 'per-field detail already rides on `data.record.updated` as `changes` (with `before` / ', - '`after`), one event per write instead of N on a wide table. Like the driver contract ', - 'above it is a runtime surface, never stored in stack metadata, so it is one semantic ', - 'TODO for event consumers rather than a source rewrite, and it carries no tombstone: a ', - 'removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` ', - 'retirement noted. Should a real per-field stream ever be wanted, it earns its own ', - 'contract, as the bulk `data.records.*` events did, rather than reclaiming this slot.\n\n', - 'The object capability block closes out the same ADR-0049 pass: `enable.trash` and ', - '`enable.mru` left the schema in the 16.x line (the close-out of the 11.0 dead-property removals — every ', - 'delete has always been a hard delete and MRU tracking was never implemented, so both ', - 'default-true flags gated nothing), and the `.strict()` capabilities block rejects them ', - 'with the prescription. This step registers the migration surface that removal was ', - 'missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, ', - 'and `os migrate meta --from 16` lists the mechanical edits for authored sources. Soft delete stays parked at ', - 'the proposal stage; if built it returns as a live enforced flag rather than by reviving these keys.\n\n', - 'The same enforce-or-remove pass retires the `RestServerConfig.openApi31` block: ', - '`OpenApi31ExtensionsSchema` (`webhooks` / `callbacks` / `jsonSchemaDialect` / ', - '`pathItemReferences`) with `OpenApiWebhookEventSchema` and `CallbackSchema` under it. ', - "Declared-but-unenforced end to end: the REST server's `normalizeConfig` forwards only ", - '`api`/`crud`/`metadata`/`batch`/`routes`, the served /openapi.json is the pre-generated ', - 'contract enriched with the live server URL and registered objects, and `gen:openapi` ', - 'never read a webhook or callback — so a definition authored under `openApi31.webhooks` ', - 'never appeared in any served document, and zero import-level consumers existed across ', - 'objectstack / cloud / objectui. `RestServerConfig` is plugin TS configuration (the REST ', - 'plugin constructor / `plugin-hono-server` `restConfig`), never a stored metadata shape: ', - "the stack tree's own `api` block declares only its four scoping/auth knobs, so no ", - '`sys_metadata` row can carry `openApi31` and there is no source for the chain to ', - 'rewrite — one semantic TODO for config authors rather than a stack conversion, the ', - '`validateOnly` shape. The key itself is tombstoned (the schema is not `.strict()`; a ', - 'plain delete would strip it silently), and a config-driven webhooks/callbacks synthesis, ', - 'if ever wanted, returns via the enforce route of ADR-0049 through a new ADR.\n\n', - 'The same pass closes `activationEvents`: both keys that carried it — ', - '`DynamicLoadRequest.activationEvents` on the kernel side and ', - '`StudioPluginManifest.activationEvents` on the studio side — declared lazy plugin ', - 'activation ("plugins remain dormant until an activation event fires") that no runtime ', - 'in any repo ever implemented: every plugin has always activated immediately on ', - "load/registration, and cloud-v1's own ROADMAP recorded the capability as ", - 'unimplemented, planned for v0.4.0. A dual-source cleanup had just converged the two ', - '`ActivationEventSchema` declarations onto one structured `{ type, pattern }` ', - "vocabulary in this same unreleased major; with the maintainer's enforce-or-remove ", - 'ruling landing on REMOVE, that converged vocabulary retires before ever shipping — ', - 'composed across the two changes, a v16 author simply deletes the key in whichever ', - 'form they carried. Neither parent is stored metadata (`StudioPluginManifest` is TS ', - 'configuration parsed by `defineStudioPlugin`; `DynamicLoadRequest` is a runtime ', - 'request shape with no caller in any repo), so there is no source for the chain to ', - 'rewrite — one semantic TODO, the `validateOnly` shape. The kernel key is tombstoned ', - '(its schema is not `.strict()`; a plain delete would strip it silently), the studio ', - 'key is rejected by the strict manifest parse with its own guidance prescription, and ', - 'the orphaned `ActivationEventSchema` def is removed with them. Behaviour is ', - 'byte-identical: eager activation was always the only behaviour.\n\n', - 'That kernel-side tombstone was then SUPERSEDED inside the same unreleased major by ', - 'a removal that finished the enforce-or-remove pass one level up: the entire ', - '`plugin-runtime.zod` family — `DynamicLoadRequest`, `DynamicUnloadRequest`, ', - '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation` — is removed, because ', - 'the "Dynamic Loading" capability it described (runtime load / unload / reload without ', - 'a kernel restart, with sandboxing, integrity hashes, drain strategies and ', - 'dependent-cascade policy) has no server anywhere: no runtime in objectstack, cloud or ', - 'objectui ever received one of these requests or produced one of these results. An earlier removal from that module ', - 'had suspended the call on these five deliberately — "operation contracts, not security ', - 'promises" — in a changeset paragraph no issue carried; this removal is that decision, ', - 'answered REMOVE. So a v16 author who wrote `activationEvents` inside a ', - '`DynamicLoadRequest` value does not delete a key: the whole value has no shape and no ', - 'recipient, and importing `DynamicLoadRequestSchema` at all is TS2305 in v17. The ', - 'studio half of the `activationEvents` retirement is untouched and still rejects the ', - 'key with its own prescription — `defineStudioPlugin` remains a live authoring surface. ', - 'Behaviour is again byte-identical: nothing ever executed a dynamic plugin operation.\n\n', - "Finally it removes the script-body capability token 'crypto.hash'. Four layers ", - 'declared it — the `HookBodyCapability` enum, the doc table beside it, the CLI extractor ', - 'and `ScriptContext.crypto.hash` — and none implemented it: `installCtx` wired only ', - '`randomUUID`, so the one call the token authorised threw inside the VM every time. The ', - 'build-time inference made it worse than an ordinary declared-but-unenforced key: writing ', - '`ctx.crypto.hash(...)` made the CLI ADD the capability for you, so `os build` went green ', - 'on the body that was guaranteed to fail at the first record write. Removed rather than ', - 'implemented (ADR-0049) — hashing inside the sandbox widens its capability and ', - 'security-review surface, and a capability that throws on every use yet drew zero ', - 'complaints in its whole life is its own liveness verdict. This is an enum VALUE, not a ', - 'key, so there is no `retiredKey()` tombstone: the enum error map carries the ', - 'prescription, keyed on the received value so that only the spelling which used to be ', - 'legal is told it "was removed". The conversion strips the dead token from ', - '`body.capabilities` on hooks and actions; it deliberately does NOT touch the ', - '`ctx.crypto.hash(...)` call the body made under it, which never returned a value and ', - 'which the author must delete. Hashing returns only WITH an implementation, through the ', - 'capability admission process.\n\n', - "It also removes `connector.rateLimitConfig` and its whole shape. This one is not ", - '"declared but unread" — it is declared but UNIMPLEMENTED, one step worse. The only token ', - "bucket the platform owns (runtime `security/rate-limit.ts`) is INBOUND: the dispatcher ", - 'calls `consume(key)` on a request fingerprint and answers 429. Nothing anywhere throttles ', - 'the calls a connector makes OUT, and no provider — `connector-rest`, `connector-openapi`, ', - '`connector-mcp`, `connector-slack` — reads the key or has a seam that could. So ', - '`strategy`, `maxRequests`, `windowSeconds`, `burstCapacity`, `respectUpstreamLimits` and ', - '`rateLimitHeaders` parsed cleanly and capped nothing, on a surface where the author ', - "believed they had bounded their spend against a third party's quota. `ConnectorRateLimit", - 'Config` and the `RateLimitStrategy` enum it embedded had no other consumer and are removed ', - 'with the key, so importing either is TS2305 in v17 — the `plugin-runtime.zod` shape, and the same ', - 'implementation-first ruling: the vocabulary comes back WITH the engine, in one change. ', - 'It is deliberately NOT converted to `shared` `RateLimitConfig`, which limits the calls ', - 'others make to US; the dual-source cleanup split their names for precisely this confusion, and rewriting an ', - 'outbound cap into an inbound one would throttle the wrong direction. Delete the key and ', - 'rate-limit where the calls are actually made — the connector provider or upstream gateway.\n\n', - 'Last, it removes `dashboard.widgets[].responsive` — the straggler of the close-out ', - 'sweep above, which retired the literally same-named `view.responsive` on the same ', - 'evidence four days earlier. Re-measured before removal: no objectui code reads ', - '`widget.responsive` (DashboardRenderer, DashboardEditor and plugin-designer name it only ', - 'in comments), and there are zero authored instances repo-wide, so the conversion is ', - 'expected to be a no-op on every real source — it exists so that a stored dashboard ', - 'carrying the key is cleaned deterministically rather than meeting the tombstone at load. ', - 'What kept it alive was not evidence but a hole in the instrument: the liveness ledger ', - 'declares no `children` on `dashboard.widgets`, and the walk only drills one level through ', - 'an explicit `children`, so no widget-level key has ever been classified at all (filed as ', - 'its own finding, fixed separately). The removal was deliberately narrow — it took the widget EMBED, ', - 'not the shape, which at the time was believed live on `page.components[].responsive` via ', - 'objectui `useResponsiveConfig`. CORRECTED in 2026-08: that belief measured false ', - '— the hook had zero callers, nothing read the page key either — so protocol 18 retires ', - '`page.components[].responsive` and the `ResponsiveConfig` shape with it (see the ', - '`page-component-responsive-removed` step-18 entry). Authors who need breakpoint behaviour ', - 'use `responsiveStyles` (ADR-0065), the channel objectui really compiles. Per-widget ', - 'responsive layout returns if and when a renderer implements it.\n\n', - 'Finally it CONVERGES `dashboard.widgets[].compareTo` — the one entry in this step ', - 'that is not a removal but a vocabulary merge, and the one whose defect was worst-shaped. ', - 'The widget declared three arms with confident TSDoc; the analytics executor implements one ', - 'contract, `DatasetSelection.compareTo` = `{ kind, dimension? }`, which has no `offset` in ', - 'it. On the ADR-0021 dataset path the two string arms were DROPPED by the renderer (a ', - 'comparison silently absent from a widget whose author asked for one) and `{ offset }` was ', - 'forwarded into that contract with no dimension, so the executor threw ', - '`compareTo requires a timeDimension "undefined"` and errored the whole widget. All three ', - 'arms worked on the legacy inline chart path. Same key, two fates — and the failing one was ', - 'the path the spec itself calls canonical, which is why this ranks above an ordinary ', - 'declared-but-unread key: the documentation was actively teaching a shape that crashes. ', - 'The widget now declares the executor\'s own words, so `declared = enforced` holds by ', - 'construction with no second vocabulary left to drift. `dimension` is optional and resolved ', - 'by the EXECUTOR (one dated time dimension → that one; zero or several → a loud error ', - 'naming the candidates), which is a producer-side resolution rule, not the consumer-side ', - 'tolerance PD #12 forbids. The bare strings and `{ offset: \'1y\' }` replay mechanically; ', - 'every other `{ offset }` duration is a semantic TODO below, because `previousPeriod` ', - 'shifts by the resolved window\'s own length and rewriting `7d` into it would change which ', - 'rows the comparison counts. The converged slot is also union-free, which is not cosmetic: ', - 'zod collapses a failed union into one bare `Invalid input`, and a top-level-only error mapper showed that curated ', - 'guidance inside a union arm never reaches the author at all.\n\n', - 'The same widget drill retires four more keys: the action trio ', - '`actionUrl`/`actionType`/`actionIcon`, and `aria`. The trio described a per-widget action ', - 'BUTTON that no renderer in either repo has ever drawn — all 14 `actionUrl` reads in ', - 'DashboardRenderer are scoped to `header.actions[]`, a different schema — and `actionIcon` ', - 'had zero references anywhere outside its own declaration. `aria` is the dashboard-level ', - '`aria` removed by the close-out sweep, one level down: declared ARIA attributes that never ', - 'reached the DOM, i.e. an accessibility guarantee an author could state and nothing ', - 'honoured. It survived that sweep for the same reason `responsive` did — `widgets` had no ', - 'ledger drill until the instrument hole above was fixed — not on evidence. This removal also settles a second-order cost ', - 'the trio was carrying: `packages/lint`\'s dashboard action-ref rule enforced ', - 'ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key ', - '"the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so ', - 'an author could FAIL A BUILD because a control that cannot render pointed at an action ', - 'that also did not. That widget branch is deleted with the keys. Lossless deletes in every ', - 'case — the keys contributed nothing to any rendered output — and the shared `AriaProps` ', - 'shape is untouched, staying live on `page.aria` / `page.components[].aria` and the list ', - 'view `aria` — not on `app.aria`, which this same major retires (see ', - '`app-dead-authoring-keys-removed`, which strips it). Move a ', - 'dashboard-wide affordance to `header.actions[]` (where `icon` is the header spelling of ', - '`actionIcon`); for per-row click-through use a dataset-bound `table`/`pivot`, whose rows ', - 'drill through the semantic layer already.\n\n', - '⚠️ One protocol-17 change turns metadata ON rather than off, and it is the one to read ', - 'first: declarative `apis:` endpoints EXECUTE from 17. The surface used to be inert ', - 'end to end — no route mounted, no matcher, every key including `authRequired` parsed and ', - 'enforced nothing — which is why publish at first refused a non-empty `apis:` outright. 17 ships the ', - 'executor and narrows that refusal to a per-endpoint publish gate, so an endpoint that ', - 'passes the gate is MOUNTED and serves traffic the moment it is published. Any historical ', - '`apis:` block therefore changes meaning without changing a byte. Review every entry before ', - 'upgrading, and pay particular attention to an explicit `authRequired: false`: the schema ', - 'default is `true`, so an omission is safe, and only that explicit `false` opens anonymous ', - 'access — which ADR-0121 D6 now pairs with a mandatory armed `rateLimit` ', - '(`enabled: true`; the key defaults to `false`, so a budget written without it meters ', - 'nothing). Paths also move under the namespace carve-out `/api/v1/apps//', - '` (ADR-0121 D1/D2). The full checklist is the `declarative-apis-endpoints-live` ', - 'semantic entry below; it is a security review, not a rename, so nothing about it is ', - 'applied for you.\n\n', - 'Finally, the theme token scales retire (ADR-0049): `typography.fontSize`, ', - '`typography.fontWeight`, `typography.lineHeight`, `typography.letterSpacing`, ', - '`typography.fontFamily.heading`, `typography.fontFamily.mono`, `animation` and `zIndex`. ', - 'These are the reverse of the usual inert key and the distinction is the point: the theme ', - 'engine DID emit them — `--font-size-*`, `--font-weight-*`, `--line-height-*`, ', - '`--letter-spacing-*`, `--duration-*`, `--timing-*`, `--z-*`, `--font-heading`, ', - '`--font-mono` all reached the document exactly as authored — and no first-party component ', - 'or stylesheet has ever read one, so a declared type scale was real CSS that styled ', - 'nothing. That is why the earlier theme sweep left them standing: its criterion was ', - '"never emitted", and these are emitted. `colors`, `borderRadius`, `shadows` and ', - '`typography.fontFamily.base` have live consumers and are untouched. The prescription is ', - '`customVars`, which emits `--: ` verbatim — so a tenant stylesheet that really ', - 'was reading `--z-modal` reproduces it byte for byte and loses no capability. The ', - 'conversion DELETES the keys and emits a notice per key rather than auto-populating ', - '`customVars`: a rewrite would hand back two dozen variables that still nothing reads, ', - 'turning a dead semantic slot into a dead literal one. Deciding which of them you actually ', - 'consume is yours to make; the notice names each one. Retired from the load path with the ', - 'other keys that misdescribed themselves.\n\n', - 'It closes the enforce-or-remove line with two `./ui` vocabulary shapes that never ', - 'had a key to be written into: `NotificationActionSchema` / `NotificationAction` ', - 'and `EmbedConfigSchema` / `EmbedConfig`. This is the class BELOW a declared-but-unread ', - 'key — there was no key at all. No schema anywhere declared a carrier, a BFS from all 24 ', - 'metadata-type roots plus `ObjectStackSchema` reached neither (with `Page` / `Action` / ', - '`DashboardWidget` / `Webhook` / `SharingConfig` as positive controls in the same run, and ', - 'a synthetic carrier flipping both), and no repo parsed either outside its own unit test. ', - 'Each was left behind by an earlier retirement one level up: the notification action by ', - 'the dual-source cleanup that deleted the two wrapper shapes that could have carried it, and the embed ', - 'config by 17.0.0\'s own `App.embed` tombstone. The strictness wave\'s 批 14 measured both and deliberately ', - 'declined to close them with `.strict()`, because strictness on a shape nothing parses ', - 'enforces nothing and only makes a dead slot look load-bearing; this removal is that ', - 'deferred call, answered REMOVE. Nothing is applied for you and nothing needs to be — ', - 'there is no key in any source to rewrite; the change is visible only as TS2305 on an ', - 'import. ⚠️ Read the scope precisely, because one of the two modules SPLITS: ', - '`ui/sharing.zod` keeps `SharingConfigSchema` and it stays LIVE — `FormView.sharing` ', - 'carries it and `rest-server.ts` mounts the anonymous form routes on ', - '`allowAnonymous` + `publicLink`, so public form sharing is untouched — and ', - '`ui/notification.zod` keeps `NotificationType` / `NotificationSeverity` / ', - '`NotificationPosition`. Only the two named shapes go.\n\n', - 'The same is true of the protocol-17 retirement that closes this list, and the pair is ', - 'worth reading together (ADR-0049): the five `@objectstack/spec/ui` interaction-config ', - 'modules — `touch.zod.ts`, `dnd.zod.ts`, `keyboard.zod.ts`, `animation.zod.ts` and ', - '`offline.zod.ts`, 22 `z.object` sites and 64 exported names — are deleted whole, with ', - 'their reference docs. They were never reachable: no schema in the protocol declared a ', - '`touch:` / `dnd:` / `keyboard:` / `animation:` / `offline:` key, so no metadata document ', - 'could carry one and none needs rewriting now. The defect was on the DOCUMENTATION side, ', - 'which is the half that made it urgent — `authorable-surface.json` carried 109 keys under ', - 'these defs and the generated `references/ui/*` pages rendered them as authoring tables, ', - 'so an AI author reading `dnd.mdx` wrote a `dnd:` block that `PageComponentSchema` then ', - 'rejected as an unknown key. That is a published capability the runtime does not deliver ', - '(Prime Directive #10), not a strictness gap: closing the shapes would have validated a ', - 'slot nobody can reach. Business reading behind the ruling: these five are RENDERER ', - 'BUILT-IN behaviour, decided by the component library rather than authored per page; ', - 'offline is a platform capability whose vocabulary belongs on a sync engine that has not ', - 'been built. ⚠️ The `animation` here is `ui/animation.zod.ts` ', - '(`ComponentAnimation` / `MotionConfig` / `PageTransition` / `AnimationTrigger`), a ', - 'DIFFERENT surface from the theme `animation` block retired above with the token scales — that one had ', - 'a carrier key and got a tombstone; this one had none and gets deletion. The one name ', - 'worth checking on upgrade is the bare `ConflictResolution` type: it left with ', - '`ui/offline.zod.ts` and is now published by nobody. `ConnectorConflictResolution` ', - '(`@objectstack/spec/integration`, connector sync) and `ConflictResolutionStrategy` ', - '(`@objectstack/spec/api`, route merge policy) are different concepts under their own ', - 'names and are untouched.\n\n', - 'The last enforce-or-remove entry of this step is on the RUNTIME context rather than on ', - 'anything authorable: `HookContext.session.roles`. It was declared in ', - '`data/hook.zod.ts`, read by exactly two consumers — the approvals record lock and the ', - 'delegation write guard, each opening with `session.roles?.includes(\'admin\')` — and ', - 'produced by nobody on the hook path: ObjectQL\'s `buildSession()` writes the session ', - 'field by field (`userId`, `organizationId`, `accessToken`, `isSystem`, `actor`, the skip ', - 'flags) and has no `roles` write, here or in `cloud`, whose hook consumers read ', - '`hookContext?.session?.userId` and nothing else (an ACTION body\'s `ctx.session` is a ', - 'different untyped object that does carry one, tracked apart). So both branches were dead ', - 'on every real ', - 'engine path: an authorization decision in shape only, and — worse for a reader — a SECOND ', - 'admin dialect competing with the one ADR-0095 D3 sanctions. The approvals plugin deleted the ', - 'two readers on the maintainer\'s ruling; this step removes the declaration that outlived ', - 'them, which is what ADR-0049 asks for once a key has neither end. Nothing observable ', - 'changes: a key nobody wrote and nothing read cannot alter a single decision. It is ', - 'tombstoned rather than deleted because `HookContextSchema` is deliberately NOT `.strict()` ', - '(strictness there would make an engine-internal enrichment a breaking change for anyone ', - 'parsing a context they were handed, as `provenance` was for schedule-triggered runs), so a plain delete would ', - 'strip the key in silence — the ADR-0104 failure this whole pass exists to end. ', - 'There is NO conversion and no source rewrite: a HookContext is built per operation by the ', - 'engine and never stored, so no `sys_metadata` row, example or template can carry the key ', - '— the `openApi31` / `activationEvents` shape, one semantic TODO for hook authors. The ', - 'live vocabulary is untouched and deliberately elsewhere: gate on `session.userId` / ', - '`session.isSystem` in the hook, and judge PRIVILEGE through the security service, which ', - 'reads capability grants (`permissions`), placements (`positions`) and the derived posture ', - 'off the execution context.\n\n', - 'The same enforce-or-remove reading reaches the storage contract: ', - '`IStorageService.list(prefix)` is removed. It had no ', - 'consumer — the only in-repo call site was a proxy pass-through — and the two shipped ', - 'adapters answered it with two different, silently incomplete semantics: the local ', - 'adapter listed a single level and reported directories as files, the S3 adapter ', - 'recursed and stopped at 1000 objects without reading `IsTruncated` / ', - '`ContinuationToken`. Enumerating a prefix without a cursor is the wrong signature to ', - 'inherit, so nothing replaces it in place: query the file records you wrote, and let a ', - 'real caller bring back a cursor-shaped `list(prefix, { cursor, limit })` with ', - 'adapter-conformance cases behind it. Same shape and same disposition as the ', - '`findStream` retirement above — a TS/API contract, no stored source, no tombstone, ', - 'tsc at the call site.\n\n', - 'Finally it retires the two inert `IndexSchema` keys, `indexes[].type` and ', - '`indexes[].partial`. Neither ever had a DDL consumer: ', - '`SqlDriver.syncDeclaredIndexes` creates declared indexes through knex\'s `table.index()` / ', - '`table.unique()`, and the drift differ\'s `DeclaredIndexInput` carries only ', - '`name`/`fields`/`unique`/`nullSafeColumns` — so an authored `type` selected no access ', - 'method and an authored `partial` produced a FULL index with its predicate discarded. ', - '`partial` was the more damaging of the two because it read as a correctness control: the ', - 'platform\'s own `sys_metadata` declared it for overlay uniqueness, and what the ', - 'declaration alone materialized was an unrestricted unique index (the active-row scoping ', - 'is delivered by a runtime migration, `metadata-protocol`\'s `ensureOverlayIndex`, not by ', - 'the key). `type` was the louder: its `.default(\'btree\')` put an inert knob into every ', - 'parse output, so it read as live configuration — the ADR-0078 no-silently-inert shape. ', - 'Remove was chosen over enforce (maintainer ruling, 2026-08-06): enforcing needs ', - 'per-dialect algorithm mapping (`gin`/`gist` Postgres-only, `fulltext` MySQL-family), ', - 'raw-SQL `CREATE INDEX … WHERE` on the dialects that have partial indexes at all (MySQL ', - 'does not), and a redesign of how `isSyncReproducibleIndex` excludes partial indexes from ', - 'incremental sync — design cost for a capability nothing has asked for. Both are lossless ', - 'deletes: no DDL changes, because no DDL ever depended on them. Drift detection is ', - 'untouched — the `partial` flag it consumes is parsed back out of the database\'s OWN ', - '`CREATE INDEX` DDL and never came from this key.\n\n', - 'It also retires the field-mapping `transform` key and the whole five-member ', - '`FieldMappingTransform` union behind it: `constant` / `cast` / `lookup` / ', - '`javascript` / `map`, declared on `shared/FieldMapping` and inherited by ', - '`integration/ConnectorFieldMapping` and `data/ExternalFieldMapping`. Nothing ever ', - 'executed one. `fieldMappings` is spelled only inside `packages/spec` itself — the ', - 'connector packages, the automation engine, REST and objectui never read it, and no ', - 'code anywhere switches on `transform.type` — so all five members were ', - 'declared-but-unenforced together, not just the one that got the bug filed. That one ', - 'is the sharpest evidence though: `javascript`\'s `.describe()` recommended the ', - 'dialect `js`, which `ExpressionDialect` had already retired (ADR-0058 addendum), so the ', - 'envelope the documentation taught was rejected by the enum; the only spelling that ', - 'parsed was the bare string, which `ExpressionInputSchema` wraps as `cel`; and the CEL ', - 'that resulted could not evaluate the `value.toUpperCase()` the same line offered as ', - 'its example. Three surfaces disagreeing about a capability with no implementation ', - 'under any of them. Fixing the sentence alone was rejected (maintainer, 2026-08-06) as ', - 'gilding a member that cannot run. The key is tombstoned rather than deleted because ', - 'the schema and both extenders are plain `z.object`s and `ConnectorSchema.parse` is a ', - 'live receiver, so a bare deletion would strip silently. What is NOT affected, despite ', - 'the shared word: the import mapping\'s `mapping.fieldMapping[].transform`, a flat ', - 'string enum applied row by row by the REST import path and live in the liveness ', - 'ledger — including its own `javascript` value, which that path rejects with a 400 ', - 'rather than pretending to run.\n\n', - 'The last of the strictness wave\'s enforce-or-remove batches lands on two more `ui/` files (', - 'ADR-0049 — read it next to the interaction-config modules above, it is the same shape one batch later). ', - '`ui/widget.zod.ts` published a whole widget-REGISTRATION vocabulary — `WidgetManifest` ', - 'with `WidgetLifecycle` hooks, `WidgetEvent`s, `WidgetProperty` knobs and a ', - '`WidgetSource` npm/remote/inline implementation union — and `ui/i18n.zod.ts` published ', - '`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat` and `LocaleConfig`. Ten defs, ', - 'twenty exported names, and not one carrier key between them: nothing under ', - '`packages/spec/src` imported `widget.zod` at all, the only live imports of `i18n.zod` ', - 'name `I18nLabelSchema` / `AriaPropsSchema`, the BFS from all 24 metadata-type roots ', - 'plus `defineStack` reached none of them, and no repo ever parsed one. So again nothing ', - 'is applied for you and nothing needs to be — the change is TS2305 on an import, and a ', - '`field.widget: "my_picker"` string is untouched, because that key names a component the ', - 'RENDERER registered and never referenced `WidgetManifest`. ⚠️ Read this scope precisely ', - 'too, because BOTH files split. `ui/i18n.zod.ts` keeps `I18nLabelSchema` (the label ', - 'primitive the whole `ui/` tree imports) and `AriaPropsSchema` — a REAL door, carried as ', - '`aria:` on ~30 live shapes and closed by 批 16, untouched here. And `ui/widget.zod.ts` ', - 'keeps `FieldWidgetPropsSchema`, the one site of the nine whose evidence differs: it is ', - 'a React props contract rather than authorable metadata (it never appeared in the ', - 'authorable surface or the schema manifest — `onChange` is a `z.function()`), so having ', - 'no parse is its design; and an objectui change (2026-08-03) made it a live compile-time ', - 'consumer, renaming `@object-ui/fields`\' validation slot onto this contract\'s `error` ', - 'with no alias and pinning it as a deliberate tripwire. Retiring it would have broken ', - 'the one consumer the batch had, one day after it appeared. The measurement that decides ', - 'a site is the CURRENT one, not the one in the issue body.\n\n', - 'Last, it reconciles the SDUI component-props surface with the renderers that serve it ', - '— wiring the first parse gate `ComponentPropsMap` ever had found that the corpus it ', - 'landed on diverged in BOTH directions: keys objectui honours that the schema never ', - 'declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. ', - 'The maintainer ruled direction A (2026-08-06), the `record:details` sections rule again: the delivered and ', - 'authorized shape is the contract. So the honoured keys are declared ', - '(`element:record_picker` `labelField`/`valueField`/`label`/`emptyText`, `record:path` ', - '`stages[].terminal`, `page:tabs` `items[].value`/`items[].count`, `page:card` `children`, ', - 'and `children` on `page:section`/`page:footer`/`page:sidebar`, which were declared ', - '`EmptyProps` while their renderers rendered a child list), and four keys retire. Two are ', - 'synonym renames: `element:record_picker.displayField` → `labelField` (the required key no ', - 'renderer read, while `labelField ?? \'name\'` is what actually renders the row — so an ', - 'author who followed the schema got a picker listing `name` with no diagnostic, the ', - 'ADR-0078 shape), and `page:card.body` → `children` (one composition key across every ', - 'container; the card renderer already reads both, and the showcase authors `children`). ', - 'Two are enforce-or-remove deletions: `element:record_picker.searchFields` and ', - '`.multiple` — the control is a shadcn single-select with no search input, binding ONE ', - 'record id into a page variable, so `searchFields` narrowed nothing and `multiple: true` ', - 'selected nothing extra while reporting success. Either returns the day the capability ', - 'is implemented. Not in scope, and deliberately: `page:card.visible` is a ', - 'component-level visibility predicate written into `properties` and hoisted by the ', - 'renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.\n\n', - 'That count turned out to be incomplete, and a second pass finishes it: five more keys the ', - 'renderers read were still undeclared. Four are plain additions with no behaviour change ', - '(`page:header` `recordChrome`/`showStar`/`showCopyId`, which select between the ', - 'record-chip header and the bare heading a dashboard wants, and `page:accordion.variant`, ', - 'which decides whether the accordion draws its own dividers or leaves the border to each ', - 'panel). The fifth is a rename, and the only one in the family whose defect is structural ', - 'rather than an oversight: the tab strip\'s visual style was declared as ', - '`page:tabs.type`, which collides with the page component\'s OWN dispatch key. objectui\'s ', - '`SchemaRenderer` refuses to hoist `properties.type` for exactly that reason, ', - '`sdui-parser`\'s `BASE_PROPS` contains `type` and skips it before any validation runs, ', - 'and in a flat or JSX carrier the node reads `{ type: \'page:tabs\', … }` so the name is ', - 'already taken. The key was therefore unauthorable in every carrier but the nested ', - '`properties` object, and unvalidated even there. It becomes `tabStyle` — the spelling ', - 'objectui publishes and the renderer already reads first in the flat carriers — which is ', - '`displayField` → `labelField` again: converge on the spelling that works, not the one ', - 'that declares well, and keep one spelling rather than two (Prime Directive #12).\n\n', - 'A third pass closes that reconciliation from the other side, on three keys the two earlier ', - 'passes left standing (maintainer ruling 2026-08-09, decision-inbox round: retire the header icon and ', - 'card actions upstream, and the details layout). Two are the plain B class — declared here, read NOWHERE. ', - '`page:header.icon` is resolved by objectui only per header ACTION (`action.icon`); the ', - "header's own props bag is never asked for one, and `@object-ui/layout`'s `` ", - 'takes an `icon` React prop from a host with no schema fallback beside the ', - '`schema?.actions ?? schema?.properties?.actions` fallback four lines away. ', - '`page:card.actions` has no actions area to render into at all: the card renderer builds ', - 'its `` from `title`, `bordered`, `children` and `footer`, full stop. Both sat in ', - "objectui's own unpublished-exemption map as \"spec declares it, NO renderer read point\", ", - 'which is what put the contract decision — wire it, publish it with a KNOWN GAP marker, or ', - 'retire it — in front of the maintainer; the ruling retired it. Neither has a lossless ', - 'rewrite target (a header has no second icon slot, and moving a card\'s action ids into ', - '`children` as components is a page rewrite, not a mechanical one), so both are pure ', - 'strips. ⚠️ `page:header.actions` is LIVE and untouched — the strip is scoped by component ', - 'type, never by key name.\n\n', - 'The third, `record:details.layout`, is a sharper shape and the one worth reading twice: ', - 'it IS read. The renderer computes ', - "`schema.layout === 'inline' || schema.layout === 'compact' ? 'horizontal' : 'vertical'`, ", - 'while the declared enum is `auto | custom` — so neither legal value can match, both take ', - 'the same branch, and a key that was accepted and read still selected nothing, under a ', - '`.describe()` promising "auto uses object highlightFields, custom uses explicit sections". ', - 'The behaviour that prose describes is real, but the renderer keys it off whether ', - '`sections` was authored, never off this flag. Every gate stayed green because ', - '`check:react-declaration-parity` compares two DECLARATIONS and objectui declared the same ', - '`auto | custom` enum — perfect agreement over a key nothing honoured — while a THIRD ', - "spelling (`stacked | inline | compact`) sat in `@object-ui/types`' mirror. A pure strip ", - 'for the same reason: `auto`, `custom` and omission were behaviourally identical, so there ', - 'is no value to carry. ⚠️ `record:highlights.layout` is a different, live, honoured key ', - 'and is untouched. objectui\'s side of both rulings drops the exemptions, the input and the ', - 'dead branch on the next pin bump.\n\n', - 'Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave ', - '`AggregationFunction` (ADR-0049). The enum declared eight functions and the SQL ', - 'family compiles five — `SqlDriver.mapAggregateFunc` and the Turso ', - '`RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest ', - 'to one refusal — so three were declared-but-unenforced against the backends this platform ', - 'targets. What makes these two worse than an ordinary inert declaration is that another ', - 'package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and ', - '`string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they ', - "reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of ", - 'the requested value, with no error and no log. The maintainer SPLIT the three rather than ', - 'retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS ', - 'and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard ', - 'staple, already lowered by `service-analytics` — with its SQL implementation following on ', - 'its own card, so that declaration leads its implementation by decision rather than by ', - 'drift. These two take the remove leg: display conveniences with no measured pull, and ', - '`string_agg` never had one shape to lower to (the delimiter is a second argument in ', - 'PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). ', - 'This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no ', - '`retiredKey()` tombstone: the enum error map carries the prescription, keyed on the ', - 'received value so only the two spellings that used to be legal are told they "were ', - 'removed". Of the two authoring surfaces only one is stored metadata: the conversion ', - 'rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with ', - 'neither `aggregate` nor `derived` fails the dataset\'s own refinement, so stripping just ', - 'the key would emit an item that cannot parse) plus any derived measure the drop strands, ', - 'with a notice each. Nothing is lost: `compileDataset` refused both by name already, so ', - 'such a measure never produced a number. `QueryAST.aggregations[].function` is a request ', - 'surface with no stored source — one semantic TODO below. The mongodb and in-memory ', - 'backends that implemented these two were inside the maintainer\'s driver freeze when this was decided ', - '(it was lifted on 2026-08-11) and are untouched; their code is simply no longer reachable ', - 'through a spec-valid request.\n\n', - 'The same aggregation node loses one more member, and it is the sharper class of the two: ', - '`aggregations[].distinct` is removed (ADR-0049, maintainer ruling 2026-08-09). ', - 'The functions above were declared and UNLOWERED — a caller on a SQL datasource got a ', - 'refusal. This flag was declared and lowered by exactly ONE of the six faces that read an ', - 'aggregation: the engine\'s in-memory fallback deduplicated the values before applying the ', - 'function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, ', - '`driver-mongodb`\'s `buildAggregationStage`, `driver-memory`\'s `computeAggregate` and ', - 'service-analytics\' `AGGREGATE_SQL` all ignored it. So the same query answered a ', - 'deduplicated `sum` on the fallback path and an ordinary `sum` on every SQL datasource, ', - 'with the engine choosing between the two per query — by driver, by a non-UTC date bucket, ', - 'by whether the driver aggregates natively at all. That is the divergence class closed on this axis when ', - 'every SQL face got one aggregate spelling and one refusal, still open on this key, and it is worse to sit on because ', - 'the wrong answer is a PLAUSIBLE NUMBER rather than a refusal: no error, no log, nothing ', - 'for a dashboard author to notice. It survived the `QueryAST` sweep of this very schema because ', - 'that sweep asked which members no executor reads, and this one had a reader — the wrong ', - 'question for a key whose defect is WHICH executor reads it. Remove rather than enforce, ', - 'per the ruling: `count_distinct` (which just took the enforce leg above, and whose SQL ', - 'lowering has since landed) already covers the only deduplicating spelling with measured ', - 'demand, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a modelling ', - 'mistake and would have to be lowered across five faces, two of them then frozen under ', - 'the driver freeze (lifted 2026-08-11, after this ruling), to buy it. The blast radius inside the ', - 'fallback is narrower than the key suggests and was ', - 'measured rather than assumed: only `sum` and `avg` ever changed answer — `count` returned ', - 'from its own branch before reaching the dedupe, `count_distinct` fed a Set, and dedupe ', - 'does not move `min`/`max`. `AggregationNodeSchema` is non-strict, so the key is ', - '`retiredKey()`-tombstoned rather than bare-deleted: a plain deletion would have made zod ', - 'silently STRIP what callers still send, trading a divergent flag for an ignored one ', - '(ADR-0104). One tombstone covers every aggregation door, because ', - '`QuerySchema.aggregations` and `EngineAggregateOptionsSchema.aggregations` reuse that one ', - 'schema by reference. No conversion: a request surface with no stored source — one ', - 'semantic TODO below, the disposition every other `data.query.*` retirement in this major ', - 'already takes.\n\n', - 'One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape ', - "protocol 12 last used for `api.requireAuth`: an omitted `ActionDescriptor.resumeAuthority` ", - "resolves to `'service'` instead of `'any'`, so a pausing node type that never states who ", - 'may continue its pauses is refused on the generic resume route rather than open to it ', - '(ADR-0044\'s 2026-07-28 amendment). Nothing is removed and no metadata shape ', - 'changes — the field has been optional since step one of the same issue — so tsc reports ', - 'nothing and only the MEANING of silence moved. That is exactly why it needs a ledger ', - 'entry: a third-party plugin author has no compile error to discover it with, and the ', - 'one-line prescription (declare `resumeAuthority` on the descriptor) has to arrive before ', - 'a user meets a run that will not continue.\n\n', - 'The same descriptor loses a key in this step, and the pairing is the point (', - 'ADR-0049). `ActionDescriptor.isAsync` and `ActionDescriptor.supportsPause` were two ', - 'spellings of one capability — "this node type can suspend the run" — and enforcing pauses split ', - 'them by evidence rather than by preference: `supportsPause` took the ENFORCE leg (the ', - 'engine now refuses a suspension the descriptor never declared, at the one seam every ', - 'suspension passes through), and `isAsync` takes the REMOVE leg, because a fresh ', - 'three-repo measurement found zero readers and no consumer it could grow into. What ', - 'makes the duplicate worse than an ordinary inert key is that five shipped descriptors ', - 'WROTE it, so the platform itself modelled a declaration that decided nothing — and a ', - 'plugin author copying `screen` (which declared BOTH) had no way to tell which of the ', - 'two the runtime honoured. It is tombstoned rather than deleted, so the answer arrives ', - 'as a rejection carrying the fix; and because a descriptor lives in executor TypeScript ', - 'rather than in stored metadata, its prescription is a semantic entry below rather than ', - 'a conversion `os migrate meta` could replay.\n\n', - 'The plugin manifest loses its whole `loading` block in this step (ADR-0049, ', - 'maintainer ruling 2026-08-04) — the same enforce-or-remove question asked of a block ', - 'rather than a key, and answered REMOVE on measurement: every reference to ', - '`manifest.loading.*` in objectstack, cloud and objectui lived inside `packages/spec` ', - 'itself, so a full loading policy parsed, entered the manifest, and configured nothing. ', - 'The reason it outranked ordinary inert-key cleanup is that one of its members was ', - '`sandboxing`, declaring process / vm / iframe / web-worker isolation and a service ACL: ', - 'an inert SECURITY control is worse than an absent one, because an author (very often an ', - 'AI, ADR-0033) reads the vocabulary as proof the isolation exists and stops looking. Hot ', - 'reload was a two-source defect on top of that — the retired `PluginHotReloadSchema` was ', - 'the dead one of two vocabularies, and the ruling converges on the live one, ', - '`HotReloadConfigSchema`, which `HotReloadManager` actually reads and which is KEPT ', - 'unenforced as the starting point for a separate future decision. Like `isAsync`, its ', - 'prescription is a semantic entry rather than a conversion: a manifest is not a stack ', - 'collection, so `os migrate meta` has no seam at which to rewrite one.\n\n', - 'The action LOCATION vocabulary loses `global_nav` in this step (ADR-0049, ', - 'maintainer ruling 2026-08-09). It was declared from the day `ACTION_LOCATIONS` was ', - 'written and no product surface ever served it: the console command palette composes its ', - 'groups from nav items, objects, dashboards, pages, reports, recent items and record ', - 'search, and reads no action metadata at all — so an action declaring this location never ', - 'reached a user. What lifts it above ordinary inert-declaration cleanup is that the ', - 'authoring tool PROMISED the surface: the Studio designer previewed a mock ', - '`⌘K · Command palette` frame for exactly this value, so an author (very often an AI, ', - 'ADR-0033) declared it, watched it "render", shipped it, and got nothing — the ADR-0078 ', - 'shape arriving through a location vocabulary rather than through a missing key. It was ', - 'retired rather than implemented because the demand evidence is empty: no user has asked ', - 'for command-palette actions and the only two declarers were our own showcase corpus, so ', - 'wiring the palette would have been capability expansion with no pull. This is an enum ', - 'VALUE, not a key, so — as with `crypto.hash` and the two aggregate functions above — ', - 'there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed ', - 'on the received value so only the spelling that used to be legal is told it "was ', - 'removed". The conversion strips the value from `action.locations` and KEEPS the key even ', - 'when the array empties, because on this surface `locations: []` and an absent ', - '`locations` are different declarations: the empty array is the documented headless shape ', - '(callable over REST/MCP/AI, capability gate and audit trail intact), while an absent key ', - "means nobody placed the action — which is what `packages/lint`'s `action-no-placement` ", - 'warns about. An object-less action, whose only reason for declaring `global_nav` was that ', - 'it has no row and no record header to render on, is therefore migrated to the ', - 'declaration it always meant.\n\n', - 'It also removes the three pass-through-only list-view display keys `striped` / ', - '`bordered` / `virtualScroll` (ADR-0049 enforce-or-remove, maintainer ruling ', - '2026-08-10). All three were graded live on reads that turned out to be forwarding ', - 'copies: the react spec-bridge, plugin-list and plugin-view/app-shell each copy the ', - 'key onto the next node, and the chain ends at ObjectGrid, which never spells any of ', - 'the three — so an author who wrote `striped: true` got a parse-clean no-op, the exact ', - 'silent-no-op shape enforce-or-remove exists to end. Copy-without-apply is dead in ', - 'effect; per the ruling, if objectui wants one of these as real behavior, that is an ', - 'implementation card filed first, and the key stays retired pending it.\n\n', - "Finally it removes the 'pdf' member of `view.exportOptions` formats (maintainer ", - 'ruling 2026-08-12). PDF export was declined platform-side as not planned, so the ', - "member was declared-but-unrenderable: ObjectGrid dropped the format from the export menu ", - "with only a runtime console.warn, so `exportOptions: ['xlsx', 'pdf']` type-checked, ", - 'validated, and silently rendered a menu without PDF. The same ruling adopted the OBJECT ', - 'form for `exportOptions` — `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, ', - 'streaming? }`, exactly the key set the renderer reads, ending the state where no ', - 'declaration was both type-legal and functional — with the legacy bare array still ', - 'accepted and lifted to `{ formats: [...] }` at parse, which is why the conversion strips ', - "only 'pdf' and does not rewrite the array spelling. This is an enum VALUE, not a key, so ", - '— as with `crypto.hash` above — there is no `retiredKey()` tombstone: the format enum\'s ', - 'error map carries the prescription, keyed on the received value so only the spelling ', - 'that used to be legal is told it "was removed", plus a union-level dispatch so the ', - 'refusal is the top-level message in either authored form. The strip keeps an emptied ', - '`formats` array rather than deleting the declaration.', - ].join(''), - conversionIds: [ - 'action-execute-to-target', - 'field-conditionalRequired-to-requiredWhen', - 'agent-tools-to-skills', - 'sharing-rule-access-level-full-to-edit', - 'flow-node-crud-object-alias', - 'flow-node-notify-config-aliases', - 'flow-node-wait-event-config-lift', - 'flow-node-connector-config-lift', - 'flow-node-map-flow-alias', - 'flow-node-subflow-flow-alias', - 'flow-node-script-config-aliases', - 'permission-rls-priority-removed', - 'tool-inert-authoring-keys-removed', - 'app-dead-authoring-keys-removed', - 'app-area-fail-open-gates-removed', - 'action-inert-keys-removed', - 'flow-inert-keys-removed', - 'view-inert-keys-removed', - 'view-list-passthrough-keys-removed', - 'view-export-options-pdf-removed', - 'dashboard-inert-keys-removed', - 'agent-knowledge-removed', - 'skill-trigger-phrases-removed', - 'stack-api-require-auth-removed', - 'datasource-capabilities-removed', - 'datasource-inert-blocks-removed', - 'mapping-inert-keys-removed', - 'book-translations-removed', - 'job-id-removed', - 'translation-validation-messages-removed', - 'flow-node-wait-timeout-keys-removed', - 'datasource-read-replicas-removed', - 'datasource-config-driver-key-aliases', - 'datasource-driver-mongo-to-mongodb', - 'flow-node-script-branch-keys-removed', - 'object-managed-by-system-to-system-data', - 'retry-policy-converged', - 'object-enable-trash-mru-removed', - 'hook-body-crypto-hash-removed', - 'connector-rate-limit-config-removed', - 'dashboard-widget-responsive-removed', - 'dashboard-widget-action-aria-removed', - 'dashboard-widget-compareto-converged', - 'theme-inert-token-scales-removed', - 'page-header-subtitle-alias', - 'object-index-type-partial-removed', - 'field-mapping-transform-removed', - 'record-picker-display-field-to-label-field', - 'record-picker-inert-keys-removed', - 'page-card-body-to-children', - 'dataset-measure-array-string-agg-removed', - 'inline-action-api-params-to-body-extra', - 'page-tabs-type-to-tab-style', - 'page-structure-inert-keys-removed', - 'record-details-layout-removed', - 'app-hidden-to-unpublished', - 'action-global-nav-location-removed', - ], - semantic: [ - // One file per entry under `entries/semantic/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - // - ], -}; +export const MIGRATION_SUPPORT_FLOOR = 17; /** * One retirement's sentences in a step's `rationale` (#20535). @@ -3484,8 +2418,8 @@ const step18: MigrationStep = { // the order `CONVERSIONS_BY_MAJOR[18]` applies them — the order that list // already curates. A hand-kept copy took an append from every retirement (its // tail conflicted like the rationale's) and can fall out of step with the - // list it copies: step 17's names the same ids in a different order. Add a - // conversion there, never here. + // list it copies: the retired step 17's named the same ids in a different + // order. Add a conversion there, never here. conversionIds: CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id), semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -3498,7 +2432,6 @@ const step18: MigrationStep = { /** All migration steps, keyed by the major they migrate into. */ export const MIGRATIONS_BY_MAJOR: Readonly> = { - 17: step17, 18: step18, }; @@ -3529,10 +2462,15 @@ export const MIGRATION_MAJORS: readonly number[] = Object.keys(MIGRATIONS_BY_MAJ * not (#17969). See "Lifecycle" below for what each of them means for a row * already in the table. * - **Check (c)**, the baseline-deletion gate (#4650): the major recorded here - * is what starts a tombstone's ~two-major aging clock, so it is also what - * eventually lets its `authorable-surface/` line be deleted. Since #5898 — - * before that, (c) dated the clock by matching the key's LEAF against the - * conversion registry, and took the `Math.min` of the matches. + * is what starts a tombstone's one-major aging clock (two until #25767), so + * it is also what lets its `authorable-surface/` line be deleted. Since #5898 + * — before that, (c) dated the clock by matching the key's LEAF against the + * conversion registry, and took the `Math.min` of the matches. Since #25767 + * the clock has a second, independent source: a `[RETIRED]` line that the + * authorable surface already carried at the release tag that cut the + * previous major (`@objectstack/spec@.0.0`) is aged out + * whether or not a row here names it — the tag is the cut, and presence at + * the cut is the fact the clock keys on. * * ## Why exact keys, and not the conversion `surface` * @@ -3561,43 +2499,47 @@ export const MIGRATION_MAJORS: readonly number[] = Object.keys(MIGRATIONS_BY_MAJ * table. An entry here is the *proof the retirement was declared*; the * conversion is the *prescription a consumer follows*. A retirement needs both. * - * ## Historical tombstones: absent, and therefore not deletable + * ## Historical tombstones: dated by the release tag, not backfilled * * Check (b) fires only on a NEW live → retired transition — measured against the - * committed `authorable-surface.json` baseline, which already records every - * older tombstone as `[RETIRED]`. The 97 retirements that landed before this - * table existed therefore never re-trigger it and are deliberately absent: this - * reads "retirements registered under the exact-key gate", not "every retirement - * ever". - * - * #5898 measured what it would take to backfill them, and neither available - * source can date a row honestly: - * - * - **Leaf-matching the conversion registry** is precisely the inference #4659 - * removed. It is also demonstrably wrong in both directions on this very data: - * of the two rows it dated as aged-out, `data/Index:type` was dated by - * `flow.node.type` (an unrelated cluster) and `api/RestApiConfig:requireAuth` - * by `api.requireAuth` at major 12 — which is the secure-default FLIP, a - * different kind of change to the same surface, not its retirement. - * - **The git history of `authorable-surface.json`** begins at 17.0.0-rc.0, so - * every one of the 97 rows first carries `[RETIRED]` at major 17. That is an - * artifact of the baseline file's birth, not archaeology. + * committed `authorable-surface/` baseline, which already records every older + * tombstone as `[RETIRED]`. The retirements that landed before this table + * existed (#5902, 2026-08-06, while 16.x was current) therefore never + * re-triggered it and were never rows here: this reads "retirements registered + * under the exact-key gate", not "every retirement ever". * - * So the rows stay undeclared, and check (c) is fail-closed about it: a - * tombstone with no entry here cannot prove its age and its baseline line may - * not be deleted. Deleting one is a deliberate, reviewable act — establish the - * true major for that key, add it below, and check (b2) verifies the entry still - * names a key this build tombstones. Do NOT add a row you cannot date; an - * estimate written down here reads as a fact to every later gate. + * #5898 measured what it would take to backfill them by major and found no + * honest source for an EXACT major (leaf-matching the conversion registry is + * the inference #4659 removed; the surface file's own git history begins at + * 17.0.0-rc.0). Under the one-major rule (#25767) an exact major is no longer + * the question — "was this retired before the previous major was cut?" is, and + * the release tag answers it: a `[RETIRED]` line the surface carried at + * `@objectstack/spec@17.0.0` was retired while 16.x was current, which is the + * same cohort this table's former major-17 region registered (pre-cut + * retirements register at the major that first refused them). Check (c) reads + * the tag through git, so the historical rows were never written here: they + * aged out and left with the ≤16.x kits in #25767, and an estimate was never + * written down as a fact. ⛔ Do NOT add a row you cannot date. * * ## Lifecycle * - * A TRUE entry is permanent. A declared TOP-LEVEL tombstone ages out after ~two - * majors and its line may then leave `authorable-surface.json` (check (c)); its - * entry here stays, and then names a key the build no longer emits — the - * expected steady state, not an error. A NESTED row never reaches that file at - * all (0 dotted entries on the shipped surface), so it has no aging clock and - * "the build no longer emits it" is never that steady state for one. + * A row lives ONE protocol major past the major it is registered under + * (ADR-0087 amendment 2026-10-26, #25767; two majors until then, with rows + * kept forever). A declared TOP-LEVEL tombstone ages out when + * `CURRENT_MAJOR - major ≥ 1` and its line may then leave the authorable + * surface (check (c)); the row is removed in the same move, as part of the + * retirement kit (tombstone, this row, the semantic entry, the conversion), and + * so is the whole region: at major 18 the `17` region went, at 19 the `18` + * region goes. Until that move a row naming a key the build no longer emits is + * the expected steady state, not an error. A NESTED row never reaches the + * surface file at all (0 dotted entries on the shipped surface), so it has no + * aging clock of its own and leaves with its region. + * + * ⚠️ The order of the move is load-bearing: the tombstone's line is dated by + * the row at the merge base OR by the release tag, so a row may leave in the + * same PR as its tombstone, but a row must never leave AHEAD of its tombstone — + * a tombstone with no row and no tag presence cannot prove its age and its line + * stays (check (c) is fail-closed about it). * * Two states are rejected. A row is read as a PATH only when its `name` half * carries a dot AND this build emits no top-level property of that exact name, @@ -3615,34 +2557,18 @@ export const MIGRATION_MAJORS: readonly number[] = Object.keys(MIGRATIONS_BY_MAJ * whose DEF this build does not emit is NOT this state: that is the whole-def * removal steady state, registered in {@link RETIRED_DEFS_BY_MAJOR}. * - * ⛔ "Delete the entry" and "entries are permanent" do not conflict, and reading - * one as licence to drop the other is the way to lose a retirement. A row that - * was ever TRUE of some build is history and is never deleted; a row check (b2) - * or (b3) refuses was never true of any build, so deleting it removes a false - * claim rather than a record. Check (b3) can also not yet tell a wrong row apart - * from every truthful one — its refusal text enumerates the shapes it knows it - * cannot see — and for those the remedy is to teach the check the shape, ⛔ never - * to delete a row that is telling the truth. + * ⛔ "Delete the entry" above and the one-major removal are the only two ways a + * row leaves. A row check (b2) or (b3) refuses was never true of any build, so + * deleting it removes a false claim rather than a record; a row that was TRUE + * of some build leaves only with its major's region, never alone. Check (b3) + * can also not yet tell a wrong row apart from every truthful one — its refusal + * text enumerates the shapes it knows it cannot see — and for those the remedy + * is to teach the check the shape, ⛔ never to delete a row that is telling the + * truth. * * @see scripts/build-schemas.ts — checks (b)/(b2)/(b3)/(c), the only consumers */ export const RETIRED_KEYS_BY_MAJOR: Readonly> = { - // The first entries since #4659 built this table (#5552). ONE tombstone - // produced THREE keys: `transform` is declared on `shared/FieldMapping`, and - // every `.extend()` of the base copies the retired property into its own - // walked shape, which `authorable-surface/` marks `[RETIRED]` separately. - // Registered per key, as the gate reads them — nothing radiates from the - // base. Two of the three remain: `data/ExternalFieldMapping:transform` left - // when #8075 retired the whole external-lookup family (the def retirement - // subsumes the key entry, the WidgetManifest.performance way — see - // RETIRED_DEFS_BY_MAJOR below). - 17: [ - // One file per entry under `entries/retired-keys/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - // - ], // Post-cut retirements: v17.0.0 was cut before these landed, so the // tombstone ships on the 17.x line (launch-window convention) and the // prescription registers at the major boundary where `migrate meta` users @@ -3721,56 +2647,18 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> * * ## Lifecycle * - * Entries are permanent, and after the removal merges they name a def no build - * emits — the expected steady state. The one state the gate rejects is an entry - * naming a def this build STILL publishes: a registration nothing consumed, - * pre-approving a removal that has not happened. + * An entry lives ONE protocol major past the major it is registered under + * (ADR-0087 amendment 2026-10-26, #25767; permanent until then). After the + * removal merges it names a def no build emits — the expected steady state — + * and it leaves with its region when the next major is cut, as the rest of the + * retirement kit does: at major 18 the `17` region went, at 19 the `18` region + * goes. The one state the gate rejects is an entry naming a def this build + * STILL publishes: a registration nothing consumed, pre-approving a removal + * that has not happened. * * @see scripts/build-schemas.ts — the manifest deletion gate, the only consumer */ export const RETIRED_DEFS_BY_MAJOR: Readonly> = { - // The first entry since #4725 built this table (#5552). The `transform` key's - // value schema had no other consumer, so it goes with the key rather than - // surviving as an exported union nothing references — an exported schema with - // no consumer reads as a capability to whoever finds it (#3950). - // - // The ten that follow are #5055 (ADR-0049 enforce-or-remove, maintainer ruling - // 2026-08-06, window moved to protocol 17 on 2026-08-07): `ui/widget.zod.ts`'s - // widget-registration vocabulary and the five doorless shapes of - // `ui/i18n.zod.ts`. None had a carrier key, none was reachable from the - // metadata-type roots, and none was ever parsed in objectstack / objectui / - // cloud — so there is no tombstone and no D2 conversion (route 3 of the - // retirement playbook), and this table plus the D3 `SemanticMigration` - // `ui-widget-i18n-family-retired` IS the declaration. Same shape as #4988. - // - // ⚠️ `ui/FieldWidgetProps` is deliberately NOT here — it was never in the - // manifest to begin with (its `onChange` is a `z.function()`, so no JSON - // Schema is emitted) and it survives the retirement: it is a React props - // contract, not authorable metadata, and it has a live compile-time consumer - // in objectui (`packages/fields/src/__tests__/spec-symbol-batch7.test.ts`, - // landed by objectui PR #3289). - // - // The 2026-08-08 ADR-0049 sweep (#6486) adds twenty-three more across three - // members (4 + 10 + 9), all route 3 and all whole-def: `system/http-server.zod.ts`'s - // runtime vocabulary (#5295, D3 `http-server-runtime-vocabulary-retired`), - // `api/protocol.zod.ts`'s viewId-addressed view CRUD (commit f549a0d4a, D3 - // `view-management-protocol-retired`) and the whole L2 ETL layer (#6414, D3 - // `etl-pipeline-layer-retired`). None had a carrier key and none was ever - // parsed outside its own unit tests, so again there is no tombstone and no D2 - // conversion — this table plus those three entries ARE the declaration. - // - // ⚠️ `system/ServerRateLimitConfig` is deliberately NOT here. It sits four - // lines from the retired `system/ServerCapabilities` in the same manifest and - // shares its prefix, but it belongs to `StackServerSecurity.rateLimit` — the - // LIVE server surface #5006 admitted, with an executor. Prefix adjacency is - // not evidence. - 17: [ - // One file per entry under `entries/retired-defs/`, concatenated here sorted by - // entry id by `gen:migration-registry` (#7297). Add an entry by adding a - // FILE — never by editing between the markers, which is generated. - // - // - ], // Post-cut retirements: v17.0.0 was cut before these landed, so the // removal ships on the 17.x line (launch-window convention) and the // prescription registers at the major boundary where `migrate meta` users diff --git a/packages/spec/src/migrations/semantic-relevance.test.ts b/packages/spec/src/migrations/semantic-relevance.test.ts index 75f76088ce7..ff796419b19 100644 --- a/packages/spec/src/migrations/semantic-relevance.test.ts +++ b/packages/spec/src/migrations/semantic-relevance.test.ts @@ -35,11 +35,6 @@ import type { MigrationTodo, SemanticRelevance } from './types.js'; * its question names. */ const EXPECTED_RELEVANCE: Readonly> = { - '17:dashboard-widget-compareto-offset': ['dashboards'], - '17:declarative-apis-endpoints-live': ['apis'], - '17:job-retry-policy-constraints-tightened': ['jobs'], - '17:sharing-rule-recipient-reconcile': ['sharingRules'], - '17:tool-requires-confirmation-retired': ['tools'], '18:agent-memory-store-retired-and-limits-required': ['agents'], '18:agent-structured-output-refused-members-retired': ['agents'], '18:analytics-cube-public-default-visible-enforced': ['analyticsCubes'], @@ -83,10 +78,10 @@ describe('which entries carry a relevance question (ENUMERATION)', () => { expect(actual).toEqual(EXPECTED_RELEVANCE); }); - it('is 26 entries: 5 of protocol 17 and 21 of protocol 18', () => { + it('is 21 entries, all of protocol 18 (the 5 protocol-17 rows left with step 17 on #25767)', () => { const keys = Object.keys(EXPECTED_RELEVANCE); - expect(keys).toHaveLength(26); - expect(keys.filter((k) => k.startsWith('17:'))).toHaveLength(5); + expect(keys).toHaveLength(21); + expect(keys.filter((k) => k.startsWith('17:'))).toHaveLength(0); expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(21); }); diff --git a/packages/spec/src/security/rls.test.ts b/packages/spec/src/security/rls.test.ts index ec3358eef77..08875a6c653 100644 --- a/packages/spec/src/security/rls.test.ts +++ b/packages/spec/src/security/rls.test.ts @@ -121,12 +121,13 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(result.enabled).toBe(true); }); - it('priority is RETIRED: absent parses clean, authored rejects with the prescription', () => { + it('priority is RETIRED: absent parses clean, authored is refused as an unknown key naming the upgrade guide', () => { // Removed by the 2026-07-30 #3896 security audit: no outcome depends on // an order (reads OR-combine; a write's check is chosen per operation, // then OR-combined), so the promised "conflict resolution" cannot - // exist and nothing ever read the key. The tombstone keeps the removal - // audible instead of silently stripping an authored value. + // exist and nothing ever read the key. Its tombstone was a 16.x kit and + // aged out at 18 (#25767); the strict shape keeps the removal audible + // instead of silently stripping an authored value. const policy = { name: 'test_policy', object: 'account', @@ -138,7 +139,10 @@ describe('Row-Level Security (RLS) Protocol', () => { const authored = { ...policy, priority: 10 } as never; const r = RowLevelSecurityPolicySchema.safeParse(authored); expect(r.success).toBe(false); - expect(JSON.stringify(!r.success ? r.error.issues : [])).toContain('removed in @objectstack/spec 17.0.0'); + const issues = JSON.stringify(!r.success ? r.error.issues : []); + expect(issues).toContain('unrecognized_keys'); + expect(issues).toContain('`priority`'); + expect(issues).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should handle policies for all operations', () => { @@ -551,12 +555,10 @@ describe('unknown keys are rejected, not stripped', () => { } }); - it('keeps the retired `priority` tombstone prescription (not a bare strict error)', () => { - const result = RowLevelSecurityPolicySchema.safeParse({ ...policy, priority: 10 }); - expect(result.success).toBe(false); - const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('`rowLevelSecurity[].priority` was removed'); - expect(messages).toContain('Delete the key'); + it('refuses the aged-out `priority` as a strict unknown key, with the upgrade guide (#25767)', () => { + const issue = unknownKeyIssue({ ...policy, priority: 10 })!; + expect(issue.keys).toEqual(['priority']); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index d5293268e07..085118906db 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -636,31 +636,6 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => strictObject( .default(true) .describe('Whether this policy is active'), - /** - * REMOVED — `priority` promised "conflict resolution" that cannot exist. - * - * No outcome depends on an order. On a read, the applicable policies' `using` - * clauses OR-combine (any match allows access). On an insert or an update, - * which predicates judge the written rows is decided once per operation - * across the applicable policies (the declared `check`s when any exists, - * otherwise each `using`; see `check`), and the chosen ones OR-combine. So - * there is never a conflict to order and evaluation order cannot change an - * outcome. Nothing ever read the key: the 2026-07-30 - * security-subset liveness re-verification (#3896 follow-up) closed the call - * graph across the collection site, the projection round-trip and the - * compiler, and found no consumer. A semantically-void knob on a SECURITY - * policy is worse than dead — an author (very often an AI, ADR-0033) reads it - * as a precedence lever and reasons about policy interactions that do not - * exist. Removed per the #3715 / #3950 precedent; tombstoned so the removal - * is audible (tsc `never` + the parse-time prescription) instead of a silent - * strip. - */ - priority: retiredKey( - '`rowLevelSecurity[].priority` was removed in @objectstack/spec 17.0.0. ' + - 'It never had an effect. Delete the key — policy outcomes are unchanged. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * REMOVED — `tags` promised "categorization and reporting" for governance and * compliance, and nothing ever read it (#20321, ADR-0049 enforce-or-remove). @@ -734,7 +709,7 @@ const DEFAULT_ROW_SCOPE_OBJECT_GUIDANCE = * this tree declares it. */ export const DefaultRowScopeSchema = lazySchema(() => { - const { object: _implied, priority: _priority, tags: _tags, ...members } = RowLevelSecurityPolicySchema.shape; + const { object: _implied, tags: _tags, ...members } = RowLevelSecurityPolicySchema.shape; return strictObject( { surface: "this object's default row scope", diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts index 0571671e98f..a569f2497a3 100644 --- a/packages/spec/src/security/sharing.zod.ts +++ b/packages/spec/src/security/sharing.zod.ts @@ -76,7 +76,7 @@ export const SharingLevel = enumWithRetiredValues([ + "to `edit` until this release. Use `accessLevel: 'edit'`. Change the value; the access it grants is " + 'unchanged. A sharing rule grants read or edit; delete and transfer come from object permissions and ' + 'ownership. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', }); /** diff --git a/packages/spec/src/shared/index.ts b/packages/spec/src/shared/index.ts index e2d66ff65e0..4a527888d6b 100644 --- a/packages/spec/src/shared/index.ts +++ b/packages/spec/src/shared/index.ts @@ -16,6 +16,7 @@ export * from './metadata-types.zod'; // live at the surfaces themselves: inline regexes on object/field/flow names, // bare `SnakeCaseIdentifierSchema` on app and position names. export * from './suggestions.zod'; +export * from './protocol-upgrade-guide'; // `suggestFieldType` lives apart from `suggestions.zod` so that module imports no // schema: strict-object → suggestions → data/field.zod was the eager import // cycle that crashed the `/api` and `/data` entries under OS_EAGER_SCHEMAS=1. diff --git a/packages/spec/src/shared/mapping.test.ts b/packages/spec/src/shared/mapping.test.ts index e2fdf2d605a..7e347e39921 100644 --- a/packages/spec/src/shared/mapping.test.ts +++ b/packages/spec/src/shared/mapping.test.ts @@ -89,8 +89,12 @@ describe('FieldMapping.transform is retired, and says so', () => { // It must name the live mechanism, not merely refuse: an author who wrote a // transform wants to know where transforms actually run. expect(result.error!.issues[0]!.message).toMatch(/mapping\.fieldMapping\[\]\.transform/s); - // And the migration command, since an ADR-0087 conversion covers this key. - expect(result.error!.issues[0]!.message).toMatch(/os migrate meta --from 16/s); + // The ADR-0087 conversion that covered this key rode the protocol-17 step, + // which aged out with the 16.x kits (#25767); the tombstone itself stays + // because this base is a plain `z.object` (a delete would strip in silence), + // and its house sentence now says the step is gone and links the guide. + expect(result.error!.issues[0]!.message).toMatch(/support floor 17/s); + expect(result.error!.issues[0]!.message).not.toMatch(/os migrate meta --from 16/s); }); // `the surviving extender inherits the tombstone` stood here, asserting diff --git a/packages/spec/src/shared/mapping.zod.ts b/packages/spec/src/shared/mapping.zod.ts index 67e7d0b81e2..13cd5117c59 100644 --- a/packages/spec/src/shared/mapping.zod.ts +++ b/packages/spec/src/shared/mapping.zod.ts @@ -101,7 +101,7 @@ export const FieldMappingSchema = lazySchema(() => z.object({ + '`none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), ' + 'applied by the REST import path, which rejects `javascript` with a 400 rather than ' + 'pretending to run it. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', ), /** diff --git a/packages/spec/src/shared/protocol-upgrade-guide.ts b/packages/spec/src/shared/protocol-upgrade-guide.ts new file mode 100644 index 00000000000..452cf741332 --- /dev/null +++ b/packages/spec/src/shared/protocol-upgrade-guide.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where a retired key's prescription lives once its tombstone has aged out + * (ADR-0087, amendment 2026-10-26 — #25767, maintainer ruling D2 = A). + * + * A retirement kit — the `retiredKey()` tombstone, its `RETIRED_KEYS_BY_MAJOR` + * / `RETIRED_DEFS_BY_MAJOR` rows, its D3 semantic entry and its D2 conversion + * — lives ONE protocol major past the one it retired in. After that the key is + * an ordinary unknown key: the `strictObject` refusal names it, offers the + * nearest declared spelling, and points HERE, because the prescription that + * used to ride on the tombstone is now only in the generated upgrade guide + * (`scripts/build-upgrade-guide.ts`, one page per migrated major). The + * migration chain's floor rises on the same clock, so `os migrate meta --from + * N` below the floor refuses with the same link instead of converting. + * + * One spelling, shared by the strict refusal (`suggestions.zod.ts`), the floor + * error (`migrations/chain.ts`) and the CLI's own `--from` examples, so the + * three doors an author can hit all send them to the same page. + */ +export const PROTOCOL_UPGRADE_GUIDE_URL = 'https://objectstack.ai/docs/protocol-upgrade'; + +/** + * The sentence every strict unknown-key refusal carries, after its fix channels + * and ahead of the history sentence (#5955 order). Static on purpose: + * it names no major, so the pins on the refusal text do not move once per + * release, and the page it links indexes every migrated major itself. + */ +export const RETIRED_KEY_UPGRADE_GUIDE_SENTENCE = + 'Keys retired before the previous protocol major are no longer prescribed here — ' + + `see the protocol upgrade guide: ${PROTOCOL_UPGRADE_GUIDE_URL}.`; diff --git a/packages/spec/src/shared/retired-key-migrate-sentence.test.ts b/packages/spec/src/shared/retired-key-migrate-sentence.test.ts index afb7e349b3a..76346adc855 100644 --- a/packages/spec/src/shared/retired-key-migrate-sentence.test.ts +++ b/packages/spec/src/shared/retired-key-migrate-sentence.test.ts @@ -156,7 +156,19 @@ const CORPORA: Corpus[] = [ }, ]; -const MARKER = /(?:Run )?`os migrate meta --from \d+`/g; +const MARKER = /(?:Run )?`os migrate meta --from \d+`|This edit left `os migrate meta` with the protocol-\d+\.x step/g; + +/** + * [#25767] The POST-FLOOR house form. A 17.x retirement's prescription used to + * name `os migrate meta --from 16`; the support floor is 17 since the 2026-10-26 + * ruling (ADR-0087 D2 = A), so that command refuses, and the sentence says what + * is still true: the step exists on a release that carries it, the edit is + * applied by hand otherwise, and the protocol upgrade guide is where the + * prescription lives from here. Same anchoring as the `--from` house form: last + * sentence of its literal, nothing after the closing period. + */ +const POST_FLOOR_AT_MARKER = + /^This edit left `os migrate meta` with the protocol-\d+\.x step \(support floor \d+\): apply it by hand, or run the tool on a release that still carries the step — https:\/\/objectstack\.ai\/docs\/protocol-upgrade\.['"]/; /** * House form, anchored at the marker: the sentence must be the LAST sentence @@ -247,7 +259,7 @@ function judgeReconstructed({ merged, segments }: Reconstructed, file: string): for (const m of merged.matchAll(MARKER)) { const at = m.index ?? 0; const rest = merged.slice(at); - const ok = HOUSE_AT_MARKER.test(rest) || MIXED_AT_MARKER.test(rest); + const ok = HOUSE_AT_MARKER.test(rest) || MIXED_AT_MARKER.test(rest) || POST_FLOOR_AT_MARKER.test(rest); judged.push({ file, line: lineAt(segments, at), excerpt: rest.slice(0, 120).replace(/\n/g, ' '), ok, }); diff --git a/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts b/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts index c8af6fa59d9..bed186232b1 100644 --- a/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts +++ b/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts @@ -29,8 +29,16 @@ * * The cases span both object modes and both value kinds, because `tsc` words * them differently: an array value reads as a missing property (TS2741), a - * string as a plain non-assignable type (TS2322). `PageSchema` and - * `FieldSchema` are `strictObject`s; `IndexSchema` is a plain `z.object`. + * string or boolean as a plain non-assignable type (TS2322). `PageSchema` and + * `ObjectSchema` are `strictObject`s; the stack's `api` block is a plain + * `z.object` (its `requireAuth` tombstone stays for exactly that reason). + * + * The two tombstones this used to probe on `FieldSchema` and `IndexSchema` + * (`conditionalRequired`, `indexes[].type`) were 16.x retirement kits and aged + * out at 18 (#25767): an authored one is TS2353 now — "does not exist in + * type" — the ordinary unknown-key diagnostic, with no retirement sentence + * because the key is no longer declared at all. The strict parse is where + * that author meets the upgrade-guide link. * * ⚠️ Anti-vacuity: a harness that resolves nothing reports import errors or * nothing at all, and both look like "no retirement text". The control probe @@ -48,7 +56,7 @@ const SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const PROBE_DIR = resolve(SRC_DIR, 'shared/__retired_key_tsc_probes__'); /** The stack every probe authors; `retired` splices retired keys into it. */ -function stackProbe(retired: { page?: string; field?: string; index?: string }): string { +function stackProbe(retired: { page?: string; object?: string; api?: string }): string { return [ "import { defineStack } from '@objectstack/spec';", '', @@ -57,15 +65,16 @@ function stackProbe(retired: { page?: string; field?: string; index?: string }): ' objects: [', ' {', " name: 'deal',", - " label: 'Deal',", + ` label: 'Deal'${retired.object ?? ''},`, ' fields: {', " amount: { type: 'number', label: 'Amount' },", - ` stage: { type: 'text', label: 'Stage'${retired.field ?? ''} },`, + " stage: { type: 'text', label: 'Stage' },", ' },', - ` indexes: [{ fields: ['amount']${retired.index ?? ''} }],`, + " indexes: [{ fields: ['amount'] }],", ' },', ' ],', ` pages: [{ name: 'home_page', label: 'Home', type: 'home'${retired.page ?? ''} }],`, + ...(retired.api === undefined ? [] : [` api: {${retired.api} },`]), '});', '', ].join('\n'); @@ -75,10 +84,10 @@ const PROBES = { control: stackProbe({}), // HotCRM's measured case: `page.assignedProfiles` (strictObject, array value). page: stackProbe({ page: ", assignedProfiles: ['sales_rep']" }), - // A rename retirement on a strictObject, string value. - field: stackProbe({ field: ", conditionalRequired: 'amount > 0'" }), - // A non-strict `z.object` tombstone, string value. - index: stackProbe({ index: ", type: 'btree'" }), + // A removal retirement on a strictObject, string value. + object: stackProbe({ object: ", externalSharingModel: 'private'" }), + // A non-strict `z.object` tombstone, boolean value. + api: stackProbe({ api: ' requireAuth: true' }), } as const; type ProbeName = keyof typeof PROBES; @@ -149,8 +158,8 @@ describe('a retired key names its retirement in the tsc diagnostic', () => { const cases: ReadonlyArray<[ProbeName, string, number]> = [ ['page', 'assignedProfiles', 2741], - ['field', 'conditionalRequired', 2322], - ['index', "type: 'btree'", 2322], + ['object', 'externalSharingModel', 2322], + ['api', 'requireAuth', 2322], ]; it.each(cases)('%s: writing %s fails with TS%i, and the message says [REMOVED] and names `os validate`', (name, key, code) => { diff --git a/packages/spec/src/shared/retired-key.ts b/packages/spec/src/shared/retired-key.ts index 20a4fde53f4..adc9bdec5d9 100644 --- a/packages/spec/src/shared/retired-key.ts +++ b/packages/spec/src/shared/retired-key.ts @@ -89,8 +89,16 @@ * code review. * * Tombstones age out, exactly like the `UNKNOWN_KEY_GUIDANCE` entries in - * `data/object.zod.ts`: drop one ~two majors after the removal, by which point - * it is archaeology rather than an upgrade (the history lives in CHANGELOG.md). + * `data/object.zod.ts`: a tombstone lives through the protocol major AFTER the + * one its key was removed in and leaves when the next major is cut (one major, + * ADR-0087 amendment 2026-10-26, #25767; ~two majors before that). After it + * leaves, the key is refused as a plain strict unknown key and the refusal's + * guide sentence (after its fix channels, ahead of its history) points at the + * protocol upgrade guide + * (`shared/protocol-upgrade-guide.ts`), where the history lives alongside + * CHANGELOG.md. `os migrate meta --from ` rises with it: the support floor + * (`MIGRATION_SUPPORT_FLOOR`) is one major below the current one, so a source + * older than that gets the refusal and the link, not a conversion. */ import { z } from 'zod'; @@ -177,7 +185,7 @@ type Tombstone = z.ZodOptional>; * conditionalRequired: retiredKey( * '`conditionalRequired` was removed in @objectstack/spec 17.0.0 (#3855). ' + * 'Rename the key to `requiredWhen` — the value (a CEL predicate) is unchanged. ' + - * 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; ' + + * 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; ' + * '`--write` applies the ones it can prove, and you apply the rest by hand.', * ), * ``` diff --git a/packages/spec/src/shared/retry-policy.test.ts b/packages/spec/src/shared/retry-policy.test.ts index 619ceb18919..39610294208 100644 --- a/packages/spec/src/shared/retry-policy.test.ts +++ b/packages/spec/src/shared/retry-policy.test.ts @@ -70,52 +70,33 @@ describe('RetryPolicy is a single declaration across entries', () => { expect(parsed.jitter).toBe(false); }); - it('keeps the `retryDelayMs` tombstone loud from both entries', () => { + it('refuses the aged-out `retryDelayMs` from both entries as an unknown key naming the upgrade guide', () => { + // Its tombstone was a 16.x retirement kit and left the shape at 18 (#25767, + // ADR-0087 D2 = A); the shape is strict, so the spelling is still refused + // loudly — as an unknown key, with the guide the rename now lives in. for (const entry of [Automation.RetryPolicySchema, System.RetryPolicySchema]) { const parse = () => entry.parse({ retryDelayMs: 500 }); - expect(parse).toThrow(/backoffMs/); + expect(parse).toThrow(/Unrecognized key\(s\) on this retry policy: `retryDelayMs`/); + expect(parse).toThrow(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); } }); - // The tombstone STRING is not documentation. `shared/retired-key.ts` says so - // in its own words — "an agent bumping `@objectstack/spec` sees THIS string, - // not our docs site" — which makes its enumeration of carrying surfaces - // contract, not prose. It went stale exactly once and in the worst direction: - // it kept listing "an ETL pipeline's `retry`" after #6414 deleted the whole L2 - // layer, so the one message guaranteed to reach an upgrading author named a - // fourth place to write `backoffMs` on which `tsc` now reports TS2724/TS2305 - // (#6630). Nothing could see that: every pre-existing assertion on this string - // matched `/backoffMs/` or `/retryDelayMs/`, i.e. the prescription, never the - // enumeration. Pin the enumeration in BOTH directions. - // - // Note on the assertion shape: there is no ADR-0112 `code`/`status` envelope - // to assert here. `retiredKey()` is a Zod `never` whose issue carries the - // guidance as its `message`, so for this rejection class the wording IS the - // whole contract (#5240) and the message is the only thing worth asserting. - it('the tombstone enumerates exactly the surfaces that still carry the policy', () => { + // The tombstone that used to enumerate the carrying surfaces here (pinned in + // both directions after it went stale once, #6630) aged out at 18 with its + // kit (#25767). What the one message guaranteed to reach an upgrading author + // says now is the strict refusal — the key, this surface's own name, and the + // guide the rename lives in — and it must not name a retired layer either. + it('the refusal names this surface and the upgrade guide, and nothing retired', () => { const result = RetryPolicySchema.safeParse({ retryDelayMs: 500 }); expect(result.success).toBe(false); const message = result.error!.issues.map((issue) => issue.message).join('\n'); - - // Every surface that builds from `retryPolicyShape()` today, spelled the way - // an author writes it. A prescription naming a scope NARROWER than the truth - // is the #4964 defect (a real surface left out reads as "not converged"). - for (const live of ['`job.retryPolicy`', "`try_catch` node's `retry`", '`flow.errorHandling`']) { - expect(message, `the prescription must still name ${live}`).toContain(live); - } - - // ...and nothing retired. Wider than the truth is the #6630 defect: the - // export absence is pinned in `automation/sync-retirement.test.ts`, so a - // surface named here that cannot be imported there is a signpost to a - // compile error. - expect( - message, - 'the prescription must not point at the retired ETL layer', - ).not.toMatch(/\bETL\b/i); - - // None of the above may be bought by weakening the prescription itself. - expect(message).toContain('Rename the key to `backoffMs`'); - expect(message).toContain('os migrate meta --from 16'); + expect(message).toContain('Unrecognized key(s) on this retry policy: `retryDelayMs`'); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + // Wider than the truth is the #6630 defect: the export absence is pinned in + // `automation/sync-retirement.test.ts`, so a surface named here that cannot + // be imported there is a signpost to a compile error. + expect(message, 'the refusal must not point at the retired ETL layer').not.toMatch(/\bETL\b/i); + expect(message).not.toContain('os migrate meta --from 16'); }); }); @@ -196,10 +177,10 @@ describe('RetryPolicySchema refuses a key it does not declare', () => { expect(System.JobSchema.safeParse({ ...job, retryPolicy: declared }).success).toBe(true); }); - it('the retryDelayMs tombstone keeps its own refusal — the rename — rather than becoming an unknown key', () => { + it('the aged-out retryDelayMs is an unknown key of the policy now — one refusal, at the policy (#25767)', () => { const result = RetryPolicySchema.safeParse({ retryDelayMs: 500 }); expect(result.success ? [] : result.error.issues.map((issue) => ({ code: issue.code, path: issue.path }))) - .toEqual([{ code: 'invalid_type', path: ['retryDelayMs'] }]); + .toEqual([{ code: 'unrecognized_keys', path: [] }]); }); }); @@ -294,7 +275,8 @@ describe('every retry surface carries ONE contract', () => { ...minimalFlow, errorHandling: { strategy: 'retry', maxRetries: 2, retryDelayMs: 500 }, }); expect(flowRetired.success).toBe(false); - expect(JSON.stringify(flowRetired.error!.issues)).toMatch(/backoffMs/); + expect(JSON.stringify(flowRetired.error!.issues)).toMatch(/unrecognized_keys[^}]*`retryDelayMs`/); + expect(JSON.stringify(flowRetired.error!.issues)).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('accepts a policy authored once and pasted onto any surface', () => { diff --git a/packages/spec/src/shared/retry-policy.zod.ts b/packages/spec/src/shared/retry-policy.zod.ts index 68d57c1f8ea..e425cdb6cac 100644 --- a/packages/spec/src/shared/retry-policy.zod.ts +++ b/packages/spec/src/shared/retry-policy.zod.ts @@ -75,7 +75,6 @@ import { z } from 'zod'; import { lazySchema } from './lazy-schema'; -import { retiredKey } from './retired-key'; import { strictObject } from './strict-object'; /** @@ -113,28 +112,6 @@ export function retryPolicyShape() { jitter: z.boolean().default(false) .describe('Randomize each delay within [50%, 100%] of its computed value — spreads a thundering herd of simultaneous retries'), - // ── Tombstone (ADR-0087) ──────────────────────────────────────────── - // `retryDelayMs` was the automation-side spelling of `backoffMs`, on BOTH - // `try_catch`'s `retry` (#4661) and `Flow.errorHandling` (#4964). It was - // tombstoned rather than deleted because two of the three owning shapes were - // not `.strict()` then: a plain deletion would have had Zod silently strip - // the authored value and drop the delay back to the 1000ms default, which is - // precisely the quiet-failure class ADR-0049 exists to remove. Every owning - // shape is strict since #22343, and the tombstone is still the better - // channel — it carries the rename, where a bare unknown-key rejection would - // only carry the key. `retry-policy-converged` rewrites it wherever stored - // documents are replayed (it left the load path at 18, #23543 F2, so an - // authored copy meets this tombstone directly); a copy that conversion - // leaves (a `backoffMs` beside it with a different value, or a `null`) - // meets it at parse too. - retryDelayMs: retiredKey( - '`retryDelayMs` was removed in @objectstack/spec 17.0.0 — the retry policy now ' + - 'has ONE spelling for its base delay across every surface that carries it: `job.retryPolicy`, ' + - "a `try_catch` node's `retry` and `flow.errorHandling`. " + - 'Rename the key to `backoffMs`; the value (milliseconds before the first retry) ' + - 'is unchanged. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), }; } diff --git a/packages/spec/src/shared/strict-object.test.ts b/packages/spec/src/shared/strict-object.test.ts index 9a7be738608..ba9d797d0f6 100644 --- a/packages/spec/src/shared/strict-object.test.ts +++ b/packages/spec/src/shared/strict-object.test.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { describe, expect, it } from 'vitest'; import { lazySchema } from './lazy-schema'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from './protocol-upgrade-guide'; import { strictObject } from './strict-object'; import { keySetMatches } from './suggestions.zod'; @@ -239,12 +240,13 @@ describe('message order — the fix comes before the history', () => { expect(m.split(HISTORY)).toHaveLength(2); }); - it('is unchanged when there is no fix to offer', () => { - // No rename, no prescription — the sentence follows the key statement - // directly, exactly as it always did. Full-message pin, so any stray - // separator or duplicated clause fails here. + it('is unchanged in SHAPE when there is no fix to offer', () => { + // No rename, no prescription — the key statement, then the one sentence + // every refusal carries since #25767 (the pointer to the upgrade guide, + // where an aged-out retirement's prescription lives), then the history. + // Full-message pin, so any stray separator or duplicated clause fails here. expect(messageFor({ nonsense: 1 })) - .toBe(`Unrecognized key(s) on this widget: \`nonsense\`. ${HISTORY}`); + .toBe(`Unrecognized key(s) on this widget: \`nonsense\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${HISTORY}`); }); }); diff --git a/packages/spec/src/shared/suggestions.zod.ts b/packages/spec/src/shared/suggestions.zod.ts index 4ed9b1d8c6f..5838ebdea13 100644 --- a/packages/spec/src/shared/suggestions.zod.ts +++ b/packages/spec/src/shared/suggestions.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { z } from 'zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from './protocol-upgrade-guide'; import { aliasProbe } from './alias-probe'; import { @@ -427,6 +428,15 @@ export function strictUnknownKeyError(options: StrictUnknownKeyErrorOptions): z. let message = `Unrecognized key(s) on ${surface}: ${keys.map((k) => `\`${k}\``).join(', ')}.`; if (renames.length) message += ` Did you mean ${renames.join(', ')}?`; if (prescriptions.length) message += `\n${prescriptions.map((p) => ` • ${p}`).join('\n')}`; - return `${message} ${history}`; + // The last fix channel, still ahead of `history`: where an aged-out + // retirement's prescription now lives (ADR-0087 amendment, #25767). A key + // whose tombstone left the shape one major after it retired arrives here as + // a plain unknown key — nothing in this message can tell it from a typo — + // and the generated upgrade guide is the one place its prescription is + // still written down. It is a pointer, not a prescription, so it is one + // static sentence on every refusal rather than a per-key bullet, and it + // sits with the fixes rather than behind the history sentence the #5955 + // ruling moved last. + return `${message} ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${history}`; }; } diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 75556b0f999..e8ab6fd0c6e 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -881,8 +881,7 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { + 'is now always denied. Delete the key; publish public surfaces by declaration instead — a public ' + "form view, a share link, or `book.audience: 'public'`. A stack that mounts no auth at all now " + 'fails at boot rather than silently serving object data to anonymous callers. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing ' - + 'sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', ), /** Enable environment-scoped routing for data/meta/AI APIs. */ enableProjectScoping: z.boolean().optional(), diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index 41cd8fc2888..0d293d3bbb2 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -76,7 +76,7 @@ const BOOK_TRANSLATIONS_RETIRED = + 'near neighbour that DOES work: `doc.translations` is live and read on every doc render ' + 'path — localize the docs themselves, and the portal picks the reader\'s locale up from ' + 'there. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; export const BookGroupSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/system/job.test.ts b/packages/spec/src/system/job.test.ts index 9d1b901835e..62e478119c4 100644 --- a/packages/spec/src/system/job.test.ts +++ b/packages/spec/src/system/job.test.ts @@ -293,11 +293,11 @@ describe('RetryPolicySchema', () => { expect(policy.jitter).toBe(true); }); - it('rejects the retired `retryDelayMs` spelling with the rename prescription', () => { + it('refuses the aged-out `retryDelayMs` spelling as an unknown key naming the upgrade guide (#25767)', () => { const parse = () => RetryPolicySchema.parse({ retryDelayMs: 500 }); - expect(parse).toThrow(/backoffMs/); - expect(parse).toThrow(/retryDelayMs/); + expect(parse).toThrow(/Unrecognized key\(s\) on this retry policy: `retryDelayMs`/); + expect(parse).toThrow(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); // Bounds the job side did not have before the merge. Both fail loudly rather diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index 28e227b31f4..577736ddc00 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -171,7 +171,7 @@ const JOB_ID_RETIRED = + 'key, the `sys_job` row key, and the `JobExecution.jobId` stamp. Two jobs differing only ' + 'in `id` were the same job. Delete the key; rename the job via `name` if you need a ' + 'different identity. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * `job.timeout` → `job.timeoutMs` (#14478). The unit lived only in the @@ -277,8 +277,7 @@ const JOB_RETRY_POLICY_NO_ATTEMPTS = + 'Until @objectstack/spec 18 the loader wrote `maxRetries: 3` into a block that omitted it (the pre-17 ' + 'default); it no longer does, and a retry is a decision the job states, because a retry re-runs ' + 'whatever the failed attempt already did. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies ' - + 'the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; export const JobSchema = lazySchema(() => strictObject({ surface: 'this job', diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 91d9027d304..8d23c62903d 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -1106,7 +1106,7 @@ describe('metadata form ↔ Zod reconciliation — a hand-written form held to i // // Pinned against a SYNTHETIC schema rather than against whichever live schema // happens to carry a tombstone today: tombstones age out (`retired-key.ts` says -// ~two majors), and a self-test anchored to one would either rot or, worse, go +// one major, #25767), and a self-test anchored to one would either rot or, worse, go // quietly vacuous the release its anchor is deleted — leaving a gate that // filters nothing and still reports green. // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 53eacbe2ea0..5be7dbcfcfe 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -649,8 +649,7 @@ const TRANSLATION_KEY_GUIDANCE: Record._validations..message', which the write " - + 'path resolves. Delete this key. Run ' - + '`os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'path resolves. Delete this key. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', o: "`o` is the retired object-first dialect, which no resolver reads — use 'objects.'", app: "`app` is the retired object-first dialect, which no resolver reads — use 'apps.'", nav: "`nav` is the retired object-first dialect, which no resolver reads — use 'apps..navigation..label'", diff --git a/packages/spec/src/ui/action-params.test.ts b/packages/spec/src/ui/action-params.test.ts index 54a6acc3b0d..d3cdacf7c16 100644 --- a/packages/spec/src/ui/action-params.test.ts +++ b/packages/spec/src/ui/action-params.test.ts @@ -11,7 +11,6 @@ import { } from './action-params.zod'; import type { FilterCondition } from '../data/filter.zod'; import type { EngineQueryOptions } from '../data/data-engine.zod'; -import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; const codes = (issues: ReturnType) => issues.map((i) => i.code).sort(); @@ -375,25 +374,6 @@ describe('ActionSession `positions` canonical + `roles` deprecated alias', () => expect(doc).not.toMatch(/deliberately NO `positions` key/); }); - it('registers the rename as an ADR-0087 semantic migration (the reader-facing channel)', () => { - // `spec-changes.json`, the generated upgrade guide and the `spec_changes` - // MCP tool are all projections of this registry. Without the entry the - // rename reaches a consumer only when their read starts returning - // `undefined` — which is the failure mode a deprecation window exists to - // replace with an announcement. - const entry = MIGRATIONS_BY_MAJOR[17]?.semantic - .find((s) => s.id === 'action-session-roles-to-positions'); - - expect(entry).toBeDefined(); - expect(entry!.surface).toBe('ui.actionSession.roles'); - expect(entry!.replacement).toMatch(/positions/); - // It is a RENAME under a window, not the retirement its hook-side - // neighbour (`hook-context-session-roles-retired`, #5050) was — the reason - // must not read as a removal notice. - expect(entry!.reason).toMatch(/deprecation window/); - expect(entry!.reason).toMatch(/ruled contract-first/); - expect(entry!.acceptanceCriteria).toMatch(/ctx\.session\.positions/); - }); }); // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index 66c4124b38b..85607c93e5c 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -1268,11 +1268,13 @@ describe('ActionSchema — the `execute` alias is REMOVED', () => { // was lowered into `target` at parse time and dropped from the output; as of // protocol 17 it is gone from the spec entirely. // - // It is TOMBSTONED rather than deleted, because `ActionSchema` is not + // It was TOMBSTONED rather than deleted while `ActionSchema` was not // `.strict()`: a plain deletion would have silently stripped the key, leaving // the action with no handler bound and no diagnostic — the #2169 "Mark Done - // does nothing" shape. The rejection is what makes the removal audible, and - // it carries the fix so an upgrading agent does not have to find our docs. + // does nothing" shape. The surface closed since, and the tombstone — a 16.x + // retirement kit — aged out at 18 (#25767, ADR-0087 D2 = A). The strict + // refusal is what keeps the removal audible now: it names the key and the + // protocol upgrade guide, where the `target` prescription lives. const parseWithExecute = () => ActionSchema.parse({ name: 'legacy_action', @@ -1285,18 +1287,18 @@ describe('ActionSchema — the `execute` alias is REMOVED', () => { expect(parseWithExecute).toThrow(); }); - it('carries the upgrade prescription in the rejection itself', () => { + it('names the key and the upgrade guide in the refusal itself', () => { // This string is the one upgrade channel every consumer is guaranteed to - // hit — it must name the replacement AND the automated fix. + // hit — it must name the key and where its prescription now lives. let message = ''; try { parseWithExecute(); } catch (err) { message = String(err); } - expect(message).toContain('`execute` was removed'); - expect(message).toContain('use `target`'); - expect(message).toContain('os migrate meta'); + expect(message).toContain('unrecognized_keys'); + expect(message).toContain('`execute`'); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('rejects `execute` even when the canonical `target` is also present', () => { @@ -1308,7 +1310,7 @@ describe('ActionSchema — the `execute` alias is REMOVED', () => { type: 'script', target: 'preferredHandler', execute: 'legacyHandler', - })).toThrow(/`execute` was removed/); + })).toThrow(/`execute`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('accepts the canonical `target` on its own', () => { @@ -1476,13 +1478,13 @@ describe('ActionSchema - target required for non-script types', () => { // Before protocol 17 this parsed: `execute` was lowered into `target`, so a // flow action bound through the alias worked. The alias is gone, so the // action now fails BOTH on the removed key and on its missing `target` — - // and the first message tells the author which key to rename. + // and the refusal names the key (and, since #25767, the upgrade guide). expect(() => ActionSchema.parse({ name: 'flow_legacy', label: 'Flow Legacy', type: 'flow', execute: 'my_flow', - })).toThrow(/`execute` was removed/); + })).toThrow(/`execute`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); expect(ActionSchema.parse({ name: 'flow_canonical', @@ -1544,13 +1546,16 @@ describe('ACTION_LOCATIONS — canonical source of truth', () => { it('refuses the value WITH the prescription — bare enum and whole action alike', () => { // The prescription: names the value, says it was removed, points at the // live alternatives (a served location, or the headless `locations: []`), - // and closes with the house `os migrate meta` sentence (#6856 route D). + // and closes with the house `os migrate meta` sentence (#6856 route D) — + // in its post-floor form since #25767: the protocol-17 step that carried + // the rewrite aged out, so the sentence says so and links the guide. const prescribes = (fn: () => unknown) => { expect(fn).toThrow(/`global_nav` was removed from `ACTION_LOCATIONS`/s); expect(fn).toThrow(/ADR-0049 enforce-or-remove/s); expect(fn).not.toThrow(/#\d{3,5}\b/); expect(fn).toThrow(/locations: \[\]/s); - expect(fn).toThrow(/os migrate meta --from 16/s); + expect(fn).toThrow(/support floor 17/s); + expect(fn).not.toThrow(/os migrate meta --from 16/s); }; prescribes(() => ActionLocationSchema.parse('global_nav')); @@ -1631,21 +1636,20 @@ describe('ACTION_LOCATIONS — canonical source of truth', () => { }); describe('audit close-out — retired shortcut/bulkEnabled, capability claims nothing enforced', () => { - it('REJECTS the retired `shortcut` and names the real keyboard stack', () => { + // Both tombstones were 16.x retirement kits and aged out at 18 (#25767): + // each key is a strict unknown key now, refused with the upgrade guide the + // keyboard-stack / `bulkActions` prescriptions moved to. + it.each([ + ['shortcut', 'Ctrl+S'], + ['bulkEnabled', true], + ])('REFUSES the aged-out `%s` as an unknown key naming the upgrade guide', (key, value) => { let message = ''; try { - ActionSchema.parse({ name: 'save_now', label: 'Save', type: 'script', target: 'h', shortcut: 'Ctrl+S' }); + ActionSchema.parse({ name: 'save_now', label: 'Save', type: 'script', target: 'h', [key]: value }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/keyboard stack/); - expect(message).toMatch(/audit close-out/); - }); - it('REJECTS the retired `bulkEnabled` and points at the view bulkActions', () => { - let message = ''; - try { - ActionSchema.parse({ name: 'close_all', label: 'Close', type: 'script', target: 'h', bulkEnabled: true }); - } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/bulkActions/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/unrecognized_keys/); + expect(message).toContain(`\`${key}\``); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 16d72892f31..3686fee600d 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -608,7 +608,8 @@ const TARGET_REQUIRED_TYPES: ReadonlySet = new Set( * The `execute` alias was **removed in protocol 17** (#3855). `target` is the * only handler slot, so no consumer has a second slot to disagree about. An * authored `execute` is rejected with the rename prescription rather than - * silently stripped; `os migrate meta --from 16` lists the edit for you to apply. + * silently stripped; `os migrate meta --from 16` listed the edit until the + * protocol-16.x step aged out with the support floor (#25767). * * @example Good action names * - 'on_close_deal' @@ -642,7 +643,7 @@ const GLOBAL_NAV_RETIRED = + '`record_related`, `record_section`), or — for an action that deliberately has no UI home, ' + 'such as an object-less one invoked over REST/MCP/AI — declare it headless with ' + '`locations: []`, which keeps its capability gate, param contract and audit trail. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * Action Location — where an action is allowed to surface in the UI. @@ -1247,21 +1248,6 @@ const actionObject = () => strictObject({ */ execution: BulkActionExecutionSchema.optional().describe("The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`)."), - /** - * [REMOVED in protocol 17 — #3855] The deprecated alias of `target`. - * Tombstoned rather than deleted: `ActionSchema` is `strictObject`, so a - * plain deletion would reject the key — but with a generic unknown-key error - * that does not name `target`. The prescription is the payload: it carries - * the rename, so an author who wrote `execute` is told where the handler ref - * goes instead of only that the key is unknown. It also types the key - * `never`, so the mistake fails `tsc` before any parse runs. - */ - execute: retiredKey( - '`execute` was removed in @objectstack/spec 17 — use `target`. ' + - 'Rename the key; the value (a handler / flow / URL ref) is unchanged. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * User Input Requirements — the **parameter DEFINITION array** rendered as a * dialog before the action runs. `ActionParam[]`, never a values map. @@ -1324,7 +1310,7 @@ const actionObject = () => strictObject({ + "(`${param.X}` interpolates a value collected by the params dialog, `${ctx.X}` one from the action context), and open a new tab with " + "`openIn: 'new-tab'`. The url-side readings of an object `params` — a static `${param.X}` scope, and `params.newTab` — are RETIRED, not renamed. " + 'Expected an array of ActionParam, received an object. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for the inline `element:button` action case; `--write` applies the ones it can prove, and a registered action keeps its object form until you move it by hand.' + + 'The inline `element:button` case left `os migrate meta` with the protocol-16.x step (support floor 17): move it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade; a registered action keeps its object form until you move it by hand.' : undefined ), }).optional().describe('Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`).'), @@ -1558,27 +1544,6 @@ const actionObject = () => strictObject({ requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there.'), /** Keyboard Shortcut */ - // `shortcut` and `bulkEnabled` REMOVED by the 2026-07 #3896 audit close-out — - // both were authorable capability claims nothing enforced (ledger: dead, - // #3686 re-verification): ActionEngine registered them but no keydown path - // ever dispatched a shortcut, and the multi-select toolbar reads the LIST - // VIEW's bulkActions, never this flag. Tombstoned with the prescription; - // `action-inert-keys-removed` strips them from authored sources. - shortcut: retiredKey( - '`action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + - 'it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and ' + - "objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults " + - 'action metadata. Delete the key. For a real shortcut, register the key in the Console ' + - 'keyboard stack and have its handler invoke the action by name. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - bulkEnabled: retiredKey( - '`action.bulkEnabled` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + - 'the multi-select toolbar is driven by the LIST VIEW\'s `bulkActions` / `bulkActionDefs`, ' + - 'never by this flag, so setting it changed nothing. Delete the key and declare the action ' + - "in the view's `bulkActions` instead. " + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * AI exposure block (ADR-0011). Opt-in, default off: an action is exposed diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index 2b185dd573a..318e1d16930 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -588,10 +588,11 @@ describe('AppSchema', () => { expect(() => AppSchema.parse(hrApp)).not.toThrow(); }); - it('rejects the retired app-level sharing/embed blocks with the per-form-view pointer', () => { + it('refuses the aged-out app-level sharing/embed blocks as unknown keys naming the upgrade guide', () => { // Pre-#4001 these parsed and were never read — a declared-but-unenforced - // security surface (2026-06 liveness audit, ADR-0049). The tombstone - // carries the prescription: public access is per FORM VIEW. + // security surface (2026-06 liveness audit, ADR-0049). Their tombstones + // were 16.x retirement kits and left the shape at 18 (#25767); the strict + // shell refuses both, and the guide carries the per-FORM-VIEW prescription. const result = AppSchema.safeParse({ name: 'shared_portal', label: 'Shared Portal', @@ -600,8 +601,9 @@ describe('AppSchema', () => { embed: { enabled: true }, }); expect(result.success).toBe(false); - const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('FormView.sharing'); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys') as { keys: string[]; message: string }; + expect(issue.keys.sort()).toEqual(['embed', 'sharing']); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should accept CRM app with deeply nested navigation tree', () => { @@ -676,8 +678,9 @@ describe('App Mobile Navigation (retired)', () => { }); expect(result.success).toBe(false); const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('mobileNavigation'); - expect(messages).toContain('unimplemented'); + expect(messages).toContain('`mobileNavigation`'); + // The tombstone aged out at 18 (#25767): the strict refusal, with the guide. + expect(messages).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); @@ -712,24 +715,16 @@ describe('defineApp', () => { }); describe('AppSchema retired sharing/embed keys', () => { - it('rejects app-level sharing with the FormView prescription', () => { + it.each(['sharing', 'embed'])('refuses app-level `%s` as an unknown key naming the upgrade guide (#25767)', (key) => { const result = AppSchema.safeParse({ name: 'public_app', label: 'Public App', - sharing: { enabled: true }, + [key]: { enabled: true }, }); expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('FormView.sharing'); - }); - - it('rejects app-level embed with the per-form-view prescription', () => { - const result = AppSchema.safeParse({ - name: 'embeddable_app', - label: 'Embeddable App', - embed: { enabled: true }, - }); - expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('iframe'); + const messages = result.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain(`Unrecognized key(s) on this app: \`${key}\``); + expect(messages).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('should accept app without sharing/embed (backward compatibility)', () => { @@ -1068,25 +1063,24 @@ describe('AppSchema with areas', () => { }); // #4001 (app step, PR A) — the seven keys the 2026-06 AppSchema liveness audit -// verdicted DEAD are retiredKey() tombstones: tsc-level `never` for typed -// authors, and a parse-time prescription for everyone else. Not a silent -// strip: AppSchema is not yet .strict(), so a bare deletion would have -// reintroduced the exact failure mode this campaign eliminates. -describe('retired dead keys carry prescriptions', () => { +// verdicted DEAD were retiredKey() tombstones: tsc-level `never` for typed +// authors, and a parse-time prescription for everyone else, while AppSchema +// was not yet .strict(). The shell closed since, and the tombstones — 16.x +// retirement kits — aged out at 18 (#25767, ADR-0087 D2 = A). Not a silent +// strip even so: each key is a strict unknown key, refused with the protocol +// upgrade guide the prescriptions moved to. +describe('retired dead keys are refused as unknown keys naming the upgrade guide', () => { it.each([ - ['version', '1.0.0', 'manifest.version'], - // ⚠️ The expected fragment used to be `component/widget`. The widget half - // of that prescription named `dashboard.widgets[].aria`, retired by #5010 - // in this same 17.0.0 — so the pin held `App.aria`'s prescription pointing - // at another tombstone (#6756). Re-aimed at the surviving carrier. - ['aria', { label: 'x' }, 'page.components[].aria'], - ['objects', [], 'defineStack'], - ['apis', [], 'defineStack'], - ] as const)('rejects `%s` with its upgrade prescription', (key, value, fragment) => { + ['version', '1.0.0'], + ['aria', { label: 'x' }], + ['objects', []], + ['apis', []], + ] as const)('refuses `%s`', (key, value) => { const result = AppSchema.safeParse({ name: 'app_x', label: 'X', [key]: value }); expect(result.success).toBe(false); - const messages = result.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain(fragment); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys') as { keys: string[]; message: string }; + expect(issue.keys).toEqual([key]); + expect(issue.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('an app without any retired key parses unchanged', () => { @@ -1095,46 +1089,6 @@ describe('retired dead keys carry prescriptions', () => { navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], })).not.toThrow(); }); - - // #5238 (#5040 E9) — the `apis` tombstone redirects to `defineStack({ apis })`, - // so it also has to be TRUE about what that surface does. It used to add "which - // is ALSO not executable in v17 (#4936) … until the endpoint executor ships", - // and the executor shipped: an author following the prescription today would be - // told to move a declaration to a place the same sentence calls dead, and would - // reasonably keep serving the route from handler code instead. - // - // The assertions below are deliberately two-sided. Asserting only the presence - // of the new words would keep passing if the retired claim were re-added - // underneath them, and asserting only the absence would pass on an empty - // string — the vacuity the surrounding pins exist to avoid. - describe('the `apis` prescription tracks the executor', () => { - const prescription = (): string => { - const result = AppSchema.safeParse({ name: 'app_x', label: 'X', apis: [] }); - expect(result.success).toBe(false); - return result.error!.issues.map((i) => i.message).join('\n'); - }; - - it('states the stack-level surface EXECUTES, and where the endpoint may live', () => { - const messages = prescription(); - expect(messages).toContain('defineStack({ apis })'); - expect(messages).toContain('EXECUTES from protocol 17'); - // The one edit an author moving a declaration up must make (ADR-0121 D1/D2). - expect(messages).toContain('/api/v1/apps//'); - // The key whose wrong answer is unrecoverable, plus its D6 obligation. - expect(messages).toContain('`authRequired` defaults to `true`'); - expect(messages).toContain('enabled: true'); - // Where the full security review lives — one source of truth, not a copy. - expect(messages).toContain('declarative-apis-endpoints-live'); - }); - - it('no longer tells the author that stack-level `apis:` is rejected', () => { - const messages = prescription(); - expect(messages).not.toContain('not executable in v17'); - expect(messages).not.toContain('until the endpoint executor ships'); - // The historical fact stays: #4936 happened, and the prescription says so. - expect(messages).toContain('refused wholesale'); - }); - }); }); // #4001 (app step, PR B) — the app shell is `.strict()`, and the nav-item @@ -1174,10 +1128,10 @@ describe('unknown keys are rejected, not stripped', () => { .toContain('defineStack'); }); - it('keeps the PR A tombstone prescriptions rather than a bare strict error', () => { + it('the PR A tombstones aged out (#25767): the strict error carries the upgrade guide instead', () => { const result = AppSchema.safeParse({ name: 'app_a', label: 'A', sharing: { enabled: true } }); expect(result.success).toBe(false); - expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('FormView.sharing'); + expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); // #4829 — the ADR-0045 publish gate's acceptance face, both directions. @@ -1384,40 +1338,19 @@ describe('unknown keys are rejected, not stripped', () => { }); // ── homePageId / areas[].order retired in 17.0.0 (#4667, ADR-0049) ──────── describe('retired app keys', () => { - it('rejects `homePageId` and names what actually decides the landing page', () => { - // Tombstoned (retiredKey), matching the seven #4142 retirements on this - // schema — so it is a tsc error as well as a parse error. Post-v17 the - // landing page really is the first navigation item; what the message must - // NOT do is explain *why* by claiming nobody read the key (see below). - const parse = () => AppSchema.parse({ - name: 'crm', label: 'CRM', homePageId: 'nav_pipeline', - navigation: [{ id: 'nav_home', type: 'object', label: 'Home', objectName: 'account' }], - }); - expect(parse).toThrow(/homePageId.*removed.*17\.0\.0/s); - expect(parse).toThrow(/first navigation item/s); - expect(parse).toThrow(/isDefault/s); - }); - - it('the tombstone states the REAL reason, not the false "no shell read it" premise', () => { - // #4667 shipped this tombstone claiming "no shell ever read it". It was - // false — objectui's console read the key in `resolveLandingRoute()` - // (packages/app-shell/src/console/AppContent.tsx @785b8a5d) and it was the - // only thing deciding where an app opened — and it contradicted this - // repo's own 2026-06 AppSchema liveness audit, which listed the key LIVE. - // The removal stands (#4709 ruling B): the key encoded the landing page as - // an ID cross-reference that silently fell back when it dangled. Pinned - // because a tombstone is what the next reader reasons FROM: this sentence - // already sent one session to the renderer to discover it was wrong, and a - // regression would re-arm exactly that trap. + it('refuses `homePageId` as an unknown key naming the upgrade guide (#25767)', () => { + // Its tombstone (the one #4709 corrected to state the REAL reason — the + // console did read the key in `resolveLandingRoute()`) was a 16.x + // retirement kit and aged out at 18. The strict shell keeps the removal + // loud: the refusal names the key and the guide the prescription lives in. + // The three retired aliases below still answer with the prescription — + // they are unknown-key guidance on the strict shell, not ledger rows. const parse = () => AppSchema.parse({ name: 'crm', label: 'CRM', homePageId: 'nav_pipeline', navigation: [{ id: 'nav_home', type: 'object', label: 'Home', objectName: 'account' }], }); + expect(parse).toThrow(/`homePageId`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); expect(parse).not.toThrow(/no shell ever read/i); - expect(parse).toThrow(/did read it/i); - expect(parse).toThrow(/resolveLandingRoute/); - // The prescription is unchanged by the correction — reorder, or isDefault. - expect(parse).toThrow(/reorder `navigation`/s); }); it('routes the three retired homePageId aliases to the same prescription', () => { diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 74d3de955a2..14e9b06bc4b 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -5,7 +5,6 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { I18nLabelSchema } from './i18n.zod'; import { ElementApiDataSourceSchema } from './page.zod'; -import { retiredKey } from '../shared/retired-key'; import { strictObject, type StrictObjectOptions } from '../shared/strict-object'; import { lazySchema } from '../shared/lazy-schema'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; @@ -1234,8 +1233,7 @@ const AREA_ORDER_RETIRED = + 'ever sorted areas; both the sidebar and the schema renderer iterate the array as ' + 'authored, so declaration order already IS display order. Delete the key and reorder the ' + '`areas` array itself. NOTE the neighbour that behaves differently: a navigation ITEM\'s ' - + '`order` is genuinely sorted — this removal does not touch it. Run ' - + '`os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + '`order` is genuinely sorted — this removal does not touch it. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * `app.areas[].visible` and `app.areas[].requiredPermissions`, retired in @@ -1285,8 +1283,7 @@ const AREA_VISIBLE_RETIRED = + 'tree — the app\'s top-level `navigation` AND every `areas[].navigation`, both stripped ' + 'server-side. The distinction survives at every level: `visible` is CEL ' + 'evaluated in the browser, so it hides an entry that has already been sent, while ' - + '`requiredPermissions` stops that entry from being served at all. Run ' - + '`os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + '`requiredPermissions` stops that entry from being served at all. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; const AREA_REQUIRED_PERMISSIONS_RETIRED = '`areas[].requiredPermissions` was removed in @objectstack/spec 17.0.0 (ADR-0049) — ' @@ -1301,7 +1298,7 @@ const AREA_REQUIRED_PERMISSIONS_RETIRED = + 'area-level key is not revived. Still evaluated client-side ONLY, at every level: ' + '`visible` (CEL) and `requiresObject` — so anything that must never reach the browser ' + 'goes in `requiredPermissions`, never in `visible`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * Navigation Area Schema @@ -1441,7 +1438,7 @@ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> = + 'offered an All row regardless of this flag, so `includeAll: false` hardened nothing ' + 'and `includeAll: true` unlocked nothing. Delete the key. To widen what a selector ' + 'offers, widen `optionsSource.filter` instead. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', showall: '`contextSelectors[].includeAll` (which `showall` aliased) was removed in ' + '@objectstack/spec 17.0.0 — selectors are mandatory-scope and never render an ' @@ -1449,8 +1446,7 @@ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> = placement: '`contextSelectors[].placement` was removed in @objectstack/spec 17.0.0 (' + 'ADR-0049) — no renderer ever read it. Selectors always render in the sidebar header ' - + "block, and `'topbar'` placed nothing in the topbar. Delete the key. Run " - + '`os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + "block, and `'topbar'` placed nothing in the topbar. Delete the key. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.", location: '`contextSelectors[].placement` (which `location` aliased) was removed in ' + '@objectstack/spec 17.0.0 — selectors always render in the sidebar header. ' @@ -1633,7 +1629,7 @@ const HOME_PAGE_ID_RETIRED = + 'follows `isDefault` routing. Delete the key; to change where an app opens, ' + 'reorder `navigation` so the intended entry is first, and set `isDefault` on the app that ' + 'should own the root landing. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * The prescription for every author-shaped spelling of the ADR-0045 publish @@ -1716,18 +1712,6 @@ export const AppSchema = lazySchema(() => strictObject( /** Display label */ label: I18nLabelSchema.describe('App display label'), - /** - * REMOVED — never read by any consumer (2026-06 AppSchema liveness audit). - * An app's version is its owning package's `manifest.version`; a second - * per-app number had no reader and could silently disagree with it. - */ - version: retiredKey( - '`App.version` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' + - 'no consumer in framework or objectui). An app is versioned by its owning package: ' + - 'use `manifest.version`. Delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** Description */ description: I18nLabelSchema.optional().describe('App description'), @@ -1840,14 +1824,6 @@ export const AppSchema = lazySchema(() => strictObject( contextSelectors: z.array(AppContextSelectorSchema).optional() .describe('App-level scope dropdowns whose value is injected into nav items as {} template vars'), - /** - * REMOVED in 17.0.0 (#4667) — see {@link HOME_PAGE_ID_RETIRED}. Tombstoned - * rather than deleted, matching the seven #4142 retirements on this schema: - * `retiredKey` types it `never`, so an app still authoring it fails to - * compile as well as to parse. - */ - homePageId: retiredKey(HOME_PAGE_ID_RETIRED), - /** * Access Control * List of permissions required to access this app. @@ -1856,77 +1832,6 @@ export const AppSchema = lazySchema(() => strictObject( */ requiredPermissions: z.array(z.string()).optional().describe('Permissions required to access this app'), - /** - * REMOVED — the self-described "config file convenience" slots were never - * read (2026-06 liveness audit): objects register via `defineStack`, and the - * ambient chatbot derives an app's object list from its NAV ITEMS - * (`collectNavObjects`), never from `App.objects`. - */ - objects: retiredKey( - '`App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' + - 'never read; the spec itself labelled it "config file convenience"). Objects belong ' + - 'to the stack (`defineStack({ objects })`); an app reaches them through its ' + - 'navigation items. Delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - apis: retiredKey( - '`App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' + - 'never read). Delete the key and declare the endpoint one level up, on the STACK: ' + - '`defineStack({ apis })`. That surface EXECUTES from protocol 17. Before ' + - 'the executor landed it was refused wholesale — nothing mounted a declared ' + - 'path, so every key including `authRequired` parsed and gated nothing — and that ' + - 'blanket refusal is now narrowed to five per-endpoint publish gates (namespace, ' + - 'supported target, mapping, policy, uniqueness): an endpoint that passes them is ' + - 'mounted and serves traffic as soon as the stack is published. Two things to get ' + - 'right when you move it: the path must sit inside your own carve-out, ' + - '`/api/v1/apps//` with an explicit `manifest.namespace` ' + - '(ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the ' + - 'only thing that opens anonymous access, and ADR-0121 D6 then requires an armed ' + - '`rateLimit: { enabled: true, windowMs, maxRequests }`. Read the ' + - '`declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a ' + - 'security review, not a rename. An endpoint whose work needs CODE still lands here: ' + - 'compute in a flow and register the code as a stack function ' + - '(`defineStack({ functions })`) or a connector — an app does not mount routes on ' + - '`http.server`, and the manifest `contributes.routes` key was removed in ' + - '@objectstack/spec 17 (nothing ever read it; authoring it is now rejected with its ' + - 'own prescription). ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - /** - * REMOVED — a declared-but-unenforced security surface (ADR-0049 class, - * 2026-06 liveness audit): the only live sharing/embed path is - * `FormView.sharing` (public data collection); no public-app or iframe route - * ever read the app-level blocks, so authoring them created a false - * security/feature impression. - */ - sharing: retiredKey( - '`App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ' + - 'ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared ' + - 'sharing that did not exist. Public access is granted per FORM VIEW ' + - '(`FormView.sharing`, the public-data-collection surface). Delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - embed: retiredKey( - '`App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ' + - 'ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface ' + - '(`FormView.sharing`), not an app-level switch. Delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - /** - * REMOVED — fully unimplemented (2026-06 liveness audit: even - * `packages/mobile` ignored it). Re-admit only together with a real mobile - * navigation implementation, per the ADR-0049 trichotomy — a mode picker - * that changes nothing is an authoring trap. - */ - mobileNavigation: retiredKey( - '`App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness ' + - 'audit — fully unimplemented; no renderer, including packages/mobile, ever read ' + - 'it). Delete the key; the block returns if/when a real mobile navigation ships. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * Default agent for this app's ambient chat surface. * @@ -1961,21 +1866,6 @@ export const AppSchema = lazySchema(() => strictObject( defaultAgent: SnakeCaseIdentifierSchema.optional() .describe("Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1"), - /** - * REMOVED — never read at the APP level (2026-06 liveness audit). ARIA - * attributes are live on the page / page-component / list-view surfaces that - * render DOM. NOT on a dashboard widget: `dashboard.widgets[].aria` was - * retired in this same 17.0.0 (#5010), so "component/widget" pointed half of - * its readers at another tombstone (#6756). - */ - aria: retiredKey( - '`App.aria` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — no ' + - 'renderer read app-level ARIA attributes). Declare `aria` on the page component ' + - 'that renders the DOM node instead (`page.components[].aria`; `page.aria` and the ' + - 'list view `aria` are live too). Delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** * ADR-0010 §3.7 — Package-level protection envelope. Package * authors declare lock policy here, in a code package's source; diff --git a/packages/spec/src/ui/aria-carrier-tombstones.test.ts b/packages/spec/src/ui/aria-carrier-tombstones.test.ts index 60823c620dd..a66d02c0796 100644 --- a/packages/spec/src/ui/aria-carrier-tombstones.test.ts +++ b/packages/spec/src/ui/aria-carrier-tombstones.test.ts @@ -37,6 +37,15 @@ import { ObjectSchema } from '../data/object.zod'; * class. `retiredKey()` is a Zod `never` whose issue carries the guidance as * its `message`, so here the wording IS the whole contract (#5240). * + * [#25767] The two tombstones this file was written for — `dashboard.widgets[].aria` + * and `App.aria`, both 17.0.0 — were 16.x retirement kits and aged out of + * their shapes at 18 (ADR-0087 amendment, D2 = A). Each key is a strict + * unknown key now, refused with the protocol upgrade guide the prescriptions + * moved to, so there is no enumeration left on either to go stale at the + * other; the first case below pins that shape. The ground-truth half and the + * third member of the family (`ChartConfig.aria`, 17.5.0, still in its + * window) carry on unchanged. + * * ⚠️ Two halves, two directions — stated up front because only one of them can * go red on the defect this file was written for: * @@ -69,68 +78,21 @@ describe('the `aria` tombstones name only live `AriaProps` carriers', () => { return result.error!.issues.map((i) => i.message).join('\n'); }; - it('the widget tombstone still fires, and points at surfaces that survived 17.0.0', () => { - const message = messageOf(DashboardWidgetSchema.safeParse(widgetWithAria)); - - // Anti-vacuity. Deleting the tombstone outright does not silence this file: - // `DashboardWidgetSchema` is a `strictObject`, so the key would still be - // refused — but as a generic unrecognized-key guidance that cannot contain - // the prescription's own opening clause. Wording drift fails the assertions - // below; DISAPPEARANCE fails this one. - expect( - message, - 'the widget `aria` prescription must still be reachable through the parse', - ).toContain('`dashboard.widgets[].aria` was removed'); - - // Every surface that still declares `aria: AriaPropsSchema` and is graded - // `live` in the liveness ledger, spelled the way an author writes it. A - // prescription NARROWER than the truth is the #4964 defect. - for (const live of ['`page.aria`', '`page.components[].aria`', 'list view `aria`']) { - expect(message, `the prescription must still name ${live}`).toContain(live); + it('the two aged-out tombstones are strict unknown keys now, refused with the upgrade guide (#25767)', () => { + for (const [name, result] of [ + ['`dashboard.widgets[].aria`', DashboardWidgetSchema.safeParse(widgetWithAria)], + ['`App.aria`', AppSchema.safeParse(appWithAria)], + ] as const) { + const message = messageOf(result); + // The refusal names the key and where its prescription lives — and + // carries no enumeration of carriers, so the #6756 loop cannot re-form. + expect(message, name).toContain('`aria`'); + expect(message, name).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message, name).not.toContain('was removed'); } - - // ...and nothing retired. WIDER than the truth is the #6756 defect itself: - // `app.aria` is `dead` in `liveness/app.json` and stripped by the - // protocol-17 `app-dead-authoring-keys-removed` conversion, so naming it - // sent the author to a door that does not exist. Matched by idiom (any - // mention at all), not by the exact sentence that used to carry it. - expect( - message, - 'the prescription must not point at `app.aria`, retired in this same major', - ).not.toMatch(/app\.aria/i); - - // None of the above may be bought by weakening the prescription itself. - expect(message).toContain('author a `title`'); - // The conversion STRIPS this key (`stripKeys(..., [... 'aria'])`). The - // key's fate is the body's job ("Delete the key."), asserted above; the - // `os migrate meta` sentence states what the TOOL does and is the house - // sentence (#6856 route D — superseding the #6854 `to remove it` pin, - // an accepted cost of that ruling). Class-wide shape enforcement lives in - // `shared/retired-key-migrate-sentence.test.ts`; this pin holds the one - // site that already flipped once. - expect(message).toContain('Delete the key.'); - expect(message).toContain('os migrate meta --from 16'); - expect(message).toMatch(/to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\.$/); - }); - - it('the App.aria tombstone points at a page component, not at the retired widget surface', () => { - const message = messageOf(AppSchema.safeParse(appWithAria)); - - // Anti-vacuity, same shape as above. - expect( - message, - 'the `App.aria` prescription must still be reachable through the parse', - ).toContain('`App.aria` was removed'); - - expect(message, 'the prescription must name the surviving carrier').toContain('`page.components[].aria`'); - - // The reciprocal half of the loop. "component/widget" read as an - // instruction sends half its readers at `dashboard.widgets[].aria`, which - // #5010 retired in this same 17.0.0. - expect( - message, - 'the prescription must not send the author to a dashboard widget', - ).not.toMatch(/widget/i); + // The widget refusal must not point the author at `app.aria` either — the + // suggester offers no rename onto a key the shell no longer declares. + expect(messageOf(DashboardWidgetSchema.safeParse(widgetWithAria))).not.toMatch(/app\.aria/i); }); // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index a6ec1d1c966..cf178e57928 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -121,9 +121,13 @@ describe('PageHeaderProps recordChrome / showCopyId — declared because the hea // the header's own bag is never asked for one, and the registration publishes // no `icon` input. Four in-repo pages authored it and none ever drew it. describe('PageHeaderProps icon is retired — no renderer reads it', () => { - it('rejects the retired `icon` with its prescription', () => { + // #25767: the tombstone was a 16.x retirement kit and aged out (ADR-0087 + // D2 = A), so the key is refused as a strict unknown key and the refusal's + // trailing sentence names the protocol upgrade guide instead of carrying + // the `recordChrome` prescription itself. + it('refuses the retired `icon` as a strict unknown key, pointing at the upgrade guide', () => { expect(() => PageHeaderProps.parse({ title: 'Connect an Agent', icon: 'bot' })) - .toThrow(/`icon`.*removed.*`recordChrome`/s); + .toThrow(/Unrecognized key\(s\) on this `page:header`: `icon`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('does not materialize the retired `icon` on a clean parse', () => { @@ -240,12 +244,12 @@ describe('PageTabsProps tabStyle — renamed from `type`, which collides with th } }); - it('rejects the retired `type` with the rename prescription', () => { - // Not `.toThrow()` alone: an undeclared key on this non-strict schema would - // be stripped silently, and a bare throw assertion cannot tell the two - // apart. The message IS the migration doc, so it is what gets asserted. + it('refuses the retired `type` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { + // Not `.toThrow()` alone: a stripping schema would drop the key silently, + // and a bare throw assertion cannot tell the two apart. The `tabStyle` + // rename prescription left with its 16.x tombstone; the guide carries it. expect(() => PageTabsProps.parse({ type: 'card', items: [] })) - .toThrow(/`type`.*removed.*`tabStyle`/s); + .toThrow(/Unrecognized key\(s\) on this `page:tabs`: `type`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('does not materialize the retired `type` on a clean parse', () => { @@ -447,9 +451,9 @@ describe('PageCardProps', () => { expect(result.children).toHaveLength(1); }); - it('rejects the retired `body` with the rename prescription', () => { + it('refuses the retired `body` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { expect(() => PageCardProps.parse({ body: ['component1'] })) - .toThrow(/`body`.*removed.*`children`/s); + .toThrow(/Unrecognized key\(s\) on this `page:card`: `body`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('does not materialize the retired `body` on a clean parse', () => { @@ -461,9 +465,9 @@ describe('PageCardProps', () => { // title/bordered/children/footer and has no actions area; the objectui // registration publishes no `actions` input either. The prescription points // at composition, which is what actually renders. - it('rejects the retired `actions` with the composition prescription', () => { + it('refuses the retired `actions` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { expect(() => PageCardProps.parse({ title: 'Shortcuts', actions: ['new_task'] })) - .toThrow(/`actions`.*removed.*`children`.*`footer`/s); + .toThrow(/Unrecognized key\(s\) on this `page:card`: `actions`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('does not materialize the retired `actions` on a clean parse', () => { @@ -479,7 +483,6 @@ describe('pageComponentSlotPositions — the one slot list, derived from the row it('names exactly the positions the rows declare, in the walks\' visit order', () => { expect(pageComponentSlotPositions()).toEqual([ { key: 'children', retired: false }, - { key: 'body', retired: true }, { key: 'footer', retired: false }, { key: 'items', panelKey: 'children', retired: false }, ]); @@ -489,7 +492,9 @@ describe('pageComponentSlotPositions — the one slot list, derived from the row const cardShape = PageCardProps.shape as Record; const tabsItem = (PageTabsProps.shape.items as any).def.element.shape as Record; const accordionItem = (PageAccordionProps.shape.items as any).def.element.shape as Record; - for (const key of ['children', 'body', 'footer']) expect(cardShape).toHaveProperty(key); + for (const key of ['children', 'footer']) expect(cardShape).toHaveProperty(key); + // `body` (≤16.x kit, aged out on #25767) is a strict unknown key, not a row. + expect(cardShape).not.toHaveProperty('body'); expect(PageContainerProps.shape).toHaveProperty('children'); expect(tabsItem).toHaveProperty('children'); expect(accordionItem).toHaveProperty('children'); @@ -506,7 +511,8 @@ describe('pageComponentSlotPositions — the one slot list, derived from the row const unmarked = z.array(z.unknown()).optional().describe('Card footer components (slot)'); expect(z.toJSONSchema(footer)).toEqual(z.toJSONSchema(unmarked)); expect(footer.parse(['bare-id', { type: 'element:button' }])).toEqual(['bare-id', { type: 'element:button' }]); - expect(() => PageCardProps.parse({ body: [] })).toThrow(/`body`.*removed.*`children`/s); + expect(() => PageCardProps.parse({ body: [] })) + .toThrow(/Unrecognized key\(s\) on this `page:card`: `body`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('is derived once and handed back frozen', () => { @@ -838,13 +844,15 @@ describe('RecordDetailsProps', () => { // two keys in that ruling this one WAS read — against `inline`/`compact`, // values this enum never permitted — so both legal values took the same // branch and the key selected nothing. - it('rejects the retired `layout` with its prescription', () => { + // The ≤16.x kit aged out on #25767: `layout` is a strict unknown key whose + // refusal names the upgrade guide, not the old prescription. + it('refuses the retired `layout` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { expect(() => RecordDetailsProps.parse({ layout: 'custom' })) - .toThrow(/`layout`.*removed.*`sections`.*`highlightFields`/s); + .toThrow(/Unrecognized key\(s\) on [^:]+: `layout`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); // The declared default is refused too — `auto` was never distinguishable // from `custom` or from omitting the key. expect(() => RecordDetailsProps.parse({ layout: 'auto' })) - .toThrow(/`layout`.*removed/s); + .toThrow(/Unrecognized key\(s\) on [^:]+: `layout`/); }); it('does not materialize the retired `layout` on a clean parse', () => { @@ -2148,30 +2156,21 @@ describe('Interactive Elements — element:record_picker', () => { }).success).toBe(true); }); - // #5775 tombstones — the prescription IS the payload. `displayField` was a - // REQUIRED declaration no renderer read; `searchFields` / `multiple` were - // capability claims the single-select control never kept (ADR-0049). - it('rejects the retired `displayField` with the rename prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ displayField: 'title' })) - .toThrow(/displayField.*removed.*use `labelField`|displayField.*removed.*`labelField`/s); - }); - - it('rejects the retired `searchFields` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ searchFields: ['name'] })) - .toThrow(/`searchFields`.*removed.*Delete the key/s); - }); - - it('the `searchFields` prescription names the binding\'s filter, never the retired flat one', () => { - const r = ElementRecordPickerPropsSchema.safeParse({ searchFields: ['name'] }); - const message = r.success ? '' : r.error.issues[0]!.message; - expect(message).toContain('use the component-level `dataSource.filter`'); - expect(message).not.toContain('use `filter`'); - }); - - it('rejects the retired `multiple` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ multiple: true })) - .toThrow(/`multiple`.*removed.*Delete the key/s); - }); + // #5775 tombstones (≤16.x kit) aged out on #25767: `displayField`, + // `searchFields` and `multiple` are strict unknown keys whose refusal names + // the upgrade guide; the rename/delete prescriptions live in the release + // that carried them. + it.each(['displayField', 'searchFields', 'multiple'])( + 'refuses the retired `%s` as a strict unknown key, pointing at the upgrade guide (#25767)', + (key) => { + const r = ElementRecordPickerPropsSchema.safeParse({ [key]: key === 'multiple' ? true : key === 'searchFields' ? ['name'] : 'title' }); + expect(r.success).toBe(false); + const message = r.success ? '' : r.error.issues[0]!.message; + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on [^:]+: \`${key}\``)); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toMatch(/was removed/); + }, + ); it('does not materialize the retired keys on a clean parse', () => { const props = ElementRecordPickerPropsSchema.parse({}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e64cd0cdcdc..e050bc4fdfd 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -527,12 +527,11 @@ const retiredComponentProps = (type: string) => { * JSON Schema and the authorable surface are byte-identical to the unmarked * key; it only records the instance in a module-private registry. * - * {@link retiredComponentSlot} marks a TOMBSTONED spelling of a slot: - * `page:card.body`, retired by #5775 (maintainer ruling 2026-08-06, direction - * A — one composition key, `children`). It is not an authorable spelling, and - * the authoring walks do not descend it; the renderers still read it as a - * back-compat fallback for STORED documents, which is exactly the population - * the conversion walker normalizes, so that walker keeps descending it. + * The registry's `retired` flag marked a TOMBSTONED spelling of a slot + * (`page:card.body`, retired by #5775) for the conversion walker alone. That + * tombstone aged out with the protocol-16.x retirements (ADR-0087 amendment + * 2026-10-26, #25767), so no row sets it today; the flag stays because the walk + * contract exposes it, and the next retired slot spelling sets it again. */ const COMPONENT_SLOT_DECLARATIONS = new WeakMap(); @@ -546,12 +545,6 @@ function componentSlot(schema: T): T { return schema; } -/** {@link componentSlot} for a tombstoned spelling of a slot — see the block above. */ -function retiredComponentSlot(schema: T): T { - COMPONENT_SLOT_DECLARATIONS.set(schema, { retired: true }); - return schema; -} - /** * The composition slot every thin container renders: `page:section`, * `page:footer`, `page:sidebar`. @@ -635,7 +628,7 @@ export const PageHeaderProps = strictObject({ + 'off-spec second spelling of the header\'s second line, which the loader rewrote to `subtitle` until ' + 'this release; `subtitle` is the declared key. Use `subtitle`. Rename the key; the value ' + '(the second line\'s text) is unchanged. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', }, }, { /** @@ -655,34 +648,6 @@ export const PageHeaderProps = strictObject({ 'Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from.', ), subtitle: I18nLabelSchema.optional().describe('Page subtitle'), - /** - * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829, - * route (c) — retire upstream). - * - * A header icon nothing has ever drawn. `PageHeaderRenderer` resolves `icon` - * only per header ACTION (`action.icon`, - * inside the action pipeline) and never off the header's own props bag; - * `@object-ui/layout`'s `` accepts an `icon` REACT prop from a - * host but — unlike `actions`, whose `schema?.actions ?? - * schema?.properties?.actions` fallback sits four lines away in the same - * function — gives it no schema fallback, so an authored node cannot reach - * it. objectui's registration publishes no `icon` input either, which is - * what put this key in that repo's `UNPUBLISHED_EXEMPTIONS` map as a B-class - * "spec declares it, NO renderer read point" entry. - * - * The live mechanism is the record chrome (`recordChrome`, on by default) - * for the header's own identity, and each action's own `icon` for the - * buttons beside it. - */ - icon: retiredKey( - '`page:header` property `icon` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' - + 'no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), ' - + 'never off the header\'s own props bag, and the component registry never published it as an ' - + 'input, so an authored value was accepted and dropped. Delete the key. The header\'s own ' - + 'identity is drawn by the record chrome (`recordChrome`, on by default) and each action ' - + 'carries its own `icon`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * REMOVED (#20758, ADR-0049 enforce-or-remove through the ADR-0087 D2 route, * the way `icon` above left this row; the spec half of objectui#11166). @@ -850,18 +815,6 @@ export const PageTabsProps = strictObject({ */ tabStyle: z.enum(['line', 'card', 'pill']).default('line') .describe("Tab-strip visual style: 'line' underlines the active tab, 'card' frames each tab, 'pill' renders rounded pills"), - /** - * REMOVED (#6776). The declared spelling of `tabStyle`, unauthorable in any - * flat or JSX carrier because a page component's own dispatch key is also - * called `type`. The live mechanism is `tabStyle`. - */ - type: retiredKey( - '`page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' - + 'a props key named `type` collides with the page component\'s own dispatch key, so it is ' - + 'unauthorable in the flat and JSX carriers and was never validated in them. Rename the key ' - + 'to `tabStyle`; the value (`line` | `card` | `pill`) is unchanged. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), position: z.enum(['top', 'left']).default('top'), /** * Keep the tab strip visible when there is only one tab (#4001 batch A). @@ -1020,32 +973,6 @@ export const PageCardProps = strictObject({ }, { title: I18nLabelSchema.optional(), bordered: z.boolean().default(true), - /** - * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829, - * route (c) — retire upstream). - * - * A card action list nothing has ever rendered. `PageCardRenderer` - * (`containers.tsx`) reads exactly four keys — `title`, `bordered`, - * `body ?? children`, `footer` — and returns a `` built from them; - * there is no actions area in the markup and no `actions` input in the - * registration, which is what put this key in objectui's - * `UNPUBLISHED_EXEMPTIONS` map as a B-class "spec declares it, NO renderer - * read point" entry. The card's sibling `page:header` DOES read `actions` - * off its bag, so the divergence was invisible to anyone reading the two - * declarations side by side. - * - * The live mechanism is composition: author the buttons as components in - * `children` or `footer` (`element:button`, `record:quick_actions`). - */ - actions: retiredKey( - '`page:card` property `actions` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' - + 'no renderer ever read it: objectui\'s card renderer builds its `` from `title`, ' - + '`bordered`, `children` and `footer` only, has no actions area, and the component registry ' - + 'never published it as an input, so an authored value was accepted and dropped. Delete the ' - + 'key and author the buttons as components in the card\'s `children` or `footer` ' - + '(`element:button`, `record:quick_actions`), which is what actually renders. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * Card content, in order — the canonical composition slot, matching every * other container (`grid`, `flex`, `page:section`, `page:tabs` items). @@ -1058,22 +985,6 @@ export const PageCardProps = strictObject({ * (Prime Directive #12). `footer` is a genuinely distinct slot and stays. */ children: componentSlot(z.array(z.unknown()).optional().describe('Card content components, in order (the card body slot)')), - /** - * REMOVED (#5775). `body` was the declared spelling of the slot every other - * container calls `children`; the two are the same slot, and the renderer - * already reads both. The live mechanism is `children`. - * - * Marked a RETIRED slot spelling (#20940): the authoring walks do not - * descend it, the ADR-0087 conversion walker does — see - * {@link retiredComponentSlot}. - */ - body: retiredComponentSlot(retiredKey( - '`page:card` property `body` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' - + 'it was a second spelling of the composition slot every other container calls `children`, ' - + 'and the renderer reads both. Rename the key to `children`; the value (an array of child ' - + 'components) is unchanged. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - )), /** * Slot for footer content — a declared, rendered slot distinct from * `children` (objectui's `PageCardRenderer` draws it under the body), so @@ -1319,35 +1230,6 @@ export const RecordDetailsProps = strictObject({ guidanceSets: COMPONENT_LEVEL_GUIDANCE, }, { columns: z.enum(['1', '2', '3', '4']).default('2').describe('Number of columns for field layout (1-4)'), - /** - * REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3818 — - * the removal direction). - * - * The declared `auto` | `custom` semantics were never implemented. objectui's - * `RecordDetailsRenderer` does read `layout`, but only to test it against - * `inline` | `compact` — two values this enum never permitted — so BOTH legal - * values fell to the same `vertical` branch and the key selected nothing. - * That is why it survived `check:react-declaration-parity`: objectui's - * registry declared `layout` with the same `auto` | `custom` enum this schema - * did, and the gate compares two DECLARATIONS, never a declaration against a - * renderer (AGENTS.md). A third spelling, `stacked` | `inline` | `compact`, - * sat in `@object-ui/types`' mirror — three declarations of one key, none of - * them the branch the renderer takes. - * - * The live mechanism is what you author: `sections` renders the explicit - * groups (the old `custom`), and omitting it falls back to the object's - * `highlightFields` (the old `auto`). objectui#3818 deletes the input and the - * dead branch on the next pin bump. - */ - layout: retiredKey( - '`record:details` property `layout` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — ' - + 'its declared `auto` | `custom` semantics were never implemented: the renderer tests `layout` ' - + 'only against `inline` | `compact`, two values the schema never permitted, so both legal ' - + 'values took the same branch and the key selected nothing. Delete the key — the body is ' - + 'already chosen by what you author: `sections` renders the explicit groups (the old ' - + '`custom`), and omitting it falls back to the object\'s `highlightFields` (the old `auto`). ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * Field groups rendered as the detail body, IN ORDER. * @@ -3377,40 +3259,6 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ placeholder: I18nLabelSchema.optional().describe('Placeholder text'), /** Shown in place of the row list when the query returns nothing. */ emptyText: I18nLabelSchema.optional().describe('Text shown when the query returns no records (default "No records")'), - /** - * REMOVED (#5775). A synonym of `labelField` — the same concept in two - * spellings, of which only `labelField` was ever read. - */ - displayField: retiredKey( - '`element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 ' - + '(ADR-0087 D2) — it was a required declaration no renderer ever read, while the ' - + 'renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key ' - + 'to `labelField`; the value (a field name) is unchanged. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** - * REMOVED (#5775). ADR-0049 enforce-or-remove: the control has no search - * box, so this narrowed nothing. - */ - searchFields: retiredKey( - '`element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 ' - + '(ADR-0049) — the picker renders a plain single-select with no search input, so no ' - + 'renderer ever read it and it narrowed nothing. Delete the key. To restrict which records ' - + 'the picker offers, use the component-level `dataSource.filter`, which the query path ' - + 'does apply. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** - * REMOVED (#5775). ADR-0049 enforce-or-remove: the control is a single-select - * `Select`, and a page variable binds one record id. - */ - multiple: retiredKey( - '`element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 ' - + '(ADR-0049) — the picker is a single-select `Select` and the bound page variable ' - + 'holds one record id, so `multiple: true` selected nothing extra and reported success. ' - + 'Delete the key; multi-record selection is not implemented on this element. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** ARIA accessibility */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); @@ -8748,7 +8596,7 @@ export interface PageComponentSlotPosition { * `page:card.footer`. * * A key is a slot exactly when its row wraps the key's schema in - * `componentSlot` / `retiredComponentSlot` (module-private, top of this file); + * `componentSlot` (module-private, top of this file); * a key of a panel list's element object (`items: z.array(z.object({ children * }))`) so marked yields a panel position. The walks match a position by SHAPE * on every component, not by the type whose row declared it: `properties` is an diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 86f3ef32b5a..17cc779890f 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -540,7 +540,7 @@ describe('date-range preset vocabulary — one source, in the spec', () => { describe('DashboardWidgetSchema — retired `responsive`', () => { const widget = { id: 'orders_kpi', type: 'metric', dataset: 'orders', values: ['total'] }; - it('REJECTS an authored `responsive` with the prescription (not "unrecognized key")', () => { + it('REFUSES an authored `responsive` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { let message = ''; try { DashboardWidgetSchema.parse({ @@ -549,24 +549,13 @@ describe('DashboardWidgetSchema — retired `responsive`', () => { }); } catch (e) { message = String((e as Error).message); } - // The prescription itself, in the four parts an upgrading author needs: - // the fully-qualified key, the version, the issue, and the fix. - expect(message).toMatch(/dashboard\.widgets\[\]\.responsive/); - expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); - expect(message).toMatch(/ADR-0049 D2/); - expect(message).toMatch(/Delete the key/); - // The redirect correction (#11027): this message used to prescribe - // `page.components[].responsive` as the live home of the shared shape; - // that key measured equally unread and is retired too, so the message - // must (a) say so rather than silently dropping the old promise — an - // author who followed it needs the correction — and (b) point at the - // channel that IS applied. - expect(message).toMatch(/page\.components\[\]\.responsive[\s\S]*/); - expect(message).toMatch(/responsiveStyles/); - // `.strict()` on this schema would answer a DELETED key with a generic - // unrecognized-key error. The tombstone is what makes it a prescription — - // if this ever regresses to the strict path, this assertion is the tripwire. - expect(message).not.toMatch(/Unrecognized key/); + // The tombstone was a 16.x retirement kit and aged out (ADR-0087 D2 = A): + // the strict widget shape refuses the key, and the refusal's trailing + // sentence names the guide that carries the prescription (the #11027 + // `responsiveStyles` redirect included) from here. Never a silent strip. + expect(message).toMatch(/Unrecognized key\(s\)[^\n]*`responsive`/); + expect(message).toMatch(/Keys retired before the previous protocol major are no longer prescribed here/); + expect(message).toMatch(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('still accepts a widget with no `responsive` (the retirement strips nothing else)', () => { @@ -622,47 +611,26 @@ describe('DashboardWidgetSchema — retired action trio + `aria`', () => { ['actionUrl', 'export_dashboard_pdf'], ['actionType', 'script'], ['actionIcon', 'download'], - ] as const)('REJECTS an authored `%s` with the prescription', (key, value) => { + ] as const)('REFUSES an authored `%s` as a strict unknown key, pointing at the upgrade guide (#25767)', (key, value) => { const message = parseWith({ [key]: value }); - // The prescription, in the parts an upgrading author needs: the - // fully-qualified key, the version, the ADR, and the fix. NOT the tracker - // id — it resolves to nothing for the author reading this refusal. - expect(message).toMatch(new RegExp(`dashboard\\.widgets\\[\\]\\.${key}`)); - expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); - expect(message).toMatch(/ADR-0049 enforce-or-remove/); - expect(message).not.toMatch(/#\d{3,5}\b/); - // The three went together — an author who deletes only the one key they - // were told about would hit this same error twice more. - expect(message).toMatch(/delete all three/i); - // It must name the surviving home, or this reads as "dashboards cannot have - // buttons" rather than "the button belongs on the header". - expect(message).toMatch(/header\.actions\[\]/); - // The tombstone is what makes it a prescription; a plain `.strict()` - // rejection of a DELETED key would be a generic unrecognized-key error. - expect(message).not.toMatch(/Unrecognized key/); - }); - - it('REJECTS an authored `aria` with the prescription, naming its surviving homes', () => { + // The trio's tombstones were 16.x retirement kits and aged out (ADR-0087 + // D2 = A). The strict widget shape refuses each key; the "delete all + // three, the button lives on `header.actions[]`" prescription is the + // guide's now, named by the refusal's trailing sentence. Never a strip. + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\)[^\\n]*\`${key}\``)); + expect(message).toMatch(/Keys retired before the previous protocol major are no longer prescribed here/); + expect(message).toMatch(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); + }); + + it('REFUSES an authored `aria` the same way (#25767)', () => { const message = parseWith({ aria: { ariaLabel: 'Total orders' } }); - expect(message).toMatch(/dashboard\.widgets\[\]\.aria/); - expect(message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); - expect(message).toMatch(/ADR-0049 D2/); - expect(message).toMatch(/Delete the key/); - // The shared shape survives elsewhere. Without this, the message reads as - // "AriaProps is gone", which would send an author deleting live metadata. - // - // ⚠️ This assertion used to require `/app\.aria/` — and `App.aria` is a - // `retiredKey()` tombstone removed in this same 17.0.0, so the pin was - // holding the prescription ON a dead surface (#6756). Re-aimed at the - // surfaces that really do still declare `aria: AriaPropsSchema` and are - // graded `live` in the liveness ledger. The full both-directions - // enumeration pin lives in `aria-carrier-tombstones.test.ts`. - expect(message).toMatch(/page\.aria/); - expect(message).toMatch(/page\.components\[\]\.aria/); - expect(message).not.toMatch(/app\.aria/i); - expect(message).not.toMatch(/Unrecognized key/); + // The surviving `aria` carriers (`page.aria`, `page.components[].aria`) + // are enumerated in `aria-carrier-tombstones.test.ts`; this refusal no + // longer names them itself — the guide does. + expect(message).toMatch(/Unrecognized key\(s\)[^\n]*`aria`/); + expect(message).toMatch(/https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('still accepts a widget carrying none of the four (nothing else was stripped)', () => { diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 17ac776359a..596a21c6f75 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -392,8 +392,8 @@ const COMPARE_TO_OFFSET_RETIRED = + "(`'7d'`, `'1M'`, …) there is no faithful one-key rewrite: state the window you want on the " + "widget's own `filter` and compare it with `previousPeriod`, which shifts by whatever length " + 'that window resolves to. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for the `1y` case; `--write` applies the ones it can prove, ' - + 'and the other durations are reported for you to re-state.'; + + 'The `1y` case left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool ' + + 'on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade; the other durations you re-state yourself.'; // The two string arms. They parsed, and on a dataset widget they then did // NOTHING — DatasetWidget dropped them deliberately, because forwarding one made @@ -407,30 +407,7 @@ const COMPARE_TO_STRING_RETIRED = (kind: 'previousPeriod' | 'previousYear') => + 'analytics executor actually reads it (`DatasetSelection.compareTo`). Add `dimension` only ' + 'when the selection has more than one dated time dimension; with one, the executor resolves ' + 'it. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; - -// ── Per-widget action button prescriptions (#5010) ─────────────────────────── -// -// `//` rather than `/** */` deliberately, per the `COMPARE_TO_*` block above: -// build-docs lifts JSDoc onto the reference page, and an upgrade note is not a -// doc for a shape that still exists. -// -// The three keys read as one affordance ("give this widget its own button"), so -// they share one prescription and name each other — an author who removes only -// `actionUrl` should learn in the same breath that its two companions are gone -// too, rather than hitting three parse errors in three edit rounds. -const WIDGET_ACTION_RETIRED = (key: 'actionUrl' | 'actionType' | 'actionIcon') => - `\`dashboard.widgets[].${key}\` was removed in @objectstack/spec 17.0.0 ` - + '(ADR-0049 enforce-or-remove) — a dashboard widget has NO action button, and never had one. ' - + 'No renderer draws per-widget chrome for it: every action the dashboard dispatches comes from ' - + '`header.actions[]`. The three keys `actionUrl` / `actionType` / `actionIcon` went together; ' - + 'delete all three. ' - + 'Put the affordance on the dashboard header instead — ' - + "`header: { actions: [{ label, actionUrl, actionType, icon }] }` — which IS dispatched " - + '(`DashboardHeaderAction`, same vocabulary, and `icon` is the header spelling of ' - + '`actionIcon`). For a per-ROW affordance, the widget to reach for is a `table`/`pivot` ' - + 'bound to a dataset: its rows are clickable and drill through the semantic layer. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * The one widget `type` that reads `options.stageOrder`. @@ -1177,19 +1154,6 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ */ requiresService: z.string().optional().describe('Hide the widget unless the named kernel service is registered').meta({ title: 'Requires Service' }), - // `actionUrl` / `actionType` / `actionIcon` REMOVED (#5010, ADR-0049 D2): - // the three keys described a per-widget header action BUTTON that no renderer - // has ever drawn. Re-measured 2026-08-04 against objectui@91757a7: all 14 - // `actionUrl` reads in `DashboardRenderer.tsx` are scoped to - // `schema.header.actions[]` (a `DashboardHeaderAction`, a different schema); - // `actionIcon` has zero references in either repo outside this declaration. - // The one consumer of the pair was `packages/lint`'s reference-integrity rule, - // which failed builds when a NEVER-RENDERED button pointed at a missing - // action — its widget branch goes with the keys, in this same change. - actionUrl: retiredKey(WIDGET_ACTION_RETIRED('actionUrl')), - actionType: retiredKey(WIDGET_ACTION_RETIRED('actionType')), - actionIcon: retiredKey(WIDGET_ACTION_RETIRED('actionIcon')), - /** * Presentation-scope filter (MongoDB-style), ANDed into the dataset query as * `runtimeFilter`. [#20116] It is charted through the analytics `where` door @@ -1424,61 +1388,6 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ */ suppressWarnings: z.array(z.string()).optional().describe('Build diagnostic rule ids suppressed on this widget').meta({ title: 'Suppress Warnings' }), - // `responsive` REMOVED (#4876): authorable and inert, exactly like the - // same-named `view.responsive` retired four days earlier (#3896 close-out). - // No objectui code read `widget.responsive` — DashboardRenderer / - // DashboardEditor / plugin-designer mention it only in comments. It escaped - // the #3896 sweep through a liveness-ledger drill gap, not on evidence: - // `dashboard.json` declared no `children` on `widgets`, so no widget-level - // key had ever been classified (#4956). - // - // CORRECTED (#11027): this tombstone used to redirect authors to - // `page.components[].responsive` as the live home of the shared - // `ResponsiveConfig` shape, on the belief that objectui `useResponsiveConfig` - // read it. Measured 2026-08 across both repos, that hook (and the second - // implementation, `ResponsiveProtocol`) had ZERO callers — the prescribed - // destination applied no breakpoint behaviour either. The page key and the - // shape are retired too (`page.zod.ts` tombstone, ADR-0049 D2); the live - // per-breakpoint channel is `responsiveStyles` (ADR-0065). - responsive: retiredKey( - '`dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — ' + - 'no renderer ever read it, so per-widget breakpoint overrides were never applied: the value ' + - 'parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules ' + - '(`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. ' + - 'This message used to point at `page.components[].responsive` as the live home of the shared ' + - '`ResponsiveConfig` shape; that key was measured equally unread and removed with the shape ' + - 'with it. For breakpoint behaviour that IS applied, use `responsiveStyles` on a page ' + - 'component (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - // `aria` REMOVED (#5010, ADR-0049 D2): the same "false compliance" the - // dashboard-level `aria` was retired for in the #3896 close-out (see the - // tombstone on `DashboardSchema` below) — declared ARIA attributes never - // reached the DOM, so a dashboard could claim accessibility work that had - // measurably not happened. Call graph closed by hand across both repos - // 2026-08-04: no consumer of `widget.aria` anywhere. The `aria-*` attributes - // in `DashboardRenderer` / `DatasetWidget` are the renderer's OWN DOM props, - // and objectui's single `.aria` read (`plugin-view/ObjectView.tsx:989`) is a - // `view`'s. The shared `AriaPropsSchema` is untouched — it stays live on - // `page.aria` / `page.components[].aria` and on the list view's `aria`, - // which really are applied (`action.aria` carries the shape too, but the - // ledger grades that one PARTIAL). It is NOT live on `app.aria`: that key - // is a `retiredKey()` tombstone removed in the same 17.0.0 by the 2026-06 - // app liveness audit, and `os migrate meta --from 16` strips it — so the - // "lift it up to the app" repair this note used to imply was a dead end - // that also lost the block (#6756). - aria: retiredKey( - '`dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — ' + - 'no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach ' + - 'the DOM: the key promised accessibility compliance it did not deliver. This is the same ' + - 'removal the dashboard-level `aria` got in 17.0.0. Delete the key. The dashboard ' + - 'renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and ' + - '`description`) on the widget instead — those ARE what the renderer labels the card with. ' + - 'The shared `AriaProps` shape is NOT gone: it stays live on `page.aria`, ' + - '`page.components[].aria` and the list view `aria`. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), // ADR-0021 single-form: every widget binds a `dataset` and selects `values` // (both required above) — there is no inline-query shape to disambiguate. // @@ -1851,21 +1760,6 @@ export const DashboardSchema = lazySchema(() => strictObject({ */ requiredPermissions: z.array(z.string()).optional().describe(AUDIENCE_REQUIRED_PERMISSIONS_DESCRIPTION), - // `aria` / `performance` REMOVED (#3896 audit close-out): authorable and - // inert — no DashboardRenderer path applied either (ledger: dead; the - // report twins were removed in the report-liveness close-out). - aria: retiredKey( - '`dashboard.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no ' + - 'dashboard renderer ever applied it, so declared ARIA attributes silently did not reach ' + - 'the DOM. Delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - performance: retiredKey( - '`dashboard.performance` was removed in @objectstack/spec 17.0.0 (audit ' + - 'close-out) — no renderer or runtime read it; dashboard performance tuning was never ' + - 'implemented. Delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), /** * ADR-0010 §3.7 — Package-level protection envelope. Package * authors declare lock policy here, in a code package's source; diff --git a/packages/spec/src/ui/list-view-export-options.ts b/packages/spec/src/ui/list-view-export-options.ts index e9cfb0e2a64..30c15846fae 100644 --- a/packages/spec/src/ui/list-view-export-options.ts +++ b/packages/spec/src/ui/list-view-export-options.ts @@ -49,7 +49,7 @@ export const LIST_VIEW_EXPORT_PDF_RETIRED = + "not produce documents: to let users print a list as shown, set the view's `allowPrinting`; " + 'a printable document (an invoice, a delivery order, a letter) is a page that declares ' + '`print` — its paper, margins and running header and footer — with its blocks in `regions`. ' - + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; /** * Export formats the platform actually delivers (#8010): `csv`/`json` on both diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 7982f33f422..49aa268b69a 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3014,8 +3014,6 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Grid Features */ resizable: z.boolean().optional().describe('Enable column resizing'), - // `striped` / `bordered` — tombstoned at the bottom of this shape with - // `virtualScroll` (#7176, pass-through-only; see the retired block below). compactToolbar: z.boolean().optional().describe('Collapse Group/Color/Density/Hide-fields into a single View settings popover'), /** Selection */ @@ -3241,46 +3239,6 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the list view'), - // `responsive` / `performance` REMOVED (#3896 audit close-out): authorable - // and inert — no renderer in either repo read them (re-grepped 2026-07-16, - // objectui@fb35e48; ledger: dead). - responsive: retiredKey( - '`view.responsive` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + - 'no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - performance: retiredKey( - '`view.performance` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + - 'no renderer or runtime read it; list-view performance tuning was never implemented. ' + - 'Delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - - // `striped` / `bordered` / `virtualScroll` REMOVED (#7176, ADR-0049 - // enforce-or-remove; maintainer ruling 2026-08-10). Every measured reader was - // a forwarding copy — react spec-bridge → plugin-list → plugin-view/app-shell - // — and the chain ends at ObjectGrid, which never spells any of the three: - // copy-without-apply is dead in effect. Per the ruling, if objectui wants one - // of these as real behavior, that is an implementation card filed first, and - // the key stays retired pending it. - striped: retiredKey( - '`view.striped` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — ' + - 'every measured reader only copied it forward and no renderer ever applied it, so authoring ' + - 'it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - bordered: retiredKey( - '`view.bordered` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — ' + - 'every measured reader only copied it forward and no renderer ever applied it (the grid frame ' + - "is the renderer's own constant, not authorable). Delete the key. " + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - virtualScroll: retiredKey( - '`view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — ' + - 'every measured reader only copied it forward and no grid ever virtualized off it; authoring ' + - 'it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), })); /** @@ -4687,15 +4645,6 @@ export const FormViewSchema = lazySchema(() => strictObject({ maxRows: z.number().optional(), })).optional().describe('Inline master-detail child collections'), - // `defaultSort` REMOVED (#3896 audit close-out): no reader in either repo — - // related lists sort by their OWN list view's sort, never by this. - defaultSort: retiredKey( - '`form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — ' + - 'nothing read it: a related list inside a form sorts by its own list view\'s `sort`. ' + - 'Delete the key and set the sort on the related list view instead. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), - /** Public form sharing configuration */ sharing: SharingConfigSchema.optional().describe('Public sharing configuration for this form'), @@ -4824,17 +4773,6 @@ export const FormViewSchema = lazySchema(() => strictObject({ defaults: z.record(z.string(), z.unknown()).optional() .describe('Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;).'), - // `aria` REMOVED from the FORM view (#3896 audit close-out): no form - // renderer applied it (plugin-form/SchemaForm never read schema.aria) — an - // ACCESSIBILITY claim that is merely accepted is false compliance, the - // requiresConfirmation shape. - aria: retiredKey( - '`form.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no form ' + - 'renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. ' + - 'Delete the key. The form renderer emits its own semantic markup; report gaps as ' + - 'renderer issues rather than per-view attribute overrides. ' + - 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', - ), }).superRefine((view, ctx) => { // `section.pane` is split-only vocabulary. On any other form type it would // be a silent no-op — the worst failure mode for authored (and especially From a17caaa236c0e52f9b83267b144453ea0f1cd538 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:30:59 +0000 Subject: [PATCH 02/22] =?UTF-8?q?feat(spec,cli)!:=20retire=20the=20?= =?UTF-8?q?=E2=89=A416.x=20retirement=20kits;=20the=20strict=20unknown-key?= =?UTF-8?q?=20refusal=20names=20the=20protocol=20upgrade=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling on #25767 (D2 = A): a retirement kit lives through the protocol major after the one it retired in and leaves when the next major is cut. At 18 that removes everything retired at or below 16.x — 50 D2 conversions and their fixtures, 55 tombstones on strict containers, the 159 major-17 migration entries and the chain's step 17 — and keeps the 17.x and 18.x kits for 19 to repeat the move. - A removed key meets the strict unknown-key refusal, which now carries one more sentence after its fix channels and before the history sentence: keys retired before the previous major are no longer prescribed here — see the protocol upgrade guide (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, `PROTOCOL_UPGRADE_GUIDE_URL`). - `MIGRATION_SUPPORT_FLOOR` is 17: `os migrate meta --from 16` and below answer `MigrationFloorError`, naming the floor and the guide; the chain is one hop (18). The 17.7.0 load-path conversions stay registered for the data-at-rest seams but have no replayable step; the three protocol-18 entries that linked one drop the link and name the hand edit (the F4/F5 shape). - `check:authorable-surface` ages a tombstone one major, dating a deleted `[RETIRED]` line by a registry row or by the previous major's release tag; no gate is added. - ADR-0087 amended in place; the template, the upgrade guide, the retirement skill and one changeset follow. Spec, cli, lint and metadata-core tests re-pinned to the refusal or to surviving kits. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- .../skills/spec-property-retirement/SKILL.md | 14 +- .../0072-reference-scope-and-resolvability.md | 2 +- ...e-membership-claim-and-assignment-rules.md | 2 +- docs/qa/platform-checklist/areas/ai.json | 12 +- .../migrate/meta.report-order.test.ts | 41 ++- .../migrate/meta.stored-flags.test.ts | 8 +- ...ate-meta-composed-load-conversions.test.ts | 90 +++--- .../cli/test/migrate-meta-composed.test.ts | 38 ++- .../test/migrate-meta-default-range.test.ts | 9 +- .../migrate-meta-load-conversions.test.ts | 89 +++--- .../test/migrate-meta-protocol-range.test.ts | 49 ++-- packages/cli/test/migrate-meta-write.test.ts | 121 ++++---- packages/cli/test/migrate-meta.e2e.test.ts | 273 +++++++----------- .../package-owned-command-readers.test.ts | 29 +- .../src/authoring-rule-input-tier.test.ts | 13 +- packages/lint/src/authoring-rules.ts | 5 +- .../lint/src/lint-liveness-properties.test.ts | 37 +-- .../lint/src/validate-component-props.test.ts | 33 +-- .../lint/src/validate-expressions.test.ts | 50 +++- packages/lint/src/validate-expressions.ts | 29 +- .../scripts/liveness/check-liveness.test.ts | 18 +- .../liveness/tombstoned-row-status.test.ts | 17 +- .../scripts/pure-schema-construction.test.ts | 4 +- .../spec/src/conversions/conversions.test.ts | 1 - packages/spec/src/conversions/registry.ts | 53 ++-- .../plugin-runtime-tier-truthful-text.test.ts | 8 - ...8.datasource-driver-mongo-alias-retired.ts | 19 +- ...sibling-block-loose-config-keys-refused.ts | 20 +- ...b-retry-policy-without-attempts-refused.ts | 24 +- packages/spec/src/ui/app.test.ts | 4 +- packages/spec/src/ui/component.test.ts | 9 +- .../spec/src/ui/dashboard-compareto.test.ts | 7 +- packages/spec/src/ui/dashboard.test.ts | 41 +-- ...view-union-retirement-prescription.test.ts | 49 ++-- packages/spec/src/ui/view.test.ts | 75 ++--- .../check-spec-docblock-symbol-anchors.mjs | 35 ++- 36 files changed, 709 insertions(+), 619 deletions(-) diff --git a/.claude/skills/spec-property-retirement/SKILL.md b/.claude/skills/spec-property-retirement/SKILL.md index 72fecbdeeb1..5b366ebf16d 100644 --- a/.claude/skills/spec-property-retirement/SKILL.md +++ b/.claude/skills/spec-property-retirement/SKILL.md @@ -191,15 +191,11 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都 注册中了 `ui/FormView:aria`,protocol 11 的 `flow.node.type` 注册中了任意 `.type`(#4658)。对每个常见叶名,保证早已失效。 *条目同时启动老化时钟(#5898):* 门 (c) 读同一张表来决定这个墓碑的 - `authorable-surface/` 行何时可删(一个 major:退役套件活过退役所在 major 的下一个 - major,再下一个 major 切出时离开 —— ADR-0087 2026-10-26 修订,#25767;此前是 ~两个 - major),所以你把它写在哪个 major 下,时钟就从那个 release 数起。比这张表更老的退役 - 由第二个来源定日期:上一个 major 的 release tag(`@objectstack/spec@<当前 major - 1>.0.0`) - 的 authorable surface 上已经带 `[RETIRED]` 的行,退役早于那次切分,套件已活满一个 - major,可删;tag 不可读(离线)时只按表判,只会更严。两个来源都不认的墓碑**不可删** - ——叶匹配曾把 `data/Index:type` 的日期从一个不相干的 `flow.node.type` 定到 major 11, - 而 baseline 文件自己的 git 历史始于 17.0.0-rc.0。要删那类行,先考证它真实的 major、 - 补条目、在 PR 里写明。 + `authorable-surface/` 行何时可删(一个 major —— ADR-0087 2026-10-26 修订,#25767; + 此前 ~两个),时钟从你写它的那个 major 的 release 数起。更老的退役由第二个来源 + 定日期:上一个 major 的 tag 上已带 `[RETIRED]` 的行可删;tag 不可读时只按表判。 + 两个来源都不认的墓碑**不可删**(叶匹配曾把 `data/Index:type` 定到 major 11); + 要删那类行,先考证真实 major、补条目、在 PR 里写明。 ⚠ 永不加一行你定不了日期的:这里的估计会被之后每个门当成事实读。 - [ ] **`surface` 保持散文 —— 它不再被匹配。** 按作者写元数据的方式写 (`flow.nodes[].outputSchema`),那也是 upgrade guide 打印的。多键 conversion diff --git a/docs/adr/0072-reference-scope-and-resolvability.md b/docs/adr/0072-reference-scope-and-resolvability.md index db1654455f5..601adf8670e 100644 --- a/docs/adr/0072-reference-scope-and-resolvability.md +++ b/docs/adr/0072-reference-scope-and-resolvability.md @@ -65,7 +65,7 @@ Two consequences for the picker model: - **Token shape is surface-specific** (flow = bare-or-dotted; everything else = dotted). The picker must know the surface. - There is **one canonical root allowlist** the validator should reuse, instead of a hand-rolled one. -**`outputSchema` today** (`packages/spec/src/automation/flow.zod.ts#outputSchema`): `Record`, **optional**, carries **no `objectName`**, is **not consumed by the engine**, and is read by the designer only for connector/Tool display. So it cannot, today, tell the picker *which object* an `object`-typed output holds. The reliable signal is the **producing node's `config.objectName`** (required on CRUD nodes) found by walking the graph back — exactly the walk the objectui picker already does (`@object-ui/app-shell` · `inspectors/flow-scope.ts` `flowAncestors`). +**`outputSchema` today** (`packages/spec/src/automation/flow.zod.ts`; removed at 17.0.0 and, since #25767, refused there as an unknown key): `Record`, **optional**, carries **no `objectName`**, is **not consumed by the engine**, and is read by the designer only for connector/Tool display. So it cannot, today, tell the picker *which object* an `object`-typed output holds. The reliable signal is the **producing node's `config.objectName`** (required on CRUD nodes) found by walking the graph back — exactly the walk the objectui picker already does (`@object-ui/app-shell` · `inspectors/flow-scope.ts` `flowAncestors`). ### What already shipped (Slice 0/1, objectui #1934) diff --git a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md index 7173a544f19..53587924c73 100644 --- a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md +++ b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md @@ -159,7 +159,7 @@ flow's two values plus `inherit` (`packages/spec/src/data/hook.zod.ts#HookSchema | Member | Declared | Enforced | Guarded | |:--|:--|:--|:--| | approver `queue` (`packages/spec/src/automation/approval.zod.ts#ApproverType`) | still parses; comment: *"Re-admit only together with a real ownership-queue implementation (queue entity + membership + claim)"* | `packages/plugins/plugin-approvals/src/approval-service.ts#resolveApproverSpec` has no queue branch: it warns *"approver type 'queue' is not implemented — the slot resolves to nobody"* and returns the literal `queue:` slot, which no reader addresses | `packages/spec/src/automation/approval.zod.ts#NON_AUTHORABLE_APPROVER_TYPES` (published as `xEnumDeprecated` on `#ApprovalNodeApproverSchema`), `#APPROVER_VALUE_BINDINGS` `queue: { source: 'unsupported' }`, `#APPROVER_ORG_SCOPED` `queue: false`; the `xRef.map` keeps `queue` so stored rows render | -| sharing recipient `queue` | absent from the authorable enum `packages/spec/src/security/sharing.zod.ts#ShareRecipientType`; carried by the runtime union `packages/spec/src/contracts/sharing-service.ts#SharingRuleRecipientType` — *"reserved: no `sys_queue` yet, `expandRecipient` returns `[]`, deliberately not authorable"* | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient` falls through to `return []` ("treat as no-op until queue impl lands"); the recompute modules exclude it by name (`packages/plugins/plugin-sharing/src/bu-tree-recompute.ts#BU_TREE_RECIPIENT_TYPES`; `packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts#RECIPIENT_MEMBERSHIP_SOURCES`) | the lint red-line table says *"If it ever becomes authorable it is a work-distribution list, not a BU node — but this table is the place to re-decide that, in `ShareRecipientType` order"* (`packages/lint/src/validate-org-axis-red-lines.ts#validateOrgAxisRedLines`); migration 17 records it as *"runtime-reserved and deliberately non-authorable"* (`packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts#entry`); the dogfood `sharing-rules` row names it as *"the one runtime `SharingRuleRecipientType` member deliberately absent"* and asks to be re-checked when the enum moves (`packages/qa/dogfood/test/authz-conformance.matrix.ts#AUTHZ_CONFORMANCE`); `packages/plugins/plugin-sharing/src/access-level.ts` still says the recipient *"was removed"* | +| sharing recipient `queue` | absent from the authorable enum `packages/spec/src/security/sharing.zod.ts#ShareRecipientType`; carried by the runtime union `packages/spec/src/contracts/sharing-service.ts#SharingRuleRecipientType` — *"reserved: no `sys_queue` yet, `expandRecipient` returns `[]`, deliberately not authorable"* | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient` falls through to `return []` ("treat as no-op until queue impl lands"); the recompute modules exclude it by name (`packages/plugins/plugin-sharing/src/bu-tree-recompute.ts#BU_TREE_RECIPIENT_TYPES`; `packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts#RECIPIENT_MEMBERSHIP_SOURCES`) | the lint red-line table says *"If it ever becomes authorable it is a work-distribution list, not a BU node — but this table is the place to re-decide that, in `ShareRecipientType` order"* (`packages/lint/src/validate-org-axis-red-lines.ts#validateOrgAxisRedLines`); migration 17 recorded it as *"runtime-reserved and deliberately non-authorable"* (the semantic entry `sharing-rule-recipient-reconcile`, retired with the protocol-17 step at #25767); the dogfood `sharing-rules` row names it as *"the one runtime `SharingRuleRecipientType` member deliberately absent"* and asks to be re-checked when the enum moves (`packages/qa/dogfood/test/authz-conformance.matrix.ts#AUTHZ_CONFORMANCE`); `packages/plugins/plugin-sharing/src/access-level.ts` still says the recipient *"was removed"* | No `sys_queue`, no `queue` metadata type and no assignment-rule concept exists anywhere under `packages/spec/src` (zero hits for `assignment_rule` / `AssignmentRule`). The word `queue` already names something else in this tree: the job-queue diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json index 225af5026a8..7bb1a56a1b5 100644 --- a/docs/qa/platform-checklist/areas/ai.json +++ b/docs/qa/platform-checklist/areas/ai.json @@ -9,7 +9,7 @@ "title": "agent / tool / skill metadata kinds author, persist and list over the meta surface — retired keys reject with prescriptions, the closed agent kind has no runtime write door", "since": "v15", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "api", "personas": ["admin"], @@ -90,9 +90,9 @@ ], "traps": ["stale-dist"], "source": [ - "packages/spec/src/ai/agent.zod.ts#tenantId (requireds, aliases, retiredKey tools/knowledge, visibility/tenantId guidance)", + "packages/spec/src/ai/agent.zod.ts#tenantId (requireds, aliases, retiredKey lifecycle, visibility/tenantId guidance; `tools`/`knowledge` aged out on #25767 — strict unknown keys naming the upgrade guide)", "packages/spec/src/ai/tool.zod.ts#TOOL_RETIRED_KEY_GUIDANCE (.strict() + TOOL_RETIRED_KEY_GUIDANCE; READ-ONLY PROJECTION note)", - "packages/spec/src/ai/skill.zod.ts#triggerPhrases (requireds, retiredKey triggerPhrases, permissions/trigger guidance #5013)", + "packages/spec/src/ai/skill.zod.ts#SkillSchema (requireds, permissions/trigger guidance #5013; `triggerPhrases` aged out on #25767 — a strict unknown key naming the upgrade guide)", "packages/spec/src/kernel/metadata-type-schemas.ts#registered (agent/tool/skill registered with schemas)", "packages/spec/src/kernel/metadata-plugin.zod.ts#allowRuntimeCreate (registry rows: agent allowRuntimeCreate:false ADR-0063 §2; tool/skill true; file patterns)", "packages/runtime/src/route-ledger.ts (GET /meta, GET /meta/types, GET/PUT /meta/:type/:name)", @@ -119,6 +119,12 @@ "date": "2026-10-19", "change": "steps[1] and acceptance[3].verify corrected — checklist-accuracy findings of the 18.0 QA sweep (QA run #24540, ai.agent-tool-skill-metadata-roundtrip; wave anchor #24579 §3). Compiling an app-package agent now warns agent-authoring-withdrawn (packages/lint/src/validate-ai-agent-authoring.ts, warning tier) while the meta door still serves it, and the package-shipped agent PUT answers NOT_OVERRIDABLE where revision 5's history cited only the runtime-name NOT_CREATABLE. Re-derived at base 9a7838edf5. No clause changed", "ref": "#24649" + }, + { + "revision": 7, + "date": "2026-10-26", + "change": "source anchors re-pointed after the ≤16.x retirement kits aged out (#25767): `skill.triggerPhrases` and `agent.tools`/`agent.knowledge` are no longer declared — their probes are strict unknown keys whose refusal names the protocol upgrade guide, not tombstones; the skill anchor now cites SkillSchema and the agent line names the surviving `lifecycle` tombstone. The negative clause holds as written: a probe accepted with a clean parse is still a FAIL.", + "ref": "#25767" } ] }, diff --git a/packages/cli/src/commands/migrate/meta.report-order.test.ts b/packages/cli/src/commands/migrate/meta.report-order.test.ts index 407fda198dc..abbe8447406 100644 --- a/packages/cli/src/commands/migrate/meta.report-order.test.ts +++ b/packages/cli/src/commands/migrate/meta.report-order.test.ts @@ -59,6 +59,7 @@ import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, + type MigrationApplication, type MigrationChainResult, type MigrationTodo, } from '@objectstack/spec/migrations'; @@ -284,9 +285,11 @@ describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () = it('prints every semantic notice of every hop crossed, byte-identical and in chain order', () => { const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); const expected = noticeLines(result); - // Anti-vacuity: more than one hop's catalogue, and at least one notice - // whose prose spans several terminal lines. - expect(new Set(listedOf(result).map((t) => t.toMajor)).size).toBeGreaterThan(1); + // Anti-vacuity: every hop's catalogue (one hop since #25767 — the support + // floor sits one major below the terminus), and at least one notice whose + // prose spans several terminal lines. + expect(new Set(listedOf(result).map((t) => t.toMajor)).size) + .toBe(MIGRATION_MAJORS.filter((m) => m > MIGRATION_SUPPORT_FLOOR).length); expect(expected.length).toBeGreaterThan(listedOf(result).length * 3); const header = indexOf(lines, SEMANTIC_HEADER_RE); const printedNotices = lines.slice(header + 1, header + 1 + expected.length); @@ -487,6 +490,16 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m expect(lines.filter((l) => l.trim() !== '').sort()).toEqual(accounted.sort()); }); + it('[#25767] every declared link names a conversion a step above the floor replays', () => { + // A link to a conversion whose step the floor no longer reaches could never + // be paired: the chain stages no edit by it. Such an entry carries no + // `conversionIds` and names the hand edit (the #23543 F4/F5 shape). + for (const link of declaredLinks()) { + const conversion = ALL_CONVERSIONS.find((c) => c.id === link.conversionId); + expect(conversion?.toMajor, `${link.entry} → ${link.conversionId}`).toBeGreaterThan(MIGRATION_SUPPORT_FLOOR); + } + }); + it('pairs every link the registry declares, replayed over the linked conversion\'s own fixture', () => { const links = declaredLinks(); // Anti-vacuity, and the two links authored when the printer learned to pair. @@ -514,15 +527,31 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m it('pairs across hops: a link to a conversion an earlier step replays', () => { // No authored link crosses a hop yet, though `SemanticMigration.conversionIds` // allows one, so this pin adds it: a protocol-18 entry made to judge a - // protocol-17 conversion. Everything else is the chain's own result. - const earlier = 'action-execute-to-target'; + // protocol-17 conversion. [#25767] The support floor is 17, so the chain + // replays no protocol-17 step any more and cannot stage that edit itself: + // `amend` stages it too, from the conversion's own row. What is measured + // is the PRINTER — it pairs a review with a run by `conversionId`, never + // by hop — and everything else is the chain's own result. + const earlier = 'sharing-rule-access-level-full-to-edit'; const conversion = ALL_CONVERSIONS.find((c) => c.id === earlier)!; const judgeId = DECISION_JUDGE; + const earlierEdit: MigrationApplication = { + toMajor: conversion.toMajor, + conversionId: earlier, + surface: conversion.surface, + from: "accessLevel: 'full'", + to: "accessLevel: 'edit'", + path: 'sharingRules[0].accessLevel', + }; const { result, lines } = run( conversion.fixture.before as Record, MIGRATION_SUPPORT_FLOOR, TERMINUS, - (r) => ({ ...r, todos: r.todos.map((t) => (t.id === judgeId ? { ...t, conversionIds: [earlier] } : t)) }), + (r) => ({ + ...r, + applied: [...r.applied, earlierEdit], + todos: r.todos.map((t) => (t.id === judgeId ? { ...t, conversionIds: [earlier] } : t)), + }), ); const judge = todoOf(result, judgeId); const edit = result.applied.find((a) => a.conversionId === earlier); diff --git a/packages/cli/src/commands/migrate/meta.stored-flags.test.ts b/packages/cli/src/commands/migrate/meta.stored-flags.test.ts index c5f860b8c7c..7a993a7b242 100644 --- a/packages/cli/src/commands/migrate/meta.stored-flags.test.ts +++ b/packages/cli/src/commands/migrate/meta.stored-flags.test.ts @@ -14,13 +14,13 @@ import MigrateMeta, { storedOnlyFlagsIn } from './meta.js'; describe('storedOnlyFlagsIn (#4327)', () => { it('reports the stored-only flags the operator typed', () => { - expect(storedOnlyFlagsIn(['--from', '16', '--apply'])).toEqual(['apply']); + expect(storedOnlyFlagsIn(['--from', '17', '--apply'])).toEqual(['apply']); expect(storedOnlyFlagsIn(['--from=16', '--type=view', '--force'])).toEqual(['force', 'type']); - expect(storedOnlyFlagsIn(['--from', '16', '-y'])).toEqual(['yes']); + expect(storedOnlyFlagsIn(['--from', '17', '-y'])).toEqual(['yes']); }); it('says nothing about a run that typed none of them', () => { - expect(storedOnlyFlagsIn(['--from', '16', '--step', '--json'])).toEqual([]); + expect(storedOnlyFlagsIn(['--from', '17', '--step', '--json'])).toEqual([]); expect(storedOnlyFlagsIn([])).toEqual([]); }); @@ -33,7 +33,7 @@ describe('storedOnlyFlagsIn (#4327)', () => { // from `OS_DATABASE_URL`, so a merely-exported env var would have counted // as "provided" and broken `os migrate meta --from N` for anyone who has // one set. Provenance comes from argv precisely so it cannot. - expect(storedOnlyFlagsIn(['--from', '16'])).toEqual([]); + expect(storedOnlyFlagsIn(['--from', '17'])).toEqual([]); expect(storedOnlyFlagsIn(['--stored', '--database-url', 'sqlite://x.db'])).toEqual(['database-url']); }); }); diff --git a/packages/cli/test/migrate-meta-composed-load-conversions.test.ts b/packages/cli/test/migrate-meta-composed-load-conversions.test.ts index 459761e2707..238e6ea2895 100644 --- a/packages/cli/test/migrate-meta-composed-load-conversions.test.ts +++ b/packages/cli/test/migrate-meta-composed-load-conversions.test.ts @@ -9,10 +9,13 @@ * converted: `datasources[].driver: 'mongo'` until #23543 F1, then a flow-level * retry policy's `retryDelayMs` until F2 retired `retry-policy-converged` from * the load path too. After F2 no load-path conversion is a step the chain - * replays from its floor (`--from 16` replays steps 17 and 18, and every entry - * of either is retired), so the defect's own reading — the chain missing what - * the load converted — has no subject on this tree. What the fix changed is - * read directly instead (see "What is pinned"). + * replays from its floor (`--from 17` replays step 18, and every entry of it + * is retired), so the defect's own reading — the chain missing what the load + * converted — has no subject on this tree. What the fix changed is read + * directly instead (see "What is pinned"). [#25767] The protocol-17 step + * itself is below the support floor now, so the refused spelling the chain + * converts here is a protocol-18 one (a dashboard's `refreshInterval`); the + * flow policy stays canonical throughout. * * ## The defect * @@ -183,14 +186,14 @@ afterAll(() => { }); /** - * The service package, whose one non-canonical spelling is the flow-level retry - * policy's `retryDelayMs`. The current schema REFUSES it at the `retryDelayMs` - * tombstone (`retry-policy-converged` is retired from the load path, #23543 - * F2), and the chain's step 17 renames it `backoffMs`. - * {@link CANONICAL_SERVICE} is the same package as the author would write it - * today, which the schema accepts. + * The service package, which the schema accepts as written: its flow-level + * retry policy is in the canonical spelling. It carried `retryDelayMs` + * (`retry-policy-converged`, the protocol-17 step) until [#25767] raised the + * support floor to 17: that spelling is a strict unknown key now, which the + * chain no longer converts. {@link REFRESH_SERVICE} is the same package with + * one spelling the protocol-18 step converts. */ -const SERVICE_STACK = `import { defineStack } from '@objectstack/spec'; +const CANONICAL_SERVICE = `import { defineStack } from '@objectstack/spec'; export const ServiceStack = defineStack({ manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' }, @@ -208,7 +211,7 @@ export const ServiceStack = defineStack({ name: 'probe_sync', label: 'Sync', type: 'autolaunched', - errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, + errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }, nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], edges: [{ id: 'e1', source: 'start', target: 'end' }], }, @@ -237,16 +240,24 @@ const composed = (service: string): Record => ({ 'src/app.stack.ts': APP_STACK, }); -const RETRY_CONVERSION = 'retry-policy-converged'; -const RETRY_BODY_SITE = { - conversionId: RETRY_CONVERSION, - path: 'packages[0].manifest.flows[0].errorHandling.backoffMs', - from: 'retryDelayMs', - to: 'backoffMs', +/** + * The same package with a dashboard whose refresh cadence is spelled the old + * way: the current schema REFUSES it at the `refreshInterval` tombstone, and + * the protocol-18 step renames it `refreshIntervalSeconds`. + */ +const AUTHORED_CADENCE = 'refreshInterval: 300'; +const CANONICAL_CADENCE = 'refreshIntervalSeconds: 300'; +const REFRESH_SERVICE = edit( + CANONICAL_SERVICE, + ' flows: [\n', + ` dashboards: [{ name: 'probe_ops', label: 'Ops', widgets: [], ${AUTHORED_CADENCE} }],\n flows: [\n`, +); +const REFRESH_BODY_SITE = { + conversionId: 'dashboard-refresh-interval-to-refresh-interval-seconds', + path: 'packages[0].manifest.dashboards[0].refreshIntervalSeconds', + from: 'refreshInterval', + to: 'refreshIntervalSeconds', }; -const AUTHORED_DELAY = 'retryDelayMs: 2000'; -const CANONICAL_DELAY = 'backoffMs: 2000'; -const CANONICAL_SERVICE = edit(SERVICE_STACK, AUTHORED_DELAY, CANONICAL_DELAY); /** The flow's policy exactly as the author wrote it … */ const AUTHORED_POLICY = { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }; /** … and as the strict parse returns it: the declared defaults filled in. */ @@ -281,20 +292,21 @@ describe('an accepted input: the authored-source load assembles the body the aut const authored = await quietLoad(dir, true); expect(bodyErrorHandling(authored.config)).toEqual(AUTHORED_POLICY); - const out = json(await runMeta(dir, ['--from', '16', '--json'])); + const out = json(await runMeta(dir, ['--from', '17', '--json'])); expect(out.applied).toEqual([]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); }); describe('a refused input the shim produces again, carrying two spellings the chain converts', () => { - // The `time` default with the UTC suffix (step 18) and the flow's - // `retryDelayMs` (step 17) are both refused by the current schema, so the - // shim hands the call's argument through and produces it again in - // `strict: false` mode for the composer. It carried a load-path spelling - // until #23543 F2; with none left that the chain replays, both are refusals. + // The `time` default with the UTC suffix and the dashboard's + // `refreshInterval` (both protocol-18 step) are refused by the current + // schema, so the shim hands the call's argument through and produces it + // again in `strict: false` mode for the composer. It carried a load-path + // spelling until #23543 F2; with none left that the chain replays, both are + // refusals. const MIXED = edit( - SERVICE_STACK, + REFRESH_SERVICE, " name: { type: 'text', label: 'Name' },\n", " name: { type: 'text', label: 'Name' },\n starts_at: { type: 'time', label: 'Starts', defaultValue: '10:00Z' },\n", ); @@ -303,25 +315,25 @@ describe('a refused input the shim produces again, carrying two spellings the ch const dir = writeProject(composed(MIXED)); const before = snapshot(dir); - const dry = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(dry.applied)).toEqual([RETRY_BODY_SITE, TIME_BODY_SITE]); + const dry = json(await runMeta(dir, ['--from', '17', '--json'])); + expect(sites(dry.applied)).toEqual([REFRESH_BODY_SITE, TIME_BODY_SITE]); - const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + const run = await runMeta(dir, ['--from', '17', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); - expect(sites(out.applied)).toEqual([RETRY_BODY_SITE, TIME_BODY_SITE]); + expect(sites(out.applied)).toEqual([REFRESH_BODY_SITE, TIME_BODY_SITE]); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'src/service.stack.ts', sites: 2 }]); expect(snapshot(dir)).toEqual({ ...before, 'src/service.stack.ts': edit( edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), - AUTHORED_DELAY, - CANONICAL_DELAY, + AUTHORED_CADENCE, + CANONICAL_CADENCE, ), }); - const again = json(await runMeta(dir, ['--from', '16', '--json'])); + const again = json(await runMeta(dir, ['--from', '17', '--json'])); expect(again.applied).toEqual([]); const strict = await quietLoad(dir); @@ -331,12 +343,12 @@ describe('a refused input the shim produces again, carrying two spellings the ch it('an input defined twice starts from the innermost argument, and is listed once', async () => { const dir = writeProject(composed(edit( - edit(SERVICE_STACK, 'export const ServiceStack = defineStack({', 'export const ServiceStack = defineStack(defineStack({'), + edit(REFRESH_SERVICE, 'export const ServiceStack = defineStack({', 'export const ServiceStack = defineStack(defineStack({'), '\n});\n', '\n}));\n', ))); - const out = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(out.applied)).toEqual([RETRY_BODY_SITE]); + const out = json(await runMeta(dir, ['--from', '17', '--json'])); + expect(sites(out.applied)).toEqual([REFRESH_BODY_SITE]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); }); @@ -348,7 +360,7 @@ describe('control: an already-canonical composed project', () => { const dir = writeProject(composed(CANONICAL)); const before = snapshot(dir); - const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + const run = await runMeta(dir, ['--from', '17', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); expect(out.applied).toEqual([]); diff --git a/packages/cli/test/migrate-meta-composed.test.ts b/packages/cli/test/migrate-meta-composed.test.ts index 7688ee373fb..ec772f07f23 100644 --- a/packages/cli/test/migrate-meta-composed.test.ts +++ b/packages/cli/test/migrate-meta-composed.test.ts @@ -268,21 +268,19 @@ export default defineStack({ ], }); `; - // The same stack with a flow whose retry policy spells its delay the old - // way: a second refused spelling, which the chain's step 17 renames. The raw - // hand-back keeps it visible to the chain. It was a conversion the load - // still applied — a datasource `driver: 'mongo'` until #23543 F1, then this - // `retryDelayMs` until F2 retired `retry-policy-converged` from the load - // path — and no load-path conversion is a step the chain replays from its - // floor any more, so the row reads the two-spelling refused arm. + // The same stack with a dashboard whose refresh cadence is spelled the old + // way: a second refused spelling, which the protocol-18 step renames. The + // raw hand-back keeps it visible to the chain. The second spelling was a + // conversion the load still applied — a datasource `driver: 'mongo'` until + // #23543 F1, then a flow's `retryDelayMs` until F2 retired + // `retry-policy-converged` from the load path — and [#25767] the protocol-17 + // step those rode is below the support floor now, so the row reads a + // protocol-18 spelling. const SINGLE_WITH_SECOND_REFUSED_SPELLING = edit( SINGLE, ' ],\n});\n', ' ],\n' - + " flows: [{ name: 'probe_sync', label: 'Sync', type: 'autolaunched', " - + "errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, " - + "nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], " - + "edges: [{ id: 'e1', source: 'start', target: 'end' }] }],\n" + + " dashboards: [{ name: 'probe_ops', label: 'Ops', widgets: [], refreshInterval: 300 }],\n" + '});\n', ); const TIME_SITE = { @@ -291,28 +289,28 @@ export default defineStack({ from: '"10:00Z"', to: '"10:00"', }; - const RETRY_SITE = { - conversionId: 'retry-policy-converged', - path: 'flows[0].errorHandling.backoffMs', - from: 'retryDelayMs', - to: 'backoffMs', + const REFRESH_SITE = { + conversionId: 'dashboard-refresh-interval-to-refresh-interval-seconds', + path: 'dashboards[0].refreshIntervalSeconds', + from: 'refreshInterval', + to: 'refreshIntervalSeconds', }; it.each([ { name: 'a refused stack', source: SINGLE, applied: [TIME_SITE], writtenSites: 1 }, - { name: 'a refused stack carrying a second refused spelling', source: SINGLE_WITH_SECOND_REFUSED_SPELLING, applied: [RETRY_SITE, TIME_SITE], writtenSites: 2 }, + { name: 'a refused stack carrying a second refused spelling', source: SINGLE_WITH_SECOND_REFUSED_SPELLING, applied: [REFRESH_SITE, TIME_SITE], writtenSites: 2 }, ])('$name: `applied` and `write.files` as measured before the change', async ({ source, applied, writtenSites }) => { const dir = writeProject({ 'objectstack.config.ts': source }); - const dry = json(await runMeta(dir, ['--from', '16', '--json'])); + const dry = json(await runMeta(dir, ['--from', '17', '--json'])); expect(sites(dry.applied)).toEqual(applied); - const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + const run = await runMeta(dir, ['--from', '17', '--write', '--json']); const out = json(run); expect(sites(out.applied)).toEqual(applied); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: writtenSites }]); let expected = edit(source, "defaultValue: '10:00Z'", "defaultValue: '10:00'"); - if (writtenSites === 2) expected = edit(expected, 'retryDelayMs: 2000', 'backoffMs: 2000'); + if (writtenSites === 2) expected = edit(expected, 'refreshInterval: 300', 'refreshIntervalSeconds: 300'); expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe(expected); }, RUN_TIMEOUT); }); diff --git a/packages/cli/test/migrate-meta-default-range.test.ts b/packages/cli/test/migrate-meta-default-range.test.ts index a2ce5936980..34a1c2410a4 100644 --- a/packages/cli/test/migrate-meta-default-range.test.ts +++ b/packages/cli/test/migrate-meta-default-range.test.ts @@ -266,10 +266,11 @@ describe('os migrate meta — an empty range answers as an empty range (#17134)' * todos on every hop it replays, whatever the stack holds, so an empty * `todos` needs a hop whose own `semantic` list is empty. `13 → 14` was that * hop: step 14 carried none. Raising `MIGRATION_SUPPORT_FLOOR` to 16 retired - * steps 11–16, and both hops the chain still reaches carry semantic entries — - * so on this build no `--from` / `--to` reaches the sentence at all. Measured: - * `--from 16 --to 17` on the canonical stack prints 77 manual changes and - * `Migrated stack is schema-valid` at exit 0. + * steps 11–16, and raising it to 17 (#25767) retired step 17; the one hop + * the chain still reaches carries semantic entries — so on this build no + * `--from` / `--to` reaches the sentence at all. Measured before the second + * move: `--from 16 --to 17` on the canonical stack printed 77 manual changes + * and `Migrated stack is schema-valid` at exit 0. * * ⛔ Re-pointing `13` to `16` would NOT have pinned this behaviour — it would * have pinned the OTHER branch of the same `if` under this branch's name, diff --git a/packages/cli/test/migrate-meta-load-conversions.test.ts b/packages/cli/test/migrate-meta-load-conversions.test.ts index b9d2fd9d790..dd5ae51e264 100644 --- a/packages/cli/test/migrate-meta-load-conversions.test.ts +++ b/packages/cli/test/migrate-meta-load-conversions.test.ts @@ -7,10 +7,13 @@ * It was measured on a spelling the load still converted: `driver: 'mongo'` * until #23543 F1, then a flow-level retry policy's `retryDelayMs` until F2 * retired `retry-policy-converged` from the load path too. After F2 no load-path - * conversion is a step the chain replays from its floor (`--from 16` replays - * steps 17 and 18, and every entry of either is retired), so the defect's own - * reading — the chain missing what the load converted — has no subject on this - * tree. What the fix changed is read directly instead (see "What is pinned"). + * conversion is a step the chain replays from its floor (`--from 17` replays + * step 18, and every entry of it is retired), so the defect's own reading — + * the chain missing what the load converted — has no subject on this tree. + * What the fix changed is read directly instead (see "What is pinned"). + * [#25767] The protocol-17 step itself is below the support floor now, so the + * refused spelling the chain converts here is a protocol-18 one (a dashboard's + * `refreshInterval`); the flow policy stays canonical throughout. * * ## The defect * @@ -34,9 +37,9 @@ * 2. A stack defined twice starts from the innermost argument. * 3. The control: an already-canonical source writes nothing, and its * `--json` summary equals the one the built stack gives. - * 4. A stack the schema refuses for two spellings the chain converts (one - * step-17, one step-18): each conversion is applied once, both are - * written, and a stack defined twice is listed once. + * 4. A stack the schema refuses for two spellings the protocol-18 step + * converts: each conversion is applied once, both are written, and a + * stack defined twice is listed once. * * Retired at F2: "lists a conversion the load already applied; --write writes * it, and the load stops converting". Its first half has no subject (above); @@ -177,14 +180,14 @@ afterAll(() => { }); /** - * A stack whose one non-canonical spelling is the flow-level retry policy's - * `retryDelayMs`. The current schema REFUSES it at the `retryDelayMs` - * tombstone (`retry-policy-converged` is retired from the load path, #23543 - * F2), and the chain's step 17 renames it `backoffMs`. {@link CANONICAL_SOURCE} - * is the same stack as the author would write it today, which the schema - * accepts. + * A stack the schema accepts as written, its flow-level retry policy in the + * canonical spelling. It carried `retryDelayMs` (`retry-policy-converged`, the + * protocol-17 step) until [#25767] raised the support floor to 17: that + * spelling is a strict unknown key now, which the chain no longer converts. + * {@link REFRESH_SOURCE} is the same stack with one spelling the protocol-18 + * step converts. */ -const RETRY_SOURCE = `import { defineStack } from '@objectstack/spec'; +const CANONICAL_SOURCE = `import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' }, @@ -202,7 +205,7 @@ export default defineStack({ name: 'probe_sync', label: 'Sync', type: 'autolaunched', - errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, + errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }, nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], edges: [{ id: 'e1', source: 'start', target: 'end' }], }, @@ -210,11 +213,24 @@ export default defineStack({ }); `; -const RETRY_CONVERSION = 'retry-policy-converged'; -const RETRY_SITE = { conversionId: RETRY_CONVERSION, path: 'flows[0].errorHandling.backoffMs', from: 'retryDelayMs', to: 'backoffMs' }; -const AUTHORED_DELAY = 'retryDelayMs: 2000'; -const CANONICAL_DELAY = 'backoffMs: 2000'; -const CANONICAL_SOURCE = edit(RETRY_SOURCE, AUTHORED_DELAY, CANONICAL_DELAY); +/** + * The same stack with a dashboard whose refresh cadence is spelled the old + * way: the current schema REFUSES it at the `refreshInterval` tombstone, and + * the protocol-18 step renames it `refreshIntervalSeconds`. + */ +const AUTHORED_CADENCE = 'refreshInterval: 300'; +const CANONICAL_CADENCE = 'refreshIntervalSeconds: 300'; +const REFRESH_SOURCE = edit( + CANONICAL_SOURCE, + ' flows: [\n', + ` dashboards: [{ name: 'probe_ops', label: 'Ops', widgets: [], ${AUTHORED_CADENCE} }],\n flows: [\n`, +); +const REFRESH_SITE = { + conversionId: 'dashboard-refresh-interval-to-refresh-interval-seconds', + path: 'dashboards[0].refreshIntervalSeconds', + from: 'refreshInterval', + to: 'refreshIntervalSeconds', +}; /** The flow's policy exactly as the author wrote it … */ const AUTHORED_POLICY = { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }; /** … and as the strict parse returns it: the declared defaults filled in. */ @@ -257,7 +273,7 @@ describe('an accepted stack: the authored-source load hands back what the author const authored = await quietLoad(dir, true); expect(authored.config.flows[0].errorHandling).toEqual(AUTHORED_POLICY); - const out = json(await runMeta(dir, ['--from', '16', '--json'])); + const out = json(await runMeta(dir, ['--from', '17', '--json'])); expect(out.applied).toEqual([]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); @@ -270,7 +286,7 @@ describe('control: an already-canonical source', () => { const dir = writeProject({ 'objectstack.config.ts': CANONICAL }); const before = snapshot(dir); - const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + const run = await runMeta(dir, ['--from', '17', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); expect(out.applied).toEqual([]); @@ -290,12 +306,13 @@ describe('control: an already-canonical source', () => { }); describe('a stack with two refused spellings the chain converts', () => { - // The `time` default with the UTC suffix (step 18) and the flow's - // `retryDelayMs` (step 17) are both refused by the current schema, so this - // call hands its argument through. It carried a load-path spelling until - // #23543 F2; with none left that the chain replays, both arms are refusals. + // The `time` default with the UTC suffix and the dashboard's + // `refreshInterval` (both protocol-18 step) are refused by the current + // schema, so this call hands its argument through. It carried a load-path + // spelling until #23543 F2; with none left that the chain replays, both + // arms are refusals. const MIXED = edit( - RETRY_SOURCE, + REFRESH_SOURCE, " name: { type: 'text', label: 'Name' },\n", " name: { type: 'text', label: 'Name' },\n starts_at: { type: 'time', label: 'Starts', defaultValue: '10:00Z' },\n", ); @@ -303,20 +320,20 @@ describe('a stack with two refused spellings the chain converts', () => { it('applies each conversion once, and --write writes both', async () => { const dir = writeProject({ 'objectstack.config.ts': MIXED }); - const dry = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(dry.applied)).toEqual([RETRY_SITE, TIME_SITE]); + const dry = json(await runMeta(dir, ['--from', '17', '--json'])); + expect(sites(dry.applied)).toEqual([REFRESH_SITE, TIME_SITE]); - const run = await runMeta(dir, ['--from', '16', '--write', '--json']); + const run = await runMeta(dir, ['--from', '17', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); - expect(sites(out.applied)).toEqual([RETRY_SITE, TIME_SITE]); + expect(sites(out.applied)).toEqual([REFRESH_SITE, TIME_SITE]); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: 2 }]); expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe( - edit(edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), AUTHORED_DELAY, CANONICAL_DELAY), + edit(edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), AUTHORED_CADENCE, CANONICAL_CADENCE), ); - const again = json(await runMeta(dir, ['--from', '16', '--json'])); + const again = json(await runMeta(dir, ['--from', '17', '--json'])); expect(again.applied).toEqual([]); const strict = await quietLoad(dir); @@ -327,13 +344,13 @@ describe('a stack with two refused spellings the chain converts', () => { it('a stack defined twice starts from the innermost argument, and is listed once', async () => { const dir = writeProject({ 'objectstack.config.ts': edit( - edit(RETRY_SOURCE, 'export default defineStack({', 'export default defineStack(defineStack({'), + edit(REFRESH_SOURCE, 'export default defineStack({', 'export default defineStack(defineStack({'), '\n});\n', '\n}));\n', ), }); - const out = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(out.applied)).toEqual([RETRY_SITE]); + const out = json(await runMeta(dir, ['--from', '17', '--json'])); + expect(sites(out.applied)).toEqual([REFRESH_SITE]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); }); diff --git a/packages/cli/test/migrate-meta-protocol-range.test.ts b/packages/cli/test/migrate-meta-protocol-range.test.ts index 8635680412a..f0fc586b73e 100644 --- a/packages/cli/test/migrate-meta-protocol-range.test.ts +++ b/packages/cli/test/migrate-meta-protocol-range.test.ts @@ -59,6 +59,7 @@ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { stripVTControlCharacters } from 'node:util'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { MIGRATION_SUPPORT_FLOOR } from '@objectstack/spec/migrations'; import { AppPlugin } from '@objectstack/runtime'; import MigrateMeta, { planProtocolRange } from '../src/commands/migrate/meta.js'; import { loadConfig } from '../src/utils/config.js'; @@ -87,19 +88,14 @@ const RUN_TIMEOUT = 120_000; /** * The shapes the load refuses on this runtime, one row per major migrated - * FROM: the major, and one authored member the chain converts out of it — a - * key the current schema tombstones, so the authored-source load hands it to - * the chain as written. The `--from 16` row's member is converted by the - * protocol-17 step, and the `--from 17` row's by the protocol-18 step. + * FROM, from the support floor up: the major, and one authored member the + * chain converts out of it — a key the current schema tombstones, so the + * authored-source load hands it to the chain as written. The `--from 17` + * row's member is converted by the protocol-18 step. [#25767] The `--from 16` + * row (`striped`, the protocol-17 step) left with the ≤16.x kit: 16 is below + * the floor now, and the refusal it meets is pinned in §3 instead. */ const SHAPES = [ - { - from: 16, - member: "views: [{ object: 'rg_item', list: { type: 'grid', columns: ['title'], striped: true } }]", - converted: ['view-list-passthrough-keys-removed'], - before: ', striped: true', - after: '', - }, { from: 17, member: "dashboards: [{ name: 'ops', label: 'Ops', widgets: [], refreshInterval: 300 }]", converted: ['dashboard-refresh-interval-to-refresh-interval-seconds'], before: 'refreshInterval: 300', after: 'refreshIntervalSeconds: 300' }, ]; @@ -400,15 +396,15 @@ describe('a range the load already admits is left alone', () => { describe('a range in a legacy home is rewritten where the handshake read it', () => { it.each([ - ['engines.platform', "engines: { platform: '^16' }", '^16', `engines: { platform: '${TARGET}' }`], + ['engines.platform', "engines: { platform: '^17' }", '^17', `engines: { platform: '${TARGET}' }`], // Its schema refuses a range short of a full version, so the full version is written. - ['engine.objectstack', "engine: { objectstack: '^16.0.0' }", '^16.0.0', `engine: { objectstack: '^${PROTOCOL_MAJOR}.0.0' }`], + ['engine.objectstack', "engine: { objectstack: '^17.0.0' }", '^17.0.0', `engine: { objectstack: '^${PROTOCOL_MAJOR}.0.0' }`], ])('%s', async (source, engines, range, rewritten) => { const text = config(engines); const dir = writeProject({ 'objectstack.config.ts': text }); - expect(await load(dir)).toEqual({ refused: expect.objectContaining(refusal(16, range, source)) }); + expect(await load(dir)).toEqual({ refused: expect.objectContaining(refusal(17, range, source)) }); - const run = await runMeta(dir, 16, ['--write', '--json']); + const run = await runMeta(dir, 17, ['--write', '--json']); expect(run.exitCode, run.stderr).toBeUndefined(); expect(JSON.parse(run.stdout).write.range).toMatchObject({ status: 'written', path: `manifest.${source}`, from: range }); expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': edit(text, engines, rewritten) }); @@ -432,10 +428,12 @@ describe('which range is written', () => { const dir = writeProject({ 'objectstack.config.ts': source }); const run = await runMeta(dir, PROTOCOL_MAJOR, ['--write']); expect(run.exitCode, run.stderr).toBeUndefined(); + // [#25767] The lowest `--from` that moves the range is never under the + // chain's floor: the range declares 16, and the run it names starts at 17. expect(run.stdout).toContain( `manifest.engines.protocol '^16' declares protocol 16, below --from ${PROTOCOL_MAJOR}: this run did not ` - + `replay protocol 16 → ${PROTOCOL_MAJOR}, so the range is left as written and the load still refuses it. ` - + 'Run with --from 16.', + + `replay protocol ${MIGRATION_SUPPORT_FLOOR} → ${PROTOCOL_MAJOR}, so the range is left as written and the load still refuses it. ` + + `Run with --from ${MIGRATION_SUPPORT_FLOOR}.`, ); expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': source }); }, RUN_TIMEOUT); @@ -531,3 +529,20 @@ describe('the range rides the plan, the write and the re-check', () => { }); }); }); + +// ── 3: below the floor (#25767) ──────────────────────────────────────────── + +describe('--from 16: below the support floor, the command refuses before it reads the range', () => { + it('is refused with the floor error naming the floor and the upgrade guide, and writes nothing', async () => { + const source = config(`engines: { protocol: '^16' }`, SHAPES[0]!.member); + const dir = writeProject({ 'objectstack.config.ts': source }); + const run = await runMeta(dir, 16, ['--write', '--json']); + expect(run.exitCode).toBe(1); + const payload = JSON.parse(run.stdout); + expect(payload.error).toBe('unsupported_from_major'); + expect(payload.message).toContain('support floor is 17'); + expect(payload.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + // Nothing written: not the chain's edit, not the range. + expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': source }); + }, RUN_TIMEOUT); +}); diff --git a/packages/cli/test/migrate-meta-write.test.ts b/packages/cli/test/migrate-meta-write.test.ts index 14bbf710e9f..a0aec2bc47e 100644 --- a/packages/cli/test/migrate-meta-write.test.ts +++ b/packages/cli/test/migrate-meta-write.test.ts @@ -130,7 +130,7 @@ async function runMeta(dir: string, flags: string[]): Promise { const error = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); let exitCode: number | undefined; try { - await MigrateMeta.run([join(dir, 'objectstack.config.ts'), '--from', '16', ...flags], { root: CLI_ROOT }); + await MigrateMeta.run([join(dir, 'objectstack.config.ts'), '--from', '17', ...flags], { root: CLI_ROOT }); } catch (e: any) { if (typeof e?.oclif?.exit !== 'number') throw e; exitCode = e.oclif.exit; @@ -168,7 +168,7 @@ beforeAll(() => { writeFileSync(join(root, 'node_modules', 'fake-kit', 'package.json'), JSON.stringify({ name: 'fake-kit', main: 'index.js' })); writeFileSync( join(root, 'node_modules', 'fake-kit', 'index.js'), - "exports.KitView = { object: 'rf_ticket', list: { type: 'grid', columns: ['title'], striped: true } };\n", + "exports.KitView = { object: 'rf_ticket', list: { type: 'grid', columns: ['title'], filterableFields: ['title'] } };\n", ); }); @@ -192,8 +192,6 @@ import { allFlows } from './src/flows/index.js'; export default defineStack({ manifest: { id: 'com.example.write', name: 'Write fixture', version: '1.0.0', type: 'app' }, objects: [TicketObject], - // The document store, spelled the old way. - datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: {} }], dashboards: Object.values(dashboards), views: Object.values(views), agents: [SupportAgent], @@ -212,6 +210,11 @@ export const OpsDashboard = Dashboard.create({ refreshInterval: 300, // seconds }); `, + // [#25767] The three `compareTo` spellings below were the protocol-17 step's + // (`dashboard-widget-compareto-converged`), a ≤16.x kit the floor retired: + // `--write` leaves them as written now, and the schema still refuses them + // with the floor named — the control that an aged-out spelling is not + // silently rewritten by a step that no longer exists. 'src/dashboards/kpi.dashboard.ts': `import { Dashboard } from '@objectstack/spec/ui'; export const KpiDashboard = Dashboard.create({ @@ -233,9 +236,8 @@ export const TicketView = defineView({ list: { type: 'grid', columns: ['title'], - // Zebra rows. - striped: true, - bordered: false, // no borders + // Filter-panel whitelist. + filterableFields: ['title'], }, }); `, @@ -246,9 +248,10 @@ export const SupportAgent = defineAgent({ label: 'Support', role: 'Support assistant', instructions: 'Help customers.', - knowledge: { - sources: ['faq'], - indexes: ['docs'], + lifecycle: { + id: 'support', + initial: 'open', + states: { open: {} }, }, }); `, @@ -262,7 +265,6 @@ export const EscalateFlow = defineFlow({ name: 'escalate', label: 'Escalate', type: 'autolaunched', - active: false, nodes: [ { id: 'start', type: 'start', label: 'Start' }, { id: 'done', type: 'end', label: 'Done' }, @@ -276,7 +278,7 @@ export const TicketObject = ObjectSchema.create({ name: 'wr_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } }, - tenancy: { enabled: true, organizationField: 'organization_id' }, + externalSharingModel: 'private', }); `, }; @@ -286,42 +288,22 @@ function expectedAfterWrite(): Record { const p = PROJECT; return { ...p, - 'objectstack.config.ts': edit(p['objectstack.config.ts']!, "driver: 'mongo'", "driver: 'mongodb'"), 'src/dashboards/ops.dashboard.ts': edit(p['src/dashboards/ops.dashboard.ts']!, 'refreshInterval: 300,', 'refreshIntervalSeconds: 300,'), - 'src/dashboards/kpi.dashboard.ts': edit( - edit(p['src/dashboards/kpi.dashboard.ts']!, "compareTo: 'previousPeriod'", "compareTo: { kind: 'previousPeriod' }"), - "compareTo: { offset: '1y' }", - "compareTo: { kind: 'previousYear' }", - ), - 'src/views/ticket.view.ts': edit( - edit(p['src/views/ticket.view.ts']!, ' striped: true,\n', ''), - ' bordered: false, // no borders\n', - '', - ), + 'src/views/ticket.view.ts': edit(p['src/views/ticket.view.ts']!, " filterableFields: ['title'],\n", ''), 'src/agents/support.agent.ts': edit( p['src/agents/support.agent.ts']!, - " knowledge: {\n sources: ['faq'],\n indexes: ['docs'],\n },\n", + " lifecycle: {\n id: 'support',\n initial: 'open',\n states: { open: {} },\n },\n", '', ), - 'src/flows/escalate.flow.ts': edit(p['src/flows/escalate.flow.ts']!, ' active: false,\n', ''), - 'src/objects/ticket.object.ts': edit( - p['src/objects/ticket.object.ts']!, - "tenancy: { enabled: true, organizationField: 'organization_id' }", - 'tenancy: { enabled: true }', - ), + 'src/objects/ticket.object.ts': edit(p['src/objects/ticket.object.ts']!, " externalSharingModel: 'private',\n", ''), }; } const EXPECTED_SITES = [ - 'agents[0].knowledge (agent-knowledge-removed)', - 'dashboards[0].widgets[0].compareTo (dashboard-widget-compareto-converged)', - 'dashboards[0].widgets[1].compareTo (dashboard-widget-compareto-converged)', + 'agents[0].lifecycle (agent-lifecycle-removed)', 'dashboards[1].refreshIntervalSeconds (dashboard-refresh-interval-to-refresh-interval-seconds)', - 'datasources[0].driver (datasource-driver-mongo-to-mongodb)', - 'flows[0].active (flow-inert-keys-removed)', - 'objects[0].tenancy.organizationField (object-tenancy-organization-field-removed)', - 'views[0].list.bordered (view-list-passthrough-keys-removed)', - 'views[0].list.striped (view-list-passthrough-keys-removed)', + 'objects[0].externalSharingModel (object-external-sharing-model-removed)', + 'views[0].list.filterableFields (view-list-filterable-fields-removed)', ]; describe('os migrate meta --write over per-artifact modules', () => { @@ -352,11 +334,8 @@ describe('os migrate meta --write over per-artifact modules', () => { // Byte-for-byte, every file: the barrels and `index.ts` files included. expect(snapshot(dir)).toEqual(expectedAfterWrite()); expect(firstJson.write.files.map((f: any) => f.file).sort()).toEqual([ - 'objectstack.config.ts', 'src/agents/support.agent.ts', - 'src/dashboards/kpi.dashboard.ts', 'src/dashboards/ops.dashboard.ts', - 'src/flows/escalate.flow.ts', 'src/objects/ticket.object.ts', 'src/views/ticket.view.ts', ]); @@ -364,10 +343,10 @@ describe('os migrate meta --write over per-artifact modules', () => { it('reports each written site at its file and line as read', () => { const at = Object.fromEntries(firstJson.write.written.map((w: any) => [w.path, `${w.file}:${w.line}`])); - expect(at['datasources[0].driver']).toBe('objectstack.config.ts:12'); + expect(at['agents[0].lifecycle']).toBe('src/agents/support.agent.ts:8'); expect(at['dashboards[1].refreshIntervalSeconds']).toBe('src/dashboards/ops.dashboard.ts:7'); - expect(at['views[0].list.striped']).toBe('src/views/ticket.view.ts:9'); - expect(at['views[0].list.bordered']).toBe('src/views/ticket.view.ts:10'); + expect(at['objects[0].externalSharingModel']).toBe('src/objects/ticket.object.ts:7'); + expect(at['views[0].list.filterableFields']).toBe('src/views/ticket.view.ts:9'); }); it('writes the applied set and nothing else: every site it reports is an applied entry', () => { @@ -380,9 +359,17 @@ describe('os migrate meta --write over per-artifact modules', () => { // Relative to the dry run of the same build, not a pinned listing: which // notices the default list carries is the chain's business, not --write's. expect(firstJson.todos.map((t: any) => t.id)).toEqual(dry.todos.map((t: any) => t.id)); - // The `compareTo` arm the conversion declines has no mechanical change, so - // its bytes stay — and the schema still refuses it, as before the write. - expect(readFileSync(join(dir, 'src/dashboards/kpi.dashboard.ts'), 'utf8')).toContain("compareTo: { offset: '7d' }"); + }); + + it('leaves an aged-out spelling as written (#25767): no step rewrites it, and the schema still refuses it', () => { + // The `compareTo` spellings rode the protocol-17 step, which the support + // floor retired: nothing is attributed to them, their bytes stay, and the + // stack is still schema-invalid after the write — the refusal, not a + // silent rewrite, is the upgrade channel for a kit one major past its own. + expect(firstJson.applied.map((a: any) => a.conversionId)).not.toContain('dashboard-widget-compareto-converged'); + const kpi = readFileSync(join(dir, 'src/dashboards/kpi.dashboard.ts'), 'utf8'); + expect(kpi).toContain("compareTo: 'previousPeriod'"); + expect(kpi).toContain("compareTo: { offset: '7d' }"); expect(firstJson.schemaValid).toBe(false); }); @@ -405,7 +392,7 @@ describe('os migrate meta without --write (controls)', () => { const run = await runMeta(dir, ['--json', '--out', out]); expect(run.exitCode, run.stderr).toBeUndefined(); expect(snapshot(dir)).toEqual(before); - expect(JSON.parse(readFileSync(out, 'utf8')).datasources[0].driver).toBe('mongodb'); + expect(JSON.parse(readFileSync(out, 'utf8')).dashboards[1].refreshIntervalSeconds).toBe(300); }, RUN_TIMEOUT); it('prints the human report with --write, naming no capability it lacks', async () => { @@ -413,7 +400,7 @@ describe('os migrate meta without --write (controls)', () => { const run = await runMeta(dir, ['--write']); expect(run.exitCode, run.stderr).toBeUndefined(); const group = run.stdout.slice(run.stdout.indexOf('Wrote ')); - expect(group).toMatch(/^Wrote 9 of 9 mechanical change\(s\) into 7 file\(s\):/); + expect(group).toMatch(/^Wrote 4 of 4 mechanical change\(s\) into 4 file\(s\):/); expect(group).toContain('Re-ran the chain over the written sources: no mechanical change remains.'); // The group's own words; the spec's notices above it are the spec's. expect(group).not.toMatch(/automatic/i); @@ -536,17 +523,20 @@ const REFUSALS: Record = { 'objectstack.config.ts': `import { defineStack } from '@objectstack/spec'; import { KitView } from 'fake-kit'; -const MONGO = 'mongo'; -const sharedList = { type: 'grid', columns: ['title'], striped: true }; -const listBase = { bordered: true }; +const START = '10:00Z'; +const sharedList = { type: 'grid', columns: ['title'], filterableFields: ['title'] }; +const listBase = { filterableFields: ['title'] }; function makeAgent(name: string) { - return { name, label: name, role: 'r', instructions: 'i', knowledge: { sources: ['faq'] } }; + return { name, label: name, role: 'r', instructions: 'i', lifecycle: 'draft' }; } export default defineStack({ manifest: { id: 'com.example.refusals', name: 'Refusals', version: '1.0.0', type: 'app' }, - objects: [{ name: 'rf_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }], - datasources: [{ name: 'docs', label: 'Docs', driver: MONGO, config: {} }], + objects: [{ + name: 'rf_ticket', label: 'Ticket', + externalSharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' }, starts_at: { type: 'time', label: 'Starts', defaultValue: START } }, + }], views: [ { object: 'rf_ticket', list: sharedList }, { object: 'rf_ticket', list: sharedList }, @@ -554,14 +544,13 @@ export default defineStack({ { object: 'rf_ticket', list: { - type: 'grid', virtualScroll: true, + type: 'grid', filterableFields: ['title'], columns: ['title'], }, }, KitView, ], agents: [makeAgent('helper_agent')], - flows: [{ name: 'plain', label: 'Plain', type: 'autolaunched', active: true, nodes: [], edges: [] }], }); `, }; @@ -581,21 +570,21 @@ describe('os migrate meta --write refuses what it cannot prove, and says why', ( payload.write.manual.find((m: any) => m.path === path)?.kind; it('names the refusal kind of every site it leaves', () => { - expect(kindOf('datasources[0].driver')).toBe('computed'); - expect(kindOf('views[0].list.striped')).toBe('shared'); - expect(kindOf('views[1].list.striped')).toBe('shared'); - expect(kindOf('views[2].list.bordered')).toBe('spread'); - expect(kindOf('views[3].list.virtualScroll')).toBe('layout'); - expect(kindOf('views[4].list.striped')).toBe('outside-project'); - expect(kindOf('agents[0].knowledge')).toBe('helper'); + expect(kindOf('objects[0].fields.starts_at.defaultValue')).toBe('computed'); + expect(kindOf('views[0].list.filterableFields')).toBe('shared'); + expect(kindOf('views[1].list.filterableFields')).toBe('shared'); + expect(kindOf('views[2].list.filterableFields')).toBe('spread'); + expect(kindOf('views[3].list.filterableFields')).toBe('layout'); + expect(kindOf('views[4].list.filterableFields')).toBe('outside-project'); + expect(kindOf('agents[0].lifecycle')).toBe('helper'); expect(payload.write.manual).toHaveLength(7); for (const m of payload.write.manual) expect(m.reason.length).toBeGreaterThan(0); }); it('still writes the site it can prove, beside them — and nothing else', () => { - expect(sites(payload.write.written)).toEqual(['flows[0].active (flow-inert-keys-removed)']); + expect(sites(payload.write.written)).toEqual(['objects[0].externalSharingModel (object-external-sharing-model-removed)']); expect(snapshot(dir)).toEqual({ - 'objectstack.config.ts': edit(REFUSALS['objectstack.config.ts']!, ' active: true,', ''), + 'objectstack.config.ts': edit(REFUSALS['objectstack.config.ts']!, " externalSharingModel: 'private',\n", ''), }); expect(payload.write.verification.ok).toBe(true); }); diff --git a/packages/cli/test/migrate-meta.e2e.test.ts b/packages/cli/test/migrate-meta.e2e.test.ts index 9805a8771ec..e1a465cdb09 100644 --- a/packages/cli/test/migrate-meta.e2e.test.ts +++ b/packages/cli/test/migrate-meta.e2e.test.ts @@ -9,9 +9,11 @@ * chain attributable, the schema-validity verdict, the machine JSON shape, * `--out` snapshots, and the support-floor refusal — had no test at all. * This spawns `bin/run-dev.js` against a temp project authored in the - * PRE-protocol-17 dialect, exercising one key from every v17 conversion - * family in a single pass, exactly the cross-major "consumer upgrades" - * scenario the command exists for. + * protocol-17 dialect, exercising one key from each family of the protocol-18 + * step in a single pass, exactly the cross-major "consumer upgrades" + * scenario the command exists for. [#25767] It was authored in the + * protocol-16 dialect until the support floor rose to 17: that dialect's + * keys are strict unknown keys now, and `--from 16` is refused (pinned below). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -32,13 +34,14 @@ const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); /** - * A stack authored against protocol 16: every line marked `// 16:` is a shape - * the v17 chain must rewrite, spanning each conversion family — renames - * (action execute→target, sharing full→edit) and the #3896 close-out removals (rls.priority, the four - * tool keys, flow active/template/outputSchema/fallbackNodeId, view/dashboard - * inert keys, agent.knowledge, skill.triggerPhrases). + * A stack authored against protocol 17: every line marked `// 17:` is a shape + * the protocol-18 step must rewrite, spanning the step's families — renames + * (dashboard refreshInterval → refreshIntervalSeconds, job/hook timeout → + * timeoutMs, api cacheTtl → cacheTtlSeconds) and the ADR-0049 close-out + * removals (object externalSharingModel, action aria, list filterableFields, + * agent lifecycle, permission managedBy and allowRestore). */ -const PRE17_CONFIG = ` +const PRE18_CONFIG = ` export default { // #8687: top-level name/label were never stack keys (silently stripped // before the strict close, refused now) — the identity lives in manifest. @@ -47,106 +50,77 @@ export default { name: 'e2e_ticket', label: 'Ticket', fields: { - title: { type: 'text', label: 'Title', required: true }, // 16: required implied NOT NULL — v17 does NOT write that down (#16693) + title: { type: 'text', label: 'Title', required: true }, // required once implied NOT NULL — the chain does NOT write that down (#16693) notes: { type: 'textarea', label: 'Notes' }, }, + externalSharingModel: 'private', // 17: removed (external access is the portal's) }], actions: [{ name: 'close_ticket', label: 'Close', type: 'script', - execute: 'closeHandler', // 16: renamed to target - shortcut: 'Ctrl+K', // 16: removed (never dispatched) - bulkEnabled: true, // 16: removed (toolbar reads view bulkActions) - }], - flows: [{ - name: 'e2e_flow', - label: 'E2E Flow', - type: 'autolaunched', - active: false, // 16: removed (status is the lifecycle) - template: true, // 16: removed (no reader) - // 16: fallbackNodeId removed (fault edges own this). maxRetries is stated - // because #4247 refuses a zero-attempt 'retry' — the count is the one v17 - // flow change the chain surfaces as a semantic TODO instead of rewriting. - errorHandling: { strategy: 'retry', maxRetries: 2, fallbackNodeId: 'n9' }, - nodes: [ - { id: 'n1', type: 'start', label: 'Start', outputSchema: { ok: { type: 'boolean' } } }, // 16: removed - { id: 'n2', type: 'delete_record', label: 'Purge', config: { objectName: 'e2e_ticket', filter: { done: true } } }, // canonical since protocol 11 — must pass through untouched - ], - edges: [], + target: 'closeHandler', + aria: { ariaLabel: 'Close this ticket' }, // 17: removed (the placing node's aria block carries it) }], views: [{ object: 'e2e_ticket', - list: { type: 'grid', columns: ['title'], responsive: { hiddenOn: ['xs'] }, performance: { lazyLoad: true } }, // 16: removed - form: { type: 'simple', sections: [{ fields: ['title'] }], defaultSort: [{ field: 'title' }], aria: { label: 'Ticket' } }, // 16: removed + list: { type: 'grid', columns: ['title'], filterableFields: ['title'] }, // 17: removed (no Filter-panel whitelist) }], dashboards: [{ name: 'e2e_kpis', label: 'E2E KPIs', - aria: { label: 'KPIs' }, // 16: removed - performance: { prefetch: true },// 16: removed widgets: [], + refreshInterval: 300, // 17: renamed to refreshIntervalSeconds }], agents: [{ name: 'e2e_agent', label: 'Agent', role: 'Helper', instructions: 'help', - knowledge: { sources: ['faq'] }, // 16: removed (never scoped retrieval) + lifecycle: 'draft', // 17: removed (no runtime read it) }], - skills: [{ - name: 'e2e_skill', - label: 'Skill', - tools: ['query_records'], - triggerPhrases: ['do the thing'], // 16: removed (never matched) + jobs: [{ + name: 'e2e_sweep', + schedule: { type: 'cron', expression: '0 1 * * *' }, + handler: 'sweepTickets', + timeout: 300000, // 17: renamed to timeoutMs + }], + hooks: [{ + name: 'e2e_audit', + object: 'e2e_ticket', + events: ['afterInsert'], + handler: 'auditTicket', + timeout: 5000, // 17: renamed to timeoutMs }], - tools: [{ - name: 'e2e_tool', - label: 'Tool', - description: 'A tool', - parameters: { type: 'object' }, - category: 'action', // 16: removed - permissions: ['x.y'], // 16: removed (gated nothing) - active: true, // 16: removed (withdrew nothing) - builtIn: false, // 16: removed + apis: [{ + name: 'list_tickets', + path: '/api/v1/apps/e2e/tickets', + method: 'GET', + type: 'object_operation', + objectParams: { object: 'e2e_ticket', operation: 'find' }, + cacheTtl: 30, // 17: renamed to cacheTtlSeconds }], permissions: [{ name: 'e2e_set', label: 'Set', - objects: { e2e_ticket: { allowRead: true } }, - rowLevelSecurity: [{ - name: 'own_rows', - object: 'e2e_ticket', - operation: 'select', - using: 'owner = current_user.id', - enabled: true, - priority: 10, // 16: removed (no conflict to order) - }], - }], - sharingRules: [{ - name: 'share_hot', - type: 'criteria', - object: 'e2e_ticket', - label: 'Hot tickets', - condition: "record.hot == true", - sharedWith: { type: 'team', value: 'support' }, - accessLevel: 'full', // 16: full→edit (walked at the TOP-LEVEL collection) + objects: { e2e_ticket: { allowRead: true, allowRestore: true } }, // 17: allowRestore removed + managedBy: 'user', // 17: removed }], }; `; /** Conversion ids the run MUST attribute at least one rewrite to. */ const EXPECTED_CONVERSIONS = [ - 'action-execute-to-target', - 'action-inert-keys-removed', - 'flow-inert-keys-removed', - 'view-inert-keys-removed', - 'dashboard-inert-keys-removed', - 'agent-knowledge-removed', - 'skill-trigger-phrases-removed', - 'tool-inert-authoring-keys-removed', - 'permission-rls-priority-removed', - 'sharing-rule-access-level-full-to-edit', + 'object-external-sharing-model-removed', + 'action-aria-removed', + 'view-list-filterable-fields-removed', + 'dashboard-refresh-interval-to-refresh-interval-seconds', + 'agent-lifecycle-removed', + 'job-timeout-to-timeout-ms', + 'hook-timeout-to-timeout-ms', + 'api-endpoint-cache-ttl-to-cache-ttl-seconds', + 'permission-set-managed-by-removed', + 'permission-allow-restore-purge-removed', ]; let dir: string; @@ -163,8 +137,8 @@ async function runMeta(args: string[], cwd: string) { beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'os-migrate-meta-e2e-')); - writeFileSync(join(dir, 'objectstack.config.ts'), PRE17_CONFIG); - const stdout = await runMeta(['--from', '16', '--json', '--out', join(dir, 'migrated.json')], dir); + writeFileSync(join(dir, 'objectstack.config.ts'), PRE18_CONFIG); + const stdout = await runMeta(['--from', '17', '--json', '--out', join(dir, 'migrated.json')], dir); out = { stdout, parsed: JSON.parse(stdout) }; }, 120_000); @@ -172,13 +146,13 @@ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); -describe('os migrate meta --from 16 (e2e over the real CLI)', () => { - it('replays the v17 chain and reports schema-valid output', () => { - expect(out.parsed.from).toBe(16); +describe('os migrate meta --from 17 (e2e over the real CLI)', () => { + it('replays the protocol-18 step and reports schema-valid output', () => { + expect(out.parsed.from).toBe(17); expect(out.parsed.schemaValid).toBe(true); }); - it('attributes at least one rewrite to every expected v17 conversion family', () => { + it('attributes at least one rewrite to every expected protocol-18 conversion family', () => { const ids = new Set(out.parsed.applied.map((a: any) => a.conversionId)); for (const id of EXPECTED_CONVERSIONS) { expect(ids.has(id), `expected a rewrite from ${id}; got ${[...ids].join(', ')}`).toBe(true); @@ -215,23 +189,25 @@ describe('os migrate meta --from 16 (e2e over the real CLI)', () => { expect(parsed.success, JSON.stringify(parsed.success ? '' : parsed.error.issues.slice(0, 3))).toBe(true); // Spot-check one rewrite per family on the snapshot itself. - expect(snap.actions[0].target).toBe('closeHandler'); - expect(snap.actions[0].execute).toBeUndefined(); - expect(snap.actions[0].shortcut).toBeUndefined(); - expect(snap.flows[0].active).toBeUndefined(); - expect(snap.flows[0].errorHandling.fallbackNodeId).toBeUndefined(); - expect(snap.flows[0].nodes[0].outputSchema).toBeUndefined(); - expect(snap.flows[0].nodes[1].config.filter, 'canonical key passes through untouched').toEqual({ done: true }); - expect(snap.views[0].list.responsive).toBeUndefined(); - expect(snap.views[0].form.defaultSort).toBeUndefined(); - expect(snap.dashboards[0].aria).toBeUndefined(); - expect(snap.agents[0].knowledge).toBeUndefined(); - expect(snap.skills[0].triggerPhrases).toBeUndefined(); - expect(snap.tools[0].category).toBeUndefined(); - expect(snap.permissions[0].rowLevelSecurity[0].priority).toBeUndefined(); - expect(snap.sharingRules[0].accessLevel).toBe('edit'); + expect(snap.objects[0].externalSharingModel).toBeUndefined(); + expect(snap.actions[0].target, 'canonical key passes through untouched').toBe('closeHandler'); + expect(snap.actions[0].aria).toBeUndefined(); + expect(snap.views[0].list.filterableFields).toBeUndefined(); + expect(snap.views[0].list.columns, 'canonical key passes through untouched').toEqual(['title']); + expect(snap.dashboards[0].refreshIntervalSeconds).toBe(300); + expect(snap.dashboards[0].refreshInterval).toBeUndefined(); + expect(snap.agents[0].lifecycle).toBeUndefined(); + expect(snap.jobs[0].timeoutMs).toBe(300000); + expect(snap.jobs[0].timeout).toBeUndefined(); + expect(snap.hooks[0].timeoutMs).toBe(5000); + expect(snap.hooks[0].timeout).toBeUndefined(); + expect(snap.apis[0].cacheTtlSeconds).toBe(30); + expect(snap.apis[0].cacheTtl).toBeUndefined(); + expect(snap.permissions[0].managedBy).toBeUndefined(); + expect(snap.permissions[0].objects.e2e_ticket.allowRestore).toBeUndefined(); + expect(snap.permissions[0].objects.e2e_ticket.allowRead).toBe(true); // ⛔ ADR-0113, #16693: the chain does NOT write a column constraint for the - // author. `required: true` crosses 16→17 as the write-time contract and + // author. `required: true` crosses a major as the write-time contract and // nothing else, so `title` comes out with NO `storage` block — exactly like // the optional field beside it. The withdrawn `field-required-notnull- // explicit` conversion used to make this line read `{ notNull: true }`, and @@ -248,7 +224,7 @@ describe('os migrate meta --from 16 (e2e over the real CLI)', () => { try { const snap = readFileSync(join(dir, 'migrated.json'), 'utf-8'); writeFileSync(join(dir2, 'objectstack.config.ts'), `export default ${snap};`); - const stdout = await runMeta(['--from', '16', '--json'], dir2); + const stdout = await runMeta(['--from', '17', '--json'], dir2); const again = JSON.parse(stdout); expect(again.applied).toEqual([]); expect(again.schemaValid).toBe(true); @@ -258,10 +234,13 @@ describe('os migrate meta --from 16 (e2e over the real CLI)', () => { }, 120_000); /** - * ADR-0104 / #3438. The metadata half of a 16→17 upgrade is only half of it: - * two DATA migrations gate enforcement per deployment, and a gate nobody is - * told about is served by nobody. The advice is scoped to the field classes - * the author actually declares, so it is never noise. + * ADR-0104 / #3438. The metadata half of a 16→17 upgrade was only half of + * it: two DATA migrations gate enforcement per deployment, and the command + * named them for the field classes the author declares. [#25767] The support + * floor is 17, so no run of this command crosses into 17 any more: the + * advice is unreachable from the command (its positive cases left with the + * ≤16.x kit, and `pendingDataMigrations` is reported as a residual on the + * card) and only the silence is pinned. */ describe('per-deployment data migrations (#3438)', () => { const withFields = (fields: string) => ` @@ -271,62 +250,32 @@ describe('os migrate meta --from 16 (e2e over the real CLI)', () => { }; `; - async function runJson(fields: string, args: string[] = ['--from', '16', '--json']) { + it('says nothing on a chain that starts at the floor — no run crosses into 17 any more', async () => { const d = mkdtempSync(join(tmpdir(), 'os-migrate-meta-dm-')); try { - writeFileSync(join(d, 'objectstack.config.ts'), withFields(fields)); - return JSON.parse(await runMeta(args, d)); - } finally { - try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } - } - } - - it('names the file migration when the metadata declares a media field', async () => { - const res = await runJson(`cover: { type: 'image', label: 'Cover' }`); - expect(res.dataMigrations.map((m: any) => m.id)).toEqual(['adr-0104-file-references']); - }, 120_000); - - it('names the value-shape scan when the metadata declares a covered field', async () => { - const res = await runJson(`spot: { type: 'location', label: 'Spot' }`); - expect(res.dataMigrations.map((m: any) => m.id)).toEqual(['adr-0104-value-shapes']); - }, 120_000); - - it('names both when both classes are declared', async () => { - const res = await runJson( - `cover: { type: 'image', label: 'Cover' }, spot: { type: 'location', label: 'Spot' }`, - ); - expect(res.dataMigrations.map((m: any) => m.id).sort()).toEqual([ - 'adr-0104-file-references', - 'adr-0104-value-shapes', - ]); - }, 120_000); - - it('stays silent for metadata that declares neither class', async () => { - const res = await runJson(`title: { type: 'text', label: 'Title' }`); - expect(res.dataMigrations).toEqual([]); - }, 120_000); - - it('says nothing on a chain that does not cross into 17', async () => { - const res = await runJson(`cover: { type: 'image', label: 'Cover' }`, ['--from', '17', '--json']); - expect(res.dataMigrations).toEqual([]); - }, 120_000); - - it('ends the human-readable upgrade with the advice, not just the JSON', async () => { - // Where an operator actually reads it. The advice names the command and - // says the run is dry by default, so nothing here reads as "do this and - // something irreversible happens". - const d = mkdtempSync(join(tmpdir(), 'os-migrate-meta-dm-h-')); - try { - writeFileSync(join(d, 'objectstack.config.ts'), withFields(`cover: { type: 'image', label: 'Cover' }`)); - const stdout = await runMeta(['--from', '16'], d); - expect(stdout).toMatch(/os migrate files-to-references/); - expect(stdout).toMatch(/dry-run by default/); + writeFileSync(join(d, 'objectstack.config.ts'), withFields(`cover: { type: 'image', label: 'Cover' }, spot: { type: 'location', label: 'Spot' }`)); + const res = JSON.parse(await runMeta(['--from', '17', '--json'], d)); + expect(res.dataMigrations).toEqual([]); } finally { try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } } }, 120_000); }); + it('refuses --from 16 — the boundary the ≤16.x kit left (#25767) — naming the floor and the upgrade guide', async () => { + let failed = false; + try { + await runMeta(['--from', '16', '--json'], dir); + } catch (e: any) { + failed = true; + const body = JSON.parse(String(e.stdout || '{}')); + expect(body.error).toBe('unsupported_from_major'); + expect(body.message).toContain('support floor is 17'); + expect(body.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + } + expect(failed, 'expected a non-zero exit at the floor boundary').toBe(true); + }, 120_000); + it('refuses a --from below the support floor with the structured error', async () => { let failed = false; try { @@ -393,13 +342,12 @@ export default defineStack({ label: 'Agent', role: 'Helper', instructions: 'help', - knowledge: { sources: ['faq'] }, // RETIRED in protocol 17 — the whole point + lifecycle: 'draft', // RETIRED in protocol 17.7 — the whole point })], skills: [defineSkill({ name: 'rk_skill', label: 'Skill', - tools: ['query_records'], - triggerPhrases: ['do the thing'], // RETIRED in protocol 17 + tools: ['query_records'], // nothing retired: the control that the helper's output survives })], }); `; @@ -424,23 +372,20 @@ export default defineStack({ }); it('loads it, converts it, and reports the migrated stack schema-valid', async () => { - const stdout = await runMeta(['--from', '16', '--json', '--out', join(rkDir, 'migrated.json')], rkDir); + const stdout = await runMeta(['--from', '17', '--json', '--out', join(rkDir, 'migrated.json')], rkDir); const parsed = JSON.parse(stdout); const appliedAt = new Map( parsed.applied.map((a: any) => [a.conversionId, a.path] as [string, string]), ); - // Authored through `defineStack`'s own inline literal… - expect(parsed.from).toBe(16); - // …and through helpers imported from a spec subpath, which is where a + expect(parsed.from).toBe(17); + // Authored through a helper imported from a spec subpath, which is where a // tolerance scoped to `defineStack` alone would still have refused. - expect(appliedAt.get('agent-knowledge-removed')).toBe('agents[0].knowledge'); - expect(appliedAt.get('skill-trigger-phrases-removed')).toBe('skills[0].triggerPhrases'); + expect(appliedAt.get('agent-lifecycle-removed')).toBe('agents[0].lifecycle'); expect(parsed.schemaValid).toBe(true); const snap = JSON.parse(readFileSync(join(rkDir, 'migrated.json'), 'utf-8')); - expect(snap.agents[0].knowledge).toBeUndefined(); - expect(snap.skills[0].triggerPhrases).toBeUndefined(); + expect(snap.agents[0].lifecycle).toBeUndefined(); // The rest of the artifact survives the tolerant load intact — the codemod // removes what retired, not what it could not parse. expect(snap.agents[0].name).toBe('rk_agent'); @@ -458,8 +403,8 @@ export default defineStack({ } catch (e: any) { refused = true; const output = `${String(e.stdout ?? '')}${String(e.stderr ?? '')}`; - expect(output).toMatch(/`agent\.knowledge` was removed/); - expect(output).toMatch(/os migrate meta --from 16/); + expect(output).toMatch(/`agent\.lifecycle` was removed/); + expect(output).toMatch(/os migrate meta --from 17/); } expect(refused, '`os validate` must still reject a retired key').toBe(true); }, 180_000); diff --git a/packages/cli/test/package-owned-command-readers.test.ts b/packages/cli/test/package-owned-command-readers.test.ts index 6ca3f1e3df9..f9b91f85f77 100644 --- a/packages/cli/test/package-owned-command-readers.test.ts +++ b/packages/cli/test/package-owned-command-readers.test.ts @@ -17,6 +17,11 @@ * os migrate meta dataMigrations [] adr-0104-file-references * --from 16 --json * + * [#25767] The data-migration listing that row measured is unreachable from the + * command since the support floor rose to 17 (ADR-0104 keys on a run crossing + * into 17), so the `migrate meta` pin reads `applied` instead: a retired key on + * the service package's object, listed at its path inside that package's body. + * * Each now folds its stack with `authoringRuleUnionStack` where it loads it. The * pairs make these pins: a fold that broke every stack would fail the control, * and the control holds the one-package answer unchanged. The enumeration of @@ -33,8 +38,6 @@ import { execFile } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, join } from 'node:path'; -import { FILE_REFERENCES_MIGRATION_ID } from '@objectstack/spec/system'; - import { CLI, TSX, childEnv } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; @@ -127,11 +130,18 @@ const one = (svcObjects: string) => `${PIECES} export default defineStack({ manifest: appManifest, objects: [${svcObjects}, ticket] } as any); `; +/** The service object `os migrate meta` reads: a protocol-17 spelling the chain converts at 18. */ +const scanWithRetiredKey = "{ ...scan, externalSharingModel: 'private' }"; + const CONFIGS: Record = { two: two('note'), twoAfter: two('note, scan'), one: one('note'), oneAfter: one('note, scan'), + // `diff` loads its after-side config, which the retired key would refuse, so + // `migrate meta` reads a pair of its own. + twoMeta: two(`note, ${scanWithRetiredKey}`), + oneMeta: one(`note, ${scanWithRetiredKey}`), }; const dirs: Record = {}; @@ -155,7 +165,7 @@ beforeAll(async () => { jobs.push([`diff:${shape}`, shape, [ 'diff', join(dirs[shape], 'objectstack.config.ts'), join(dirs[`${shape}After`], 'objectstack.config.ts'), '--json', ]]); - jobs.push([`meta:${shape}`, `${shape}After`, ['migrate', 'meta', '--from', '16', '--json']]); + jobs.push([`meta:${shape}`, `${shape}Meta`, ['migrate', 'meta', '--from', '17', '--json']]); } const queue = [...jobs]; const worker = async () => { @@ -202,13 +212,18 @@ describe('#22288 — os diff compares every package', () => { } }); -describe('#22288 — os migrate meta lists the data migration a package\'s fields need', () => { +describe('#22288 — os migrate meta lists the mechanical edit a package\'s object needs', () => { for (const shape of ['two', 'one'] as const) { - it(`${shape === 'two' ? 'two packages' : 'control, one package'}: a file field in the service package lists it`, () => { + it(`${shape === 'two' ? 'two packages' : 'control, one package'}: a retired key on the service package's object is listed at its path`, () => { const run = r[`meta:${shape}`]; expect(run.code, run.stderr).toBe(0); - const ids = (payloadOf(run).dataMigrations as Array<{ id: string }>).map((m) => m.id); - expect(ids).toContain(FILE_REFERENCES_MIGRATION_ID); + const payload = payloadOf(run); + expect(payload.applied).toContainEqual(expect.objectContaining({ + conversionId: 'object-external-sharing-model-removed', + path: shape === 'two' ? 'packages[0].manifest.objects[1].externalSharingModel' : 'objects[1].externalSharingModel', + })); + // [#25767] No run crosses into 17 any more, so ADR-0104 lists nothing. + expect(payload.dataMigrations).toEqual([]); }); } }); diff --git a/packages/lint/src/authoring-rule-input-tier.test.ts b/packages/lint/src/authoring-rule-input-tier.test.ts index 6820bda5c15..bbc3c25738b 100644 --- a/packages/lint/src/authoring-rule-input-tier.test.ts +++ b/packages/lint/src/authoring-rule-input-tier.test.ts @@ -472,12 +472,13 @@ describe('security-owd-alias reaches the rule only through the unparsed doors (# expect(aliasFindings(result.errors)).toEqual(['objects.tier_owd.sharingModel']); }); - it('CONTROL — a pre-D4 stored sibling does not surface: read/read_write fold on rehydration, and the gate diff cancels the rest', () => { - // The stored-row chain replays retired conversions, so `read` / `read_write` - // come back canonical… - expect((applyConversionsToStoredItem('object', owdObject('read')) as AnyRec).sharingModel).toBe('public_read'); - expect((applyConversionsToStoredItem('object', owdObject('read_write')) as AnyRec).sharingModel).toBe('public_read_write'); - // …`full` / `public` have no conversion and come back as authored… + it('CONTROL — a pre-D4 stored sibling does not surface: no alias folds on rehydration any more, and the gate diff cancels the rest', () => { + // [#25767] `owd-legacy-read-aliases` was a ≤16.x retirement kit and left + // the registry, so the stored-row chain no longer folds `read` / + // `read_write`: a pre-D4 row comes back as authored, like `full`… + expect((applyConversionsToStoredItem('object', owdObject('read')) as AnyRec).sharingModel).toBe('read'); + expect((applyConversionsToStoredItem('object', owdObject('read_write')) as AnyRec).sharingModel).toBe('read_write'); + // …as `full` / `public` always did, having no conversion… const storedFull = applyConversionsToStoredItem('object', owdObject('full')) as AnyRec; expect(storedFull.sharingModel).toBe('full'); // …and even so, a sibling in the gate's universe produces the finding in diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index e42ab3f57cc..e34cc9b6e9f 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -684,8 +684,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // harness. // // [#22032, pass 2] The field-rule-slot pass joins the object door: every - // field's `requiredWhen` / `readonlyWhen` / `conditionalRequired` / - // `visibleWhen`, with the `parent` gate, the `requiredWhen` null guard and + // field's `requiredWhen` / `readonlyWhen` / `visibleWhen` (and, until its + // tombstone aged out at #25767, `conditionalRequired`), with the `parent` + // gate, the `requiredWhen` null guard and // the reference-traversal refusal — the same sentence of `formulas.mdx`, // and the gap the card measured (a bare `requiredWhen: 'amount > 1'` saved // with a 200). The per-option `visibleWhen` stays fenced. No entry-level diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 44842b2b20e..f0e89c1926e 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -633,16 +633,17 @@ describe('lintLivenessProperties', () => { // // #25767: the four #5010 keys were ≤16.x retirements, so their tombstones // aged out and their rows left the ledger — the rule grades nothing for - // them now, and the strict widget parse refuses them as unknown keys. The - // 17.x `chartConfig` tombstones (#6888) still carry `dead` rows, so the - // per-key pin moves to them; the four are pinned ABSENT below. - it.each(['type', 'xAxis', 'yAxis', 'series'])( - 'grades the retired `chartConfig.%s` dead when handed an unparsed stack (#6888, #16094)', - (key) => { - const findings = lintLivenessProperties(dash({ chartConfig: { [key]: 'x' } })); - expect(ruleOf(findings, `widgets.chartConfig.${key}`)).toBe(LIVENESS_DEAD_PROPERTY); - }, - ); + // them now, and the strict widget parse refuses them as unknown keys. No + // widget-level `dead` row remains one `children` level down (the depth the + // ledger read walks; the `chartConfig.*` rows sit two down and were never + // graded), so the per-key pin moves to the dashboard's own `refreshInterval` + // row — a 17.x tombstone still `dead` in the ledger; the four are pinned + // ABSENT below. + it('grades the retired dashboard `refreshInterval` dead when handed an unparsed stack (#16094)', () => { + const [dashboard] = dash({}).dashboards; + const findings = lintLivenessProperties({ dashboards: [{ ...dashboard, refreshInterval: 60 }] }); + expect(ruleOf(findings, 'refreshInterval')).toBe(LIVENESS_DEAD_PROPERTY); + }); it.each(['actionUrl', 'actionType', 'actionIcon', 'aria'])( 'no longer grades `%s` — a ≤16.x retirement whose row left with its tombstone (#25767)', @@ -666,20 +667,20 @@ describe('lintLivenessProperties', () => { // warning and not six. // Since #16094 the retired keys warn, so this one call also proves the // dashboard walk is registered and running: the `colorVariant` silence sits - // beside four findings from the same walk, same widget, same ledger load. + // beside the `refreshInterval` finding from the same walk, same dashboard, + // same ledger load (#25767: the widget-level retired keys aged out). it('the dashboard silence is a real verdict, not a lint that stopped loading ledgers', () => { + const [dashboard] = dash({ + chartConfig: { type: 'bar', xAxis: 'region', yAxis: 'total', series: ['total'] }, + colorVariant: 'teal', + }).dashboards; const findings = lintLivenessProperties({ objects: [{ name: 'widget', externalSharingModel: 'read' }], - ...dash({ - chartConfig: { type: 'bar', xAxis: 'region', yAxis: 'total', series: ['total'] }, - colorVariant: 'teal', - }), + dashboards: [{ ...dashboard, refreshInterval: 60 }], }); const messages = findings.map((f) => f.message); expect(messages.some((m) => m.includes('externalSharingModel'))).toBe(true); - for (const retired of ['type', 'xAxis', 'yAxis', 'series']) { - expect(ruleOf(findings, `widgets.chartConfig.${retired}`), retired).toBe(LIVENESS_DEAD_PROPERTY); - } + expect(ruleOf(findings, 'refreshInterval')).toBe(LIVENESS_DEAD_PROPERTY); expect(messages.some((m) => m.includes('widgets.colorVariant'))).toBe(false); }); diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 84513445170..73605adbd9f 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -572,7 +572,10 @@ describe('validateComponentProps — value verdicts', () => { * the `safeParse` half of this rule (a `never` type is a value verdict, not * an unknown key), carrying the rename verbatim. */ - it('surfaces the retired `displayField` prescription (#5775)', () => { + it('reports the aged-out `displayField` as an unknown key (#5775, #25767)', () => { + // [#25767] The #5775 tombstone was a ≤16.x retirement kit and left the + // picker's props, so the key is an unknown key now: the lint reports it + // under its unknown-key rule, and the rename lives in the upgrade guide. const findings = validateComponentProps( stackWith([ { @@ -582,10 +585,10 @@ describe('validateComponentProps — value verdicts', () => { }, ]), ); - const retired = invalid(findings).filter((f) => f.path.endsWith('.displayField')); - expect(retired).toHaveLength(1); - expect(retired[0].severity).toBe('error'); - expect(retired[0].message).toMatch(/removed in @objectstack\/spec 17\.0\.0[\s\S]*`labelField`/); + expect(invalid(findings).filter((f) => f.path.endsWith('.displayField'))).toEqual([]); + const unknown = unknownKeys(findings).filter((f) => f.path.endsWith('.displayField')); + expect(unknown).toHaveLength(1); + expect(unknown[0].severity).toBe('error'); }); /** @@ -706,24 +709,20 @@ describe('validateComponentProps — value verdicts', () => { }); /** - * The other direction of the same card. `page:tabs.type` is now a tombstone, - * and a tombstone is REFUSED rather than reported as unknown — so the finding - * arrives as `component-props-invalid` carrying the rename prescription, not - * as `component-props-unknown-key` carrying nothing. The conversion that - * rewrites it is `retiredFromLoadPath`, so it deliberately does NOT run on - * this normalized input: an already-stored page keeps the old key and the - * author is told, which is the whole point of the tombstone. + * The other direction of the same card. `page:tabs.type` was a tombstone + * until #25767, when the ≤16.x kits aged out: the schema no longer declares + * it, so the finding arrives as `component-props-unknown-key`, not as + * `component-props-invalid` carrying the rename — that prescription now + * lives in the upgrade guide the strict parse names. */ - it('refuses the retired `page:tabs.type` by name, with the prescription (#6776)', () => { + it('reports the aged-out `page:tabs.type` as an unknown key (#6776, #25767)', () => { const findings = validateComponentProps( stackWith([{ type: 'page:tabs', properties: { type: 'card', items: [] } }]), ); - expect(invalid(findings).map((f) => f.path)).toEqual([ + expect(invalid(findings)).toEqual([]); + expect(unknownKeys(findings).map((f) => f.path)).toEqual([ 'pages[0].regions[0].components[0].properties.type', ]); - expect(invalid(findings)[0].message).toContain('tabStyle'); - // Not an unknown key — the schema still declares it, as a tombstone. - expect(unknownKeys(findings)).toEqual([]); }); /** diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index f799e332758..9d4dfc18613 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -151,6 +151,17 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { // #4343 — the retired dispatch keys. Naming them beats the generic "no // callable": they are what the author actually wrote, and each branch has a // different replacement. + // + // [#25767] Their tombstones were ≤16.x retirement kits and aged out of the + // script config contract, so the contract now refuses each key as unknown — + // one issue per key, naming the protocol upgrade guide — and the dispatch + // prescription below rides beside those refusals, not instead of them. + const REFUSED_RE = /Unrecognized key\(s\) on this script node config/; + const split = (issues: ReturnType) => ({ + refused: issues.filter((i) => REFUSED_RE.test(i.message)), + rest: issues.filter((i) => !REFUSED_RE.test(i.message)), + }); + it('flags a retired dispatch key and prescribes the replacement mechanism', () => { const issues = validateStackExpressions({ flows: [{ @@ -162,12 +173,15 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { edges: [], }], }); - expect(issues).toHaveLength(1); - expect(issues[0].message).toMatch(/made `script` a call to a registered function and nothing else/); - expect(issues[0].message).toMatch(/config\.actionType/); - expect(issues[0].message).toMatch(/config\.template/); - expect(issues[0].message).toMatch(/`notify` node/); - expect(issues[0].message).toMatch(/os migrate meta --from 16/); + const { refused, rest } = split(issues); + expect(refused).toHaveLength(3); + for (const r of refused) expect(r.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(rest).toHaveLength(1); + expect(rest[0].message).toMatch(/made `script` a call to a registered function and nothing else/); + expect(rest[0].message).toMatch(/config\.actionType/); + expect(rest[0].message).toMatch(/config\.template/); + expect(rest[0].message).toMatch(/`notify` node/); + expect(rest[0].message).not.toMatch(/os migrate meta --from 16/); // #7030 — house sentence (#6856 route D): names the TOOL's behaviour, never // the retired key's fate. This branch can DELETE the key outright // (`template`/`recipients`/`variables`/`script`), so "rewrite it" read two @@ -175,11 +189,11 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { // automatic-rewrite claim outright, and #9591 names `--write`, which writes // only the edits it can prove — never the unqualified claim. Pinned here AND class-wide in // `retired-key-migrate-sentence.test.ts` (widened to `packages/lint/src`). - expect(issues[0].message).toMatch( - /Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\.$/, + expect(rest[0].message).toMatch( + /This edit left `os migrate meta` with the protocol-16\.x step \(support floor 17\): apply it by hand, or run the tool on a release that still carries the step — https:\/\/objectstack\.ai\/docs\/protocol-upgrade\.$/, ); - expect(issues[0].message).not.toMatch(/rewrite it automatically/); - expect(issues[0].message).not.toMatch(/rewrite existing sources automatically/); + expect(rest[0].message).not.toMatch(/rewrite it automatically/); + expect(rest[0].message).not.toMatch(/rewrite existing sources automatically/); }); it('tells a shorthand actionType exactly where its name belongs', () => { @@ -193,8 +207,10 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { edges: [], }], }); - expect(issues).toHaveLength(1); - expect(issues[0].message).toMatch(/function: 'helpdesk\.aiTriageStub'/); + const { refused, rest } = split(issues); + expect(refused).toHaveLength(1); + expect(rest).toHaveLength(1); + expect(rest[0].message).toMatch(/function: 'helpdesk\.aiTriageStub'/); }); it('flags an inline script body — the runtime never executed it', () => { @@ -208,8 +224,10 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { edges: [], }], }); - expect(issues).toHaveLength(1); - expect(issues[0].message).toMatch(/config\.script/); + const { refused, rest } = split(issues); + expect(refused).toHaveLength(1); + expect(rest).toHaveLength(1); + expect(rest[0].message).toMatch(/config\.script/); }); // #1928 — bare field references are silently null in `record`-scoped sites @@ -3476,7 +3494,9 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t const fieldKeys = /for \(const key of \[([^\]]*)\] as const\)/.exec(RULE_CODE); expect(fieldKeys, 'the field-predicate loop moved — update this guard').not.toBeNull(); const slots = [...fieldKeys![1].matchAll(/'([^']+)'/g)].map((m) => m[1]); - expect(slots).toEqual(['requiredWhen', 'readonlyWhen', 'conditionalRequired', 'visibleWhen', 'settledWhen']); + // [#25767] `conditionalRequired` left the list with its tombstone: the + // schema no longer declares it, so the walk no longer reads it. + expect(slots).toEqual(['requiredWhen', 'readonlyWhen', 'visibleWhen', 'settledWhen']); expect(slots.filter((k) => !Object.keys(FieldSchema.shape).includes(k))).toEqual([]); }); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index d4f7619a1fa..1ca42b7c112 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -805,10 +805,10 @@ function rulePredicates(rule: AnyRec, path: string, depth = 0): RulePredicate[] * VISIBLE" was not merely imprecise here, it named the wrong property of * the field. * - * `conditionalRequired` also reaches this helper (the field walk still passes - * it). It is a `retiredKey` in `FieldSchema` — the strict schema rejects it by - * name — so the branch is inert on the parsed compile path, and it gets a - * slot-agnostic clause rather than a fabricated fourth measurement. + * `conditionalRequired` reached this helper until #25767: its `FieldSchema` + * tombstone was a ≤16.x retirement kit and aged out, so the schema no longer + * declares the key and the field walk no longer passes it (the strict parse + * refuses it as an unknown key, naming the upgrade guide). * * ### Axis 2 — the prescription, by ROOT (#6713) * @@ -973,12 +973,6 @@ const FIELD_RULE_SLOT_CONSEQUENCE: Record = { 'whose requirement it cannot evaluate (ADR-0137 D2: the refusal names the field and the ' + 'rule) — while the form does not mark the field required, so nothing on screen says why the ' + 'save failed', - // Listed rather than left to the `??` below, so the map covers every slot - // the field walk passes and the default stays unreachable. `FieldSchema` - // declares this key only as a `retiredKey`, which rejects it by name, so - // there is no fourth runtime to measure — the honest clause is the generic - // one, not a fabricated fourth cell (#6716). - conditionalRequired: FIELD_RULE_SLOT_CONSEQUENCE_GENERIC, // [#22227] The deadline's settle predicate (objectui#11815 ruling D) joins // the family on the `visibleWhen` side: display only, nothing on the write // path reads it. Its one consumer — objectui's date cells, the host @@ -1965,8 +1959,8 @@ export interface StackExpressionOptions { * `condition` called `sqrt` or read a bare `amount` saved with a 200 and * then faulted on every write the rule judged; * - [#22032, pass 2] the field-rule-slot pass over `fields[]` — each of - * `requiredWhen` / `readonlyWhen` / `conditionalRequired` / `visibleWhen` - * (and, since #22227, a deadline's `settledWhen`) + * `requiredWhen` / `readonlyWhen` / `visibleWhen` (and, since #22227, a + * deadline's `settledWhen`; `conditionalRequired` left with its tombstone, #25767) * as a `record`-scoped predicate with its root verdict, plus the `parent` * gate (a `readonlyWhen` / `requiredWhen` reading `parent` on an object * without exactly one `master_detail`), the #4811 null-guard gate over @@ -3132,7 +3126,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // root verdict over FIELD_RULE_BOUND_ROOTS. Like `visibleWhen` it is // display only, so the server-side gates further down (the `parent` // gate, the null guard, the traversal refusal) do not apply to it. - for (const key of ['requiredWhen', 'readonlyWhen', 'conditionalRequired', 'visibleWhen', 'settledWhen'] as const) { + for (const key of ['requiredWhen', 'readonlyWhen', 'visibleWhen', 'settledWhen'] as const) { const where = `object '${objectName}' · field '${fname}' ${key}`; const raw = (f as AnyRec)[key]; // [#13935] Verdict FIRST, emitted second. `check` needs to know which @@ -3257,9 +3251,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // unusable, and this gate is where the author meets it instead of the // first write in production. // - // `conditionalRequired` (retired alias) and `visibleWhen` (no - // server-enforced `parent` binding of its own) keep their verdicts - // untouched. + // `visibleWhen` (no server-enforced `parent` binding of its own) keeps + // its verdict untouched. // Destructured in the loop head on purpose: both predicate slots stay // LITERAL member reads (`f.readonlyWhen` / `f.requiredWhen`) rather than a // computed `f[key]`, so the #5017 meta-test's source scan still sees every @@ -3303,8 +3296,8 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // (both server-side seams first — the flow trigger-record half is // outstanding). When it is wired it needs `'fail-closed'`, like // `requiredWhen` above: a faulting `readonlyWhen` refuses the write since - // ADR-0137 D2 too. `conditionalRequired` / `visibleWhen` have no record-scoped - // total binding of their own. See the surface ledger in + // ADR-0137 D2 too. `visibleWhen` has no record-scoped total binding of + // its own. See the surface ledger in // `validate-null-guards.ts`. checkNullGuards( `object '${objectName}' · field '${fname}' requiredWhen`, diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index f8a5f6962db..3ff8ff66b20 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -474,22 +474,24 @@ describe('check:liveness — the evidence-scan population (#13041)', () => { // scanning `dead.evidence` would hold 6 rows to a standard, read nothing of // the other 74, and publish that as coverage of the class. // - // The carrier is `flow.active`, a `retiredKey()` tombstone, because the gate + // The carrier is `action.aria`, a `retiredKey()` tombstone, because the gate // itself holds a tombstoned row at `dead` (the #19062 join) — so it cannot be - // re-graded out from under this case. The carrier used to be `flow.description`, - // a docs-shaped row that WAS re-graded `live` (#20299): a borrowed `dead` row is - // a claim with a timestamp, and the precondition below says so if it moves. + // re-graded out from under this case. It was `flow.active` until that ≤16.x + // kit aged out (#25767) and its row left the ledger; before that + // `flow.description`, a docs-shaped row that WAS re-graded `live` (#20299): + // a borrowed `dead` row is a claim with a timestamp, and the precondition + // below says so if it moves. it('stays GREEN when a `dead` entry carries the SAME rotted pointer', () => { - const shipped = JSON.parse(readFileSync(path.join(LEDGERS, 'flow.json'), 'utf8')); - expect(shipped.props.active.status, 'the carrier row must be `dead` in the shipped ledger').toBe('dead'); + const shipped = JSON.parse(readFileSync(path.join(LEDGERS, 'action.json'), 'utf8')); + expect(shipped.props.aria.status, 'the carrier row must be `dead` in the shipped ledger').toBe('dead'); const root = path.join(tmp, 'dead-excluded'); cpSync(LEDGERS, root, { recursive: true }); - setEvidence(root, 'flow', 'active', `${ROTTED} (rotted by the self-test)`); + setEvidence(root, 'action', 'aria', `${ROTTED} (rotted by the self-test)`); const { status, output } = runGate(root); expect(status, output).toBe(0); - expect(output).not.toContain(`flow/active → ${ROTTED}`); + expect(output).not.toContain(`action/aria → ${ROTTED}`); }); // The partition itself, pinned at the source — the precedent is the diff --git a/packages/spec/scripts/liveness/tombstoned-row-status.test.ts b/packages/spec/scripts/liveness/tombstoned-row-status.test.ts index 75e63f07170..65838486437 100644 --- a/packages/spec/scripts/liveness/tombstoned-row-status.test.ts +++ b/packages/spec/scripts/liveness/tombstoned-row-status.test.ts @@ -87,7 +87,7 @@ const SPEC_ONLY_SCHEMAS: Record = { const TOMBSTONE_MARKER = '[REMOVED]'; /** The population measured on the commit that pinned this — the non-vacuity floor. */ -const TOMBSTONE_FLOOR = 40; +const TOMBSTONE_FLOOR = 25; function defOf(s: any): any { return s && (s._zod?.def ?? s._def); @@ -172,7 +172,8 @@ describe('shipped ledgers — a tombstoned key is `dead`', () => { const offenders = tombstones.filter((t) => t.status !== 'dead').map((t) => `${t.coord} → status=${t.status}`); expect(offenders).toEqual([]); // Non-vacuity: a walker that stopped resolving shapes would satisfy the - // line above by asking nothing at all. 40 was the measured population. + // line above by asking nothing at all. 40 was the measured population + // before #25767 aged out the ≤16.x kits; 28 after. expect(tombstones.length).toBeGreaterThanOrEqual(TOMBSTONE_FLOOR); }); @@ -185,12 +186,14 @@ describe('shipped ledgers — a tombstoned key is `dead`', () => { expect(writable).toEqual([]); }); - it('`agent.tools` is the #18304 row: tombstoned in AgentSchema, `dead` in the ledger, with no `evidence` pointer', () => { - const shape = shapeOf(getMetadataTypeSchema('agent'))!; - expect(descOf(shape.tools)).toContain(TOMBSTONE_MARKER); - expect(defOf(unwrap(shape.tools))?.type).toBe('never'); + // #18304's carrier was `agent.tools`; that ≤16.x kit aged out on #25767 and + // its row left the ledger, so a 17.x tombstone carries the same pin. + it('`action.aria` is the carrier row: tombstoned in ActionSchema, `dead` in the ledger, with no `evidence` pointer', () => { + const shape = shapeOf(getMetadataTypeSchema('action'))!; + expect(descOf(shape.aria)).toContain(TOMBSTONE_MARKER); + expect(defOf(unwrap(shape.aria))?.type).toBe('never'); - const row = ledgerOf('agent').props.tools; + const row = ledgerOf('action').props.aria; expect(row.status).toBe('dead'); // A `dead` row's pointer lives in `note` by the gate's own design // (EVIDENCE_UNSCANNED_STATUSES): an `evidence` string here would be a claim diff --git a/packages/spec/scripts/pure-schema-construction.test.ts b/packages/spec/scripts/pure-schema-construction.test.ts index b741f8a654b..cf81bdbf398 100644 --- a/packages/spec/scripts/pure-schema-construction.test.ts +++ b/packages/spec/scripts/pure-schema-construction.test.ts @@ -133,7 +133,9 @@ describe('a consumer of the built ./shared entry keeps the conversion table only return ids.filter((id) => text.includes(`'${id}'`) || text.includes(`"${id}"`)); }; - expect(ids.length).toBeGreaterThan(100); + // Anti-vacuity floor, not a count: 100 measured after #25767 retired the + // ≤16.x kits (the table shrinks by one major's worth each protocol major). + expect(ids.length).toBeGreaterThan(50); expect(await kept('normalizeStackInput', 'shared-reader')).toEqual(ids); expect( await kept( diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 621491a7a6a..37a2ba34e83 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest'; import { CreateRecordConfigSchema } from '../automation/builtin-node-config.zod.js'; import { FlowSchema } from '../automation/flow.zod.js'; import { FLOW_RESERVED_NODE_TYPES } from '../automation/flow-reserved-node-types.js'; -import { ScriptConfigSchema } from '../automation/schemaless-node-config.zod.js'; import { DatasourceSchema } from '../data/datasource.zod.js'; import { getDriverConfigSchema, diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 852347f8bd0..c8c8a55fc0d 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -512,9 +512,10 @@ const pageComponentVisibilityToVisibleWhen: MetadataConversion = { * carries them — and the generated upgrade guide and the `spec_changes` MCP * tool are projections of that record, composed across however many majors * the consumer is jumping; - * - the step-17 chain entry references them by id, so - * `os migrate meta --from 16` (a step the support floor has since retired, #25767) REWRITES the consumer's source mechanically - * instead of asking them to hand-edit. + * - the step-17 chain entry referenced them by id, so + * `os migrate meta --from 16` REWROTE the consumer's source mechanically + * instead of asking them to hand-edit — until the support floor rose to 17 + * and retired the step (#25767). * * The tombstone error is the backstop for someone who did neither, and it says * so by pointing at `migrate meta`. @@ -592,8 +593,9 @@ function mapObjectFieldsKey(stack: Dict, collection: string, from: string, to: s * release, so that refusal is what an author meets at every door that judges * a sharing rule (`os validate` / `os build` / `os lint`). Stored rows keep * healing: stored-row rehydration (`applyConversionsToStoredItem`) replays the - * full chain, this entry included, and so do the artifact-ingestion door - * (inside its window) and `os migrate meta --from 16` (a step the support floor has since retired, #25767). + * full chain, this entry included, and so does the artifact-ingestion door + * (inside its window); `os migrate meta --from 16` did too, until the support + * floor retired the step (#25767). */ const sharingRuleAccessLevelFullToEdit: MetadataConversion = { id: 'sharing-rule-access-level-full-to-edit', @@ -676,8 +678,9 @@ function renameFlowConfigAliases( * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16` (a step the support floor has since retired, #25767). + * included, and so does the artifact-ingestion door (inside its window); + * `os migrate meta --from 16` did too, until the support floor retired the + * step (#25767). */ const flowNodeCrudObjectAlias: MetadataConversion = { id: 'flow-node-crud-object-alias', @@ -809,8 +812,9 @@ function liftNotifySourceShape(stack: Dict, emit: Emit): Dict { * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16` (a step the support floor has since retired, #25767). + * included, and so does the artifact-ingestion door (inside its window); + * `os migrate meta --from 16` did too, until the support floor retired the + * step (#25767). */ const flowNodeNotifyConfigAliases: MetadataConversion = { id: 'flow-node-notify-config-aliases', @@ -1046,7 +1050,8 @@ function liftWaitEventConfig(stack: Dict, emit: Emit): Dict { * Stored flows are the `includeRetired` seams: the automation engine's flow * rehydration, stored-row rehydration (`applyConversionsToStoredItem`), the * metadata save door's flow fallback, the artifact-ingestion door inside its - * window, and `os migrate meta --from 16` (a step the support floor has since retired, #25767) (a step-17 member). + * window; `os migrate meta --from 16` replayed it too (a step-17 member), + * until the support floor retired the step (#25767). */ const flowNodeWaitEventConfigLift: MetadataConversion = { id: 'flow-node-wait-event-config-lift', @@ -1134,8 +1139,9 @@ const flowNodeWaitEventConfigLift: MetadataConversion = { * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16` (a step the support floor has since retired, #25767). + * included, and so does the artifact-ingestion door (inside its window); + * `os migrate meta --from 16` did too, until the support floor retired the + * step (#25767). */ const flowNodeMapFlowAlias: MetadataConversion = { id: 'flow-node-map-flow-alias', @@ -1203,8 +1209,9 @@ const flowNodeMapFlowAlias: MetadataConversion = { * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16` (a step the support floor has since retired, #25767). + * included, and so does the artifact-ingestion door (inside its window); + * `os migrate meta --from 16` did too, until the support floor retired the + * step (#25767). */ const flowNodeSubflowFlowAlias: MetadataConversion = { id: 'flow-node-subflow-flow-alias', @@ -1468,8 +1475,9 @@ const flowNodeConnectorConfigLift: MetadataConversion = { * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so do the artifact-ingestion door (inside its window) and - * `os migrate meta --from 16` (a step the support floor has since retired, #25767). + * included, and so does the artifact-ingestion door (inside its window); + * `os migrate meta --from 16` did too, until the support floor retired the + * step (#25767). * * The fixture below carried `actionType: 'invoke_function'` through both sides * until #4343 retired that key — an end state protocol 17 no longer reaches, so @@ -1659,8 +1667,10 @@ const stripKeys = ( * replays this entry, and an author is taught the canonical id instead of * being rewritten. Stored rows are the `includeRetired` seams: stored-row * rehydration (`applyConversionsToStoredItem`) and the artifact door still - * rewrite them, and `os migrate meta --from 16` (a step the support floor has since retired, #25767) lists the edit for existing - * sources, as step 17's mechanical member. The spelling keeps resolving the + * rewrite them; `os migrate meta --from 16` listed the edit for existing + * sources, as step 17's mechanical member, until the support floor retired the + * step (#25767) — the D3 entry `datasource-driver-mongo-alias-retired` names + * the hand edit since. The spelling keeps resolving the * mongodb CONTRACT, which is what let the retired * `datasource-config-driver-key-aliases` — ordered ahead of this entry while * it existed (#25767) — reach a stored `driver: 'mongo'` row's legacy config @@ -1800,9 +1810,10 @@ const datasourceDriverMongoToMongodb: MetadataConversion = { * no longer replays it. Stored documents are the `includeRetired` seams — * stored-row rehydration (`applyConversionsToStoredItem`), the automation * engine's flow rehydration, the metadata save door's flow fallback, the - * artifact-ingestion door inside its window — and `os migrate meta --from 16` (a step the support floor has since retired, #25767) - * (a step-17 member, carried by the D3 entry `job-retry-policy-without-attempts-refused`), - * so a job stored or written before 17 keeps the three retries it ran with. + * artifact-ingestion door inside its window — so a job stored before 17 keeps + * the three retries it ran with. `os migrate meta --from 16` replayed it too, + * until the support floor rose to 17 and retired the step (#25767); since then + * the D3 entry `job-retry-policy-without-attempts-refused` names the hand edit. */ const retryPolicyConverged: MetadataConversion = { id: 'retry-policy-converged', diff --git a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts index 3b82aada926..4af919cd523 100644 --- a/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts +++ b/packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts @@ -47,14 +47,6 @@ import { ManifestSchema, PluginRuntimeSchema } from './manifest.zod'; const SAYS_PUBLISH_GATE_ENFORCED = /publish gate/i; const SAYS_422 = /422/; const SAYS_LOAD_SIDE_NOT_ENFORCED = /load-side enforcement is NOT implemented/i; -/** - * [commit aaacf1d5c] The permissions half of the same sentence, under the same rule: the - * granted set is REGISTERED at load and queried by nothing. Probes the two - * load-bearing words rather than a whole clause, so a rewording that keeps the - * fact keeps the pin. - */ -const SAYS_GRANT_REGISTERED_NOT_ENFORCED = /REGISTERED on the PluginPermissionEnforcer at load and queried by\s+nothing/i; - describe('manifest.runtime trust-tier text is truthful', () => { const baseManifest = { id: 'com.example.plugin', diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts index 92f5d149ac9..2e1351366ef 100644 --- a/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts @@ -3,10 +3,11 @@ import type { SemanticMigration } from '../../types.js'; // #23543 F1 — the value retirement's one D3 entry. The D2 half is -// `datasource-driver-mongo-to-mongodb` (a step-17 conversion), which rewrites -// every `driver: 'mongo'` the chain can reach and is now retired from the load -// path; this entry carries what the chain cannot reach — the spellings that -// live outside metadata sources. +// `datasource-driver-mongo-to-mongodb` (protocol 17), now retired from the load +// path. It carries no `conversionIds`: since #25767 protocol 17 is +// MIGRATION_SUPPORT_FLOOR, so no step of the chain replays that conversion, +// and `os migrate meta` lists this entry as the hand edit it is — for the +// spellings inside metadata sources and the ones outside them alike. export const entry: SemanticMigration = { id: 'datasource-driver-mongo-alias-retired', // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it @@ -23,10 +24,11 @@ export const entry: SemanticMigration = { + 'hosts accepted it as a driver selection. At 18 the authoring parse refuses it by name with the ' + 'prescription naming `mongodb`, and it is no longer a selection spelling, so a boot that sets ' + '`OS_DATABASE_DRIVER=mongo` is refused with the list of supported drivers. The D2 conversion ' - + '`datasource-driver-mongo-to-mongodb` makes the mechanical edit wherever the chain reaches: ' - + "`os migrate meta` lists every `datasources[].driver: 'mongo'` in your metadata sources, and stored " - + 'datasource rows keep loading because every rehydration seam replays it. What the chain cannot ' - + 'reach is everything outside metadata sources: an environment variable or deployment manifest ' + + '`datasource-driver-mongo-to-mongodb` belongs to protocol 17, the migration floor, so ' + + "`os migrate meta` applies no edit for it and prints each `datasources[].driver: 'mongo'` in your " + + 'metadata sources among the refusals left after the chain; change the value by hand. Stored ' + + 'datasource rows keep loading because every rehydration seam replays it. Outside metadata sources ' + + 'the same hand edit is owed: an environment variable or deployment manifest ' + 'setting `OS_DATABASE_DRIVER=mongo`, a program that passes `databaseDriver: \'mongo\'` to the ' + 'standalone stack, and a script or integration that creates or updates a datasource through the ' + 'metadata or datasource REST API with `driver: \'mongo\'`. Each of those is refused at 18 and needs ' @@ -38,5 +40,4 @@ export const entry: SemanticMigration = { + "`OS_DATABASE_DRIVER=mongodb` (or with the driver chosen by a `mongodb://` URL scheme and no " + 'override); every script or integration that writes a datasource sends `driver: \'mongodb\'` and ' + 'gets a success answer from the API.', - conversionIds: ['datasource-driver-mongo-to-mongodb'], }; diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-sibling-block-loose-config-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-sibling-block-loose-config-keys-refused.ts index 6520e360cf4..a90c1303c1b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-sibling-block-loose-config-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-sibling-block-loose-config-keys-refused.ts @@ -4,10 +4,11 @@ import type { SemanticMigration } from '../../types.js'; // #23543 F3 — the two lifts' one D3 entry. The D2 halves are // `flow-node-wait-event-config-lift` and `flow-node-connector-config-lift` -// (both step-17 conversions), which move every named loose key the chain can -// reach into its block and are now retired from the load path; this entry -// states the refusal an authored source meets at 18, and the one shape no -// conversion repairs — a connector node whose pair the lift cannot complete. +// (both protocol 17), now retired from the load path. It carries no +// `conversionIds`: since #25767 protocol 17 is MIGRATION_SUPPORT_FLOOR, so no +// step of the chain replays either conversion, and `os migrate meta` lists this +// entry as the hand edit it is. It also states the one shape no conversion ever +// repaired — a connector node whose pair the lift cannot complete. export const entry: SemanticMigration = { id: 'flow-sibling-block-loose-config-keys-refused', // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it @@ -26,10 +27,12 @@ export const entry: SemanticMigration = { + 'parse refuses each one where it is written, naming the block key it belongs on, at `objectstack ' + 'validate`, `objectstack build` and `defineStack`, so under `objectstack lint` too when the config ' + 'calls it — because with the lift gone a loose key would be read by nobody, and a loose `input` ' - + 'beside a complete `connectorConfig` would dispatch the action with no input. The D2 conversions ' - + 'make the move wherever the chain reaches: `objectstack migrate meta` lists every such key in your ' - + 'metadata sources. Where the block already sets the same key, the block is the one the node always ' - + 'ran with, and the loose key is deleted. Stored flows keep loading: every rehydration seam replays ' + + 'beside a complete `connectorConfig` would dispatch the action with no input. Both D2 conversions ' + + 'belong to protocol 17, the migration floor, so `objectstack migrate meta` applies no edit for ' + + 'them and prints each such key in your metadata sources, where it is written, among the refusals ' + + 'left after the chain; move the key by hand. Where the block already sets the same key, the block ' + + 'is the one the node always ran with: delete the loose key. Stored flows keep loading: every ' + + 'rehydration seam replays ' + 'both conversions, and each now deletes the loose keys its block shadows instead of leaving them ' + 'for the parse to refuse; the metadata save door judges a flow after the same replay, so a script ' + 'that still sends the loose spelling keeps saving. What no conversion repairs is a `connector_action` ' @@ -39,5 +42,4 @@ export const entry: SemanticMigration = { '`objectstack validate` reports no refusal at a `config` path of a `wait` or `connector_action` node, ' + 'every such node declares its settings in `waitEventConfig` / `connectorConfig` only, and each wait ' + 'resumes and each connector action dispatches with the same input as before.', - conversionIds: ['flow-node-wait-event-config-lift', 'flow-node-connector-config-lift'], }; diff --git a/packages/spec/src/migrations/entries/semantic/18.job-retry-policy-without-attempts-refused.ts b/packages/spec/src/migrations/entries/semantic/18.job-retry-policy-without-attempts-refused.ts index 5a3e21eb328..aa1e9975637 100644 --- a/packages/spec/src/migrations/entries/semantic/18.job-retry-policy-without-attempts-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.job-retry-policy-without-attempts-refused.ts @@ -3,11 +3,13 @@ import type { SemanticMigration } from '../../types.js'; // #23543 F2 — the job default flip's one D3 entry. The D2 half is -// `retry-policy-converged` (a step-17 conversion), which writes the pre-17 +// `retry-policy-converged` (protocol 17), which wrote the pre-17 // `maxRetries: 3` / `backoffMultiplier: 2` into every job retry block the chain -// can reach and is now retired from the load path; this entry states the -// refusal an authored source meets at 18, and the choice no conversion can make -// for the author — whether a job that never stated a count should retry at all. +// could reach and is now retired from the load path. It carries no +// `conversionIds`: since #25767 protocol 17 is MIGRATION_SUPPORT_FLOOR, so no +// step of the chain replays that conversion, and `os migrate meta` lists this +// entry as the hand edit it is — with the choice no conversion can make for the +// author: whether a job that never stated a count should retry at all. export const entry: SemanticMigration = { id: 'job-retry-policy-without-attempts-refused', // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it @@ -23,13 +25,12 @@ export const entry: SemanticMigration = { + 'and `backoffMultiplier: 2` into such a block with a notice. At 18 the job parse refuses the block ' + 'at `retryPolicy.maxRetries` instead, at `objectstack validate`, `objectstack build` and ' + '`defineStack`, so under `objectstack lint` too when the config calls it: "a retry block with no ' - + 'attempts is no retry block". The D2 conversion makes the 17 edit wherever the chain ' - + 'reaches: `objectstack migrate meta` lists every block in your metadata sources that omits the ' - + 'count and writes both pre-17 numbers into it, which keeps the retries the job ran with; a block ' - + 'that writes `maxRetries: 0` it does not list, and you delete that block by hand (or state a ' - + 'count). Stored jobs keep loading ' - + 'with the same numbers, because every rehydration seam replays the conversion. Decide per job: ' - + 'keep the written count, or delete the block if the job should run once. A block that already ' + + 'attempts is no retry block". The D2 conversion belongs to protocol 17, the migration floor, so ' + + '`objectstack migrate meta` applies no edit for it and prints each block in your metadata sources ' + + 'that omits the count, or writes 0, among the refusals left after the chain. Stored jobs keep ' + + 'loading with the pre-17 numbers, because every rehydration seam replays the conversion. Decide ' + + 'per job, by hand: write `maxRetries: 3` and `backoffMultiplier: 2` to keep the retries the job ' + + 'ran with before 17, or delete the block if the job should run once. A block that already ' + 'states `maxRetries` and omits `backoffMultiplier` is not refused, and its delay is now flat ' + '(multiplier 1) where the loader used to write 2: state `backoffMultiplier: 2` to keep the old ' + 'curve.', @@ -37,5 +38,4 @@ export const entry: SemanticMigration = { '`objectstack validate` reports no refusal at a `jobs[].retryPolicy.maxRetries` path; every job that ' + 'should retry states `maxRetries` (1 to 10) in its block, every job that should run once has no ' + 'block, and a failing run is attempted the stated number of extra times.', - conversionIds: ['retry-policy-converged'], }; diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index 318e1d16930..d79b74143e4 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -736,8 +736,8 @@ describe('AppSchema retired sharing/embed keys', () => { ], }); - expect(app.sharing).toBeUndefined(); - expect(app.embed).toBeUndefined(); + expect(app).not.toHaveProperty('sharing'); + expect(app).not.toHaveProperty('embed'); expect(app.navigation).toHaveLength(1); }); }); diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index cf178e57928..c64d92809f9 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -421,7 +421,8 @@ describe('PageCardProps', () => { const result = PageCardProps.parse({}); expect(result.bordered).toBe(true); expect(result.title).toBeUndefined(); - expect(result.actions).toBeUndefined(); + // [#25767] `actions` left the shape with its ≤16.x tombstone. + expect(result).not.toHaveProperty('actions'); expect(result.children).toBeUndefined(); expect(result.footer).toBeUndefined(); }); @@ -848,11 +849,11 @@ describe('RecordDetailsProps', () => { // refusal names the upgrade guide, not the old prescription. it('refuses the retired `layout` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { expect(() => RecordDetailsProps.parse({ layout: 'custom' })) - .toThrow(/Unrecognized key\(s\) on [^:]+: `layout`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); + .toThrow(/Unrecognized key\(s\) on this `record:details`: `layout`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); // The declared default is refused too — `auto` was never distinguishable // from `custom` or from omitting the key. expect(() => RecordDetailsProps.parse({ layout: 'auto' })) - .toThrow(/Unrecognized key\(s\) on [^:]+: `layout`/); + .toThrow(/Unrecognized key\(s\) on this `record:details`: `layout`/); }); it('does not materialize the retired `layout` on a clean parse', () => { @@ -2166,7 +2167,7 @@ describe('Interactive Elements — element:record_picker', () => { const r = ElementRecordPickerPropsSchema.safeParse({ [key]: key === 'multiple' ? true : key === 'searchFields' ? ['name'] : 'title' }); expect(r.success).toBe(false); const message = r.success ? '' : r.error.issues[0]!.message; - expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on [^:]+: \`${key}\``)); + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on this \`element:record_picker\`: \`${key}\``)); expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); expect(message).not.toMatch(/was removed/); }, diff --git a/packages/spec/src/ui/dashboard-compareto.test.ts b/packages/spec/src/ui/dashboard-compareto.test.ts index dfe67328ab2..b32a4d3118c 100644 --- a/packages/spec/src/ui/dashboard-compareto.test.ts +++ b/packages/spec/src/ui/dashboard-compareto.test.ts @@ -122,11 +122,14 @@ describe('compareTo — every retired spelling is rejected WITH its upgrade', () expect(period).not.toMatch(/#\d{3,5}\b/); expect(period).toContain("kind: "); expect(period).toContain('previousPeriod'); - expect(period).toContain('os migrate meta --from 16'); + // The protocol-16.x step aged out on #25767 (support floor 17): the sentence + // says so instead of naming a `--from 16` run the tool refuses. + expect(period).toContain('support floor 17'); + expect(period).not.toContain('--from 16'); const year = reject('previousYear'); expect(year).toContain('previousYear'); - expect(year).toContain('os migrate meta --from 16'); + expect(year).toContain('support floor 17'); }); /** diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 17cc779890f..d4cc30c5385 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '../shared/protocol-upgrade-guide'; import { DashboardSchema, DashboardWidgetSchema, @@ -218,10 +219,11 @@ describe('widget unknown-key message order — fix before history', () => { }); it('is unchanged in SHAPE when no fix matches — full-message pin', () => { - // A key with no family, no guidance and no near-declared-key: the history - // follows the key statement directly. Any stray separator fails here. + // A key with no family, no guidance and no near-declared-key: the upgrade + // guide sentence (#25767, the last fix channel) and then the history follow + // the key statement directly. Any stray separator fails here. expect(messageFor({ zzWrongKey: 'blue' })) - .toBe(`Unrecognized key(s) on this dashboard widget: \`zzWrongKey\`. ${HISTORY}`); + .toBe(`Unrecognized key(s) on this dashboard widget: \`zzWrongKey\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${HISTORY}`); }); it('a near-miss of a DECLARED key now gets the rename the bespoke map never offered', () => { @@ -233,7 +235,7 @@ describe('widget unknown-key message order — fix before history', () => { const m = messageFor({ colourVariant: 'blue' }); expect(m).toBe( 'Unrecognized key(s) on this dashboard widget: `colourVariant`. ' - + `Did you mean \`colourVariant\` → \`colorVariant\`? ${HISTORY}`, + + `Did you mean \`colourVariant\` → \`colorVariant\`? ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${HISTORY}`, ); }); @@ -747,11 +749,14 @@ describe('DashboardHeaderAction fields carry an item-level `title`', () => { const RETIRED_DESCRIPTION_PREFIX = '[REMOVED] '; /** - * Every `retiredKey()` tombstone on the `widgets[]` row, BY NAME, derived - * from `z.toJSONSchema(DashboardSchema)` on this tree — the same five under - * `io: 'input'` and `io: 'output'`, out of 22 row properties. + * Every `retiredKey()` tombstone on the dashboard ROOT, BY NAME, derived + * from `z.toJSONSchema(DashboardSchema)` on this tree — the same set under + * `io: 'input'` and `io: 'output'`. The five on the `widgets[]` row + * (`actionIcon`/`actionType`/`actionUrl`/`aria`/`responsive`) were ≤16.x + * kits and aged out on #25767: the row carries no tombstone now, so the + * root's 17.x tombstone carries the rule and the row is the control. */ - const WIDGET_ROW_TOMBSTONES = ['actionIcon', 'actionType', 'actionUrl', 'aria', 'responsive'] as const; + const ROOT_TOMBSTONES = ['refreshInterval'] as const; // The console derives the panel schema with `io: 'input'`; `GET /meta` // derives it in output mode. The name must survive both. @@ -775,21 +780,23 @@ describe('DashboardHeaderAction fields carry an item-level `title`', () => { // could each be titled with this file still green — so the set is // DERIVED from the served schema and every member is asserted. // (`widgets[].id` held this role until its carrier was titled.) - const widgetProps = js.properties.widgets.items.properties; - const tombstones = Object.keys(widgetProps).filter( - (k) => - typeof widgetProps[k]?.description === 'string' && - widgetProps[k].description.startsWith(RETIRED_DESCRIPTION_PREFIX), - ); + const isTombstone = (props: any, k: string) => + typeof props[k]?.description === 'string' && props[k].description.startsWith(RETIRED_DESCRIPTION_PREFIX); + const rootProps = js.properties; + const tombstones = Object.keys(rootProps).filter((k) => isTombstone(rootProps, k)); // Floor, BY NAME — the loop below is vacuously green over an EMPTY set, // and the `'Widget ID'` leg guards only that the node resolved, not that // the predicate still selects anything. A floor rather than an equality: - // a sixth tombstone is covered by the loop on the day it lands, which is + // a second tombstone is covered by the loop on the day it lands, which is // the reason for deriving the set instead of listing it. - expect(tombstones.slice().sort()).toEqual(expect.arrayContaining([...WIDGET_ROW_TOMBSTONES])); + expect(tombstones.slice().sort()).toEqual(expect.arrayContaining([...ROOT_TOMBSTONES])); for (const key of tombstones) { - expect(widgetProps[key].title, `widgets.items.properties.${key}.title`).toBeUndefined(); + expect(rootProps[key].title, `properties.${key}.title`).toBeUndefined(); } + // CONTROL (#25767): the `widgets[]` row's five ≤16.x tombstones aged out + // — they are strict unknown keys now, not rows, so the row derives EMPTY. + const widgetProps = js.properties.widgets.items.properties; + expect(Object.keys(widgetProps).filter((k) => isTombstone(widgetProps, k))).toEqual([]); // Lit — the authorable sibling really does carry one, so the lines above // measure the tombstone rule, not an emitter that never writes titles. expect(widgetProps.id.title).toBe('Widget ID'); diff --git a/packages/spec/src/ui/view-union-retirement-prescription.test.ts b/packages/spec/src/ui/view-union-retirement-prescription.test.ts index 71125883fa6..f117e15982c 100644 --- a/packages/spec/src/ui/view-union-retirement-prescription.test.ts +++ b/packages/spec/src/ui/view-union-retirement-prescription.test.ts @@ -20,7 +20,7 @@ * ``` * * ⚠️ Shipped behaviour, not a regression: `virtualScroll`, `striped` and - * `bordered` have read this way since 17.0.0. Guidance that exists and cannot + * `bordered` read this way from 17.0.0 until their kit aged out (#25767). Guidance that exists and cannot * be seen is, from the author's side, indistinguishable from guidance that does * not exist. * @@ -86,47 +86,60 @@ const topMessage = (body: unknown): string => { // from the WRONG branch would be measuring nothing about reach. // ─────────────────────────────────────────────────────────────────────────── -const BRANCH_PINS: ReadonlyArray, string]> = [ +/** + * [#25767] The carriers moved: `virtualScroll` / `striped` / `form.aria` were + * ≤16.x kits that aged out and read as strict unknown keys now (the catchall + * channel, §3), so each branch carries a 17.x tombstone instead. The last + * column says whether the prescription carries the `os migrate meta` + * sentence: the form branch's only surviving top-level-reachable tombstone is + * the ADR-0089 D2 `visibleOn` rename, which the parse folded and no + * conversion ever rode, so it never carried one. + */ +const BRANCH_PINS: ReadonlyArray, string, boolean]> = [ [ 'viewItem', - 'config.virtualScroll', + 'owner (the record\'s own key)', { - name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', - config: { type: 'grid', columns: ['name'], virtualScroll: true }, + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', owner: 'admin', + config: { type: 'grid', columns: ['name'] }, }, - '`view.virtualScroll` was removed', + '`view.owner` was removed', + true, ], [ 'container', - 'list.striped', + 'list.filterableFields', { name: 'crm_lead', object: 'crm_lead', - list: { type: 'grid', columns: ['name'], striped: true }, + list: { type: 'grid', columns: ['name'], filterableFields: ['name'] }, }, - '`view.striped` was removed', + '`view.list.filterableFields` was removed', + true, ], [ 'listOverlay', - 'virtualScroll (the card\'s own repro)', + 'filterableFields (the card\'s own repro, re-targeted)', { name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', - type: 'grid', columns: ['name'], virtualScroll: true, + type: 'grid', columns: ['name'], filterableFields: ['name'], }, - '`view.virtualScroll` was removed', + '`view.list.filterableFields` was removed', + true, ], [ 'formOverlay', - 'aria', + 'sections[].visibleOn', { name: 'crm_lead.edit', object: 'crm_lead', viewKind: 'form', - type: 'simple', aria: { label: 'x' }, + type: 'simple', sections: [{ fields: ['name'], visibleOn: 'record.status == "open"' }], }, - '`form.aria` was removed', + '`view.form.sections[].visibleOn` was removed', + false, ], ]; describe('§1 reach — every branch of the view union surfaces its own prescription', () => { - it.each(BRANCH_PINS)('%s branch (%s)', (branch, _key, body, opening) => { + it.each(BRANCH_PINS)('%s branch (%s)', (branch, _key, body, opening, migrateSentence) => { expect(selectViewMetadataBranch(body), 'the pin must exercise the branch it names').toBe(branch); const message = topMessage(body); @@ -136,7 +149,9 @@ describe('§1 reach — every branch of the view union surfaces its own prescrip // its `os migrate meta` sentence is pinned class-wide by // `../shared/retired-key-migrate-sentence.test.ts`. A message this code // composed would be a second spelling of a pinned string. - expect(message).toContain('to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'); + const sentence = 'to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + if (migrateSentence) expect(message).toContain(sentence); + else expect(message).not.toContain(sentence); }); it('the lifted string is byte-identical to the nested issue it came from', () => { diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 1e10e9d9b29..0c6024e4ee2 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -69,6 +69,7 @@ import { checkViewCompleteness } from '../kernel/functional-completeness'; // [#19088] The object-field `scale` site #18972 bounded — read only, to pin // that this row's refusal text and that one's are the same platform fact. import { FieldSchema } from '../data/field.zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '../shared/protocol-upgrade-guide'; describe('HttpMethodSubsetSchema', () => { it('should accept valid HTTP methods', () => { const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; @@ -3203,7 +3204,10 @@ describe('Airtable-style ListView enhancements', () => { // ============================================================================ describe('FormViewSchema — retired defaultSort (audit close-out: nothing read it)', () => { - it('REJECTS the retired `defaultSort` and points at the related list view', () => { + // The ≤16.x kit aged out on #25767: `defaultSort` is a strict unknown key + // whose refusal names the upgrade guide; the audit close-out prescription + // lives in the release that carried it. + it('refuses the retired `defaultSort` as a strict unknown key, pointing at the upgrade guide (#25767)', () => { let message = ''; try { FormViewSchema.parse({ @@ -3211,8 +3215,9 @@ describe('FormViewSchema — retired defaultSort (audit close-out: nothing read defaultSort: [{ field: 'created_at', order: 'desc' }], }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/list view/); - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(/Unrecognized key\(s\) on this form view: `defaultSort`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toMatch(/audit close-out/); }); it('a form without it still parses', () => { expect(() => FormViewSchema.parse({ type: 'simple', sections: [{ fields: ['name'] }] })).not.toThrow(); @@ -3916,17 +3921,19 @@ describe('ListViewSchema filter field', () => { // Issue #7: ListView responsive and performance config // ============================================================================ describe('ListViewSchema — retired responsive/performance (audit close-out: no renderer read them)', () => { - it('REJECTS the retired `responsive` with the prescription', () => { - expect(() => ListViewSchema.parse({ - type: 'grid', columns: ['name'], responsive: { hiddenOn: ['xs'] }, - })).toThrow(/responsive.*removed|removed.*responsive/s); - }); - it('REJECTS the retired `performance` with the prescription', () => { + // The ≤16.x kits aged out on #25767: both keys are strict unknown keys whose + // refusal names the upgrade guide, not the audit close-out prescription. + it.each([ + ['responsive', { hiddenOn: ['xs'] }], + ['performance', { lazyLoad: true }], + ] as const)('refuses the retired `%s` as a strict unknown key, pointing at the upgrade guide (#25767)', (key, value) => { let message = ''; try { - ListViewSchema.parse({ type: 'grid', columns: ['name'], performance: { lazyLoad: true } }); + ListViewSchema.parse({ type: 'grid', columns: ['name'], [key]: value }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(/audit close-out/); + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on this list view: \`${key}\``)); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toMatch(/audit close-out|was removed/); }); }); @@ -3937,31 +3944,22 @@ describe('ListViewSchema — retired responsive/performance (audit close-out: no // end, never spells any of the three. // ============================================================================ describe('ListViewSchema — retired striped/bordered/virtualScroll (every reader only passed them through)', () => { - it('REJECTS the retired `striped` with the prescription', () => { - expect(() => ListViewSchema.parse({ - type: 'grid', columns: ['name'], striped: true, - })).toThrow(/`view\.striped`.*removed.*no renderer ever applied it.*delete the key/s); - }); - it('REJECTS the retired `bordered` with the prescription', () => { - expect(() => ListViewSchema.parse({ - type: 'grid', columns: ['name'], bordered: true, - })).toThrow(/`view\.bordered`.*removed.*Delete the key/s); - }); - it('REJECTS the retired `virtualScroll` with the prescription, naming the live mechanism', () => { - expect(() => ListViewSchema.parse({ - type: 'grid', columns: ['name'], virtualScroll: true, - })).toThrow(/`view\.virtualScroll`.*removed.*large datasets page via `pagination`/s); - }); - it('the prescriptions carry the pinned migrate sentence (the retirement rides the protocol-17 conversion)', () => { - for (const key of ['striped', 'bordered', 'virtualScroll'] as const) { + // The #7176 kit rode the protocol-17 conversion and aged out on #25767: the + // three keys are strict unknown keys whose refusal names the upgrade guide. + // The prescriptions (and the `--from 16` sentence) live in the release that + // carried them. + it.each(['striped', 'bordered', 'virtualScroll'] as const)( + 'refuses the retired `%s` as a strict unknown key, pointing at the upgrade guide (#25767)', + (key) => { let message = ''; try { ListViewSchema.parse({ type: 'grid', columns: ['name'], [key]: true }); } catch (e) { message = String((e as Error).message); } - expect(message).toMatch(new RegExp(`view\\.${key}\` was removed`)); - expect(message).toMatch(/Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./); - } - }); + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on this list view: \`${key}\``)); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(message).not.toMatch(/was removed|os migrate meta/); + }, + ); it('accepts the live grid siblings byte-identically (rowHeight/selection/pagination/resizable)', () => { const siblings = { resizable: true, @@ -3981,13 +3979,13 @@ describe('ListViewSchema — retired striped/bordered/virtualScroll (every reade expect(parsed).not.toHaveProperty('bordered'); expect(parsed).not.toHaveProperty('virtualScroll'); }); - it('ObjectListViewSchema (the .extend copy) rejects the keys with the same prescription', () => { + it('ObjectListViewSchema (the .extend copy) refuses the keys the same way', () => { expect(() => ObjectListViewSchema.parse({ type: 'grid', columns: ['name'], striped: true, - })).toThrow(/`view\.striped`.*removed/s); + })).toThrow(/Unrecognized key\(s\) on [^:]+: `striped`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); expect(() => ObjectListViewSchema.parse({ type: 'grid', columns: ['name'], virtualScroll: true, - })).toThrow(/`view\.virtualScroll`.*removed/s); + })).toThrow(/Unrecognized key\(s\) on [^:]+: `virtualScroll`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); }); @@ -4037,7 +4035,10 @@ describe('ListViewSchema.exportOptions — object form + array lift + pdf retire expect(message).toMatch(/a printable document \(an invoice, a delivery order, a letter\) is a page that declares\s+`print`/); expect(message).not.toMatch(/#\d{3,5}\b/); expect(message).toMatch(/'csv', 'xlsx' and 'json'/); - expect(message).toMatch(/Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./); + // [#25767] The protocol-16.x step aged out (support floor 17): the sentence + // says so instead of naming a `--from 16` run the tool refuses. + expect(message).toMatch(/left `os migrate meta` with the protocol-16\.x step \(support floor 17\)/); + expect(message).not.toMatch(/--from 16/); }); it("REJECTS 'pdf' in the object form's `formats` with the same prescription", () => { @@ -4387,7 +4388,7 @@ describe('visibility unknown-key message order — fix before history', () => { const res = FormSectionSchema.safeParse({ label: 'S', fields: [], bogusKey: true }); expect(res.success).toBe(false); const m = res.error!.issues.find((i) => i.code === 'unrecognized_keys')!.message; - expect(m).toBe(`Unrecognized key(s) on this form section: \`bogusKey\`. ${HISTORY}`); + expect(m).toBe(`Unrecognized key(s) on this form section: \`bogusKey\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ${HISTORY}`); }); it('emits the history exactly once, whatever the key count', () => { diff --git a/scripts/check-spec-docblock-symbol-anchors.mjs b/scripts/check-spec-docblock-symbol-anchors.mjs index 2d2d39e123e..bda44d1d279 100644 --- a/scripts/check-spec-docblock-symbol-anchors.mjs +++ b/scripts/check-spec-docblock-symbol-anchors.mjs @@ -239,6 +239,18 @@ export const CENSUS_17065 = { trackedTargetFiles: 5, hardFindings: 7, hardFindingFiles: 6, + /* CENSUS_RESIDUAL rows deleted since the census, each because the cited + * docblock LEFT THE TREE (never because a finding was re-graded). The + * self-test pins the residual at `hardFindings - repairedRows.length`, so the + * ratchet still only moves down, and every move is dated and named here. */ + repairedRows: [ + { + doc: 'packages/spec/src/conversions/registry.ts', + raw: 'packages/rest/src/import-mapping.ts:115-167', + dated: '2026-10-26', + why: '#25767 deleted the docblock with its conversion (a retirement kit at or below protocol 16.x)', + }, + ], declinedCitations: 183, declinedByShape: { 'bare-filename': 69, 'directory-qualified': 62, continuation: 52 }, softCrossRepoRows: 3, @@ -590,14 +602,6 @@ export const CENSUS_RESIDUAL = Object.freeze([ repair: 'the symbol is real — write the path from the repo root, ' + '`packages/spec/src/contracts/realtime-service.ts#RealtimeSubscriptionOptions`', }, - { - doc: 'packages/spec/src/conversions/registry.ts', - raw: 'packages/rest/src/import-mapping.ts:115-167', - kind: 'line-anchor', - count: 1, - dated: '2026-09-09', - repair: 'cite the symbol in `packages/core/src/utils/import-mapping.ts` (`applyMappingToRows`), or drop to a file-level anchor', - }, { doc: 'packages/spec/src/data/filter-array-declaration.test.ts', raw: 'examples/app-showcase/src/ui/pages/my-work.page.ts:52', @@ -1262,9 +1266,18 @@ export function selfTest() { check(row.repair && row.repair.length > 20, `the residual row for \`${row.raw}\` must name its repair`); check(row.doc.startsWith(`${SPEC_SRC_DIR}/`), `a residual row may only name a file in this corpus, got \`${row.doc}\``); } - // ...and it is the census's own number, so a row quietly added later reds. - check(liveTriage.pinned.length === CENSUS_17065.hardFindings, - `the pinned residual must equal the census's ${CENSUS_17065.hardFindings} hard findings, got ${liveTriage.pinned.length}`); + // ...and it is the census's own number less the rows it names as repaired, + // so a row quietly added later reds, and so does a row quietly deleted. + const expectedPinned = CENSUS_17065.hardFindings - CENSUS_17065.repairedRows.length; + check(liveTriage.pinned.length === expectedPinned, + `the pinned residual must equal the census's ${CENSUS_17065.hardFindings} hard findings less its ` + + `${CENSUS_17065.repairedRows.length} repaired row(s), got ${liveTriage.pinned.length}`); + for (const row of CENSUS_17065.repairedRows) { + check(/^\d{4}-\d{2}-\d{2}$/.test(row.dated), `the repaired row for \`${row.raw}\` must carry an ISO date`); + check(row.why && row.why.length > 20, `the repaired row for \`${row.raw}\` must say why the citation left`); + check(!CENSUS_RESIDUAL.some((r) => r.doc === row.doc && r.raw === row.raw), + `the repaired row for \`${row.raw}\` is still in CENSUS_RESIDUAL — a row is repaired or pinned, never both`); + } check(liveTriage.hard.length === 0, `the live corpus must carry NO finding outside the day-one residual: ${liveTriage.hard.map((f) => `${f.doc} ${f.raw}`).join(', ')}`); From 485a377558c61c226c4b9869009f351e00ee25bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:17:53 +0000 Subject: [PATCH 03/22] =?UTF-8?q?test(metadata-protocol,lint):=20the=20sto?= =?UTF-8?q?red-row=20and=20object-door=20pins=20ride=2017.x=20kits,=20not?= =?UTF-8?q?=20the=20retired=20=E2=89=A416.x=20ones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ≤16.x retirement kits left the registry in the previous commit, and two suites still rode them as fixtures: - packages/metadata-protocol's stored-row seams (migrateStoredMetadata, getMetaItem(s), loadMetaFromDb, the residue re-save and the non-canonical-type control) used the `conditionalRequired` → `requiredWhen` field rename and the action `execute` → `target` alias as the legacy shape the stored seam keeps lowering. Those conversions are gone, so the rows now carry the 17.2.0 `reference_to` → `reference` rename (`field-reference-to-alias`) and the 17.4.0 `aria` removal (`action-aria-removed`), both still registered for the data-at-rest seams; the pins read the same notices, rewrites, checksums and report lines as before, on the surviving kit. - packages/lint's object-door field-rule test listed `conditionalRequired` among the slots the build's expression walk judges; the walk no longer reads it (its tombstone aged out of `FieldSchema`, so the key is a strict unknown key), and the row is dropped with the docblock saying why. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- ...time-gate.object-field-rule-writes.test.ts | 7 +-- ...l.migrate-stored-noncanonical-type.test.ts | 7 +-- .../src/protocol.stored-conversions.test.ts | 39 ++++++++++------ .../src/protocol.stored-migration.test.ts | 45 ++++++++++++------- .../protocol.stored-residue-resave.test.ts | 6 ++- 5 files changed, 65 insertions(+), 39 deletions(-) diff --git a/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts b/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts index e64575aec8c..8061696fc95 100644 --- a/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-field-rule-writes.test.ts @@ -7,8 +7,10 @@ * ## The state this closes * * `validateStackExpressions` is the build's expression rule. Its field walk - * judges every field's `requiredWhen` / `readonlyWhen` / `conditionalRequired` - * / `visibleWhen` as a `record`-scoped predicate, with the root verdict, the + * judges every field's `requiredWhen` / `readonlyWhen` / `visibleWhen` as a + * `record`-scoped predicate (`conditionalRequired` left the walk at #25767: + * its tombstone aged out of `FieldSchema`, so the key is a strict unknown key + * and no longer a slot the pass judges), with the root verdict, the * `parent` gate (a slot reading `parent` on an object that does not declare * exactly one `master_detail`), the #4811 null-guard gate over `requiredWhen`, * and the #20078 refusal of a `requiredWhen` / `readonlyWhen` read through a @@ -67,7 +69,6 @@ const REFUSED = [ { slot: 'requiredWhen', slots: { requiredWhen: 'amount > 1' }, subject: 'bare reference `amount`' }, { slot: 'readonlyWhen', slots: { readonlyWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' }, { slot: 'visibleWhen', slots: { visibleWhen: 'sqrt(record.amount) > 1' }, subject: '`sqrt`' }, - { slot: 'conditionalRequired', slots: { conditionalRequired: 'amount > 1' }, subject: 'bare reference `amount`' }, // The root verdict: a root a field-level rule never binds. { slot: 'requiredWhen', slots: { requiredWhen: 'current_user.id != null' }, subject: 'reads `current_user`' }, // The `parent` gate: `fx_field` declares no `master_detail`. diff --git a/packages/metadata-protocol/src/protocol.migrate-stored-noncanonical-type.test.ts b/packages/metadata-protocol/src/protocol.migrate-stored-noncanonical-type.test.ts index 811116eb436..2e9180a5e92 100644 --- a/packages/metadata-protocol/src/protocol.migrate-stored-noncanonical-type.test.ts +++ b/packages/metadata-protocol/src/protocol.migrate-stored-noncanonical-type.test.ts @@ -192,7 +192,8 @@ const legacyObjectsPluralRow: SeedRow = { label: 'Invoice', fields: { status: { type: 'select', label: 'Status' }, - amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + amount: { type: 'currency', label: 'Amount' }, + account_id: { type: 'lookup', label: 'Account', reference_to: 'crm_account' }, }, }, }; @@ -299,8 +300,8 @@ describe('migrateStoredMetadata — a non-canonical stored `type` is reported, n expect(report.pending).toBe(1); const row = report.rows[0]!; expect(row).toMatchObject({ type: 'object', name: 'crm_invoice', outcome: 'pending' }); - expect(row.notices[0]!.from).toBe('conditionalRequired'); - expect(row.notices[0]!.to).toBe('requiredWhen'); + expect(row.notices[0]!.from).toBe('reference_to'); + expect(row.notices[0]!.to).toBe('reference'); }); it('⛔ an apply run writes NOTHING for it — the identity move stays unruled', async () => { diff --git a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts index bba8a3b8dd5..60b999ff699 100644 --- a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts @@ -95,9 +95,11 @@ function makeStubEngine( return { engine, rows, registered }; } -// A protocol-≤16 object row: the `conditionalRequired` alias was removed from -// the spec in 17 (#3855) and its conversion is `retiredFromLoadPath` — the -// authored load seam refuses it, but the stored seam must keep lowering it. +// A protocol-17 object row: the `reference_to` alias was renamed in 17 and its +// conversion (`field-reference-to-alias`, retired from the load path after +// 17.2.0) is `retiredFromLoadPath` — the authored load seam refuses it, but +// the stored seam must keep lowering it. (Until #25767 this row rode the +// ≤16.x `conditionalRequired` kit, aged out of the registry since.) const legacyObjectRow = { type: 'object', name: 'crm_invoice', @@ -106,12 +108,18 @@ const legacyObjectRow = { label: 'Invoice', fields: { status: { type: 'select', label: 'Status', options: [{ label: 'Sent', value: 'sent' }] }, - amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + amount: { type: 'currency', label: 'Amount' }, + account_id: { type: 'lookup', label: 'Account', reference_to: 'crm_account' }, }, }, }; -// A pre-17 standalone action row still carrying the removed `execute` alias. +// The retired `aria` block the legacy action row still carries +// (`action-aria-removed`, 17.4.0; the ≤16.x `execute` alias it rode until +// #25767 aged out of the registry). +const RETIRED_ACTION_ARIA = { label: 'Convert the invoice' }; + +// A protocol-17 standalone action row still carrying the removed `aria` block. // NOTE: this once wrote `object: 'crm_invoice'`. `ActionSchema` has never // declared `object` — the key is `objectName` — and `.strip` ate it, so the // fixture read as a claim about real legacy data while being a typo. Closing @@ -120,26 +128,29 @@ const legacyObjectRow = { const legacyActionRow = { type: 'action', name: 'convert', - metadata: { name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', execute: 'convertHandler' }, + metadata: { + name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', target: 'convertHandler', + aria: RETIRED_ACTION_ARIA, + }, }; describe('getMetaItems — stored rows are served canonical (#3903)', () => { - it('lowers the retired conditionalRequired alias on a stored object row', async () => { + it('lowers the retired reference_to alias on a stored object row', async () => { const { engine } = makeStubEngine([legacyObjectRow]); const protocol = new ObjectStackProtocolImplementation(engine); const res = await protocol.getMetaItems({ type: 'object' }); const obj = (res.items as any[]).find((i) => i.name === 'crm_invoice'); - expect(obj.fields.amount.requiredWhen).toBe("record.status == 'sent'"); - expect('conditionalRequired' in obj.fields.amount).toBe(false); + expect(obj.fields.account_id.reference).toBe('crm_account'); + expect('reference_to' in obj.fields.account_id).toBe(false); }); - it('lowers the retired execute alias on a stored action row', async () => { + it('strips the retired aria block on a stored action row', async () => { const { engine } = makeStubEngine([legacyActionRow]); const protocol = new ObjectStackProtocolImplementation(engine); const res = await protocol.getMetaItems({ type: 'action' }); const action = (res.items as any[]).find((i) => i.name === 'convert'); expect(action.target).toBe('convertHandler'); - expect('execute' in action).toBe(false); + expect('aria' in action).toBe(false); }); it('deliberately does NOT convert flow rows — that seam is registerFlow (the engine canonicalizes flows)', async () => { @@ -221,7 +232,7 @@ describe('getMetaItem — single stored read is canonical (#3903)', () => { const { engine } = makeStubEngine([legacyObjectRow]); const protocol = new ObjectStackProtocolImplementation(engine); const res: any = await protocol.getMetaItem({ type: 'object', name: 'crm_invoice' }); - expect(res.item.fields.amount.requiredWhen).toBe("record.status == 'sent'"); + expect(res.item.fields.account_id.reference).toBe('crm_account'); // Pre-#3903 this row validated RAW: the tombstoned alias made every // legacy row read as invalid metadata. Converted first, it is valid. expect(res.item._diagnostics?.valid).toBe(true); @@ -239,8 +250,8 @@ describe('loadMetaFromDb — boot hydration converts, diagnoses, never drops (#3 expect(res).toEqual({ loaded: 2, errors: 0, invalid: 0, storeUnavailable: false }); const obj = registered.find((r) => r.kind === 'object')!; - expect(obj.body.fields.amount.requiredWhen).toBe("record.status == 'sent'"); - expect('conditionalRequired' in obj.body.fields.amount).toBe(false); + expect(obj.body.fields.account_id.reference).toBe('crm_account'); + expect('reference_to' in obj.body.fields.account_id).toBe(false); const action = registered.find((r) => r.kind === 'item' && r.type === 'action')!; expect(action.body.target).toBe('convertHandler'); diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index 8e70ceb3f5f..a86e6e095b3 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -127,10 +127,14 @@ const historyRows = (tables: Map[]>) => tables.get('sys_metadata_history') ?? []; /** - * A protocol-≤16 object row: `conditionalRequired` was removed from the spec in - * 17 (#3855) and its conversion is `retiredFromLoadPath` — the authored load - * seam refuses it, the stored seam keeps lowering it, and this pass persists - * the lowering. + * A protocol-17 object row: the field key `reference_to` was renamed to + * `reference` in 17 and its conversion (`field-reference-to-alias`, retired + * from the load path after 17.2.0) is `retiredFromLoadPath` — the authored + * load seam refuses it, the stored seam keeps lowering it, and this pass + * persists the lowering. (Until #25767 this row rode the ≤16.x + * `conditionalRequired` → `requiredWhen` kit; that kit aged out of the + * registry with the one-major rule, so the row rides a 17.x kit now — the + * seam under test is the same.) */ const legacyObjectRow = { type: 'object', @@ -140,7 +144,8 @@ const legacyObjectRow = { label: 'Invoice', fields: { status: { type: 'select', label: 'Status', options: [{ label: 'Sent', value: 'sent' }] }, - amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + amount: { type: 'currency', label: 'Amount' }, + account_id: { type: 'lookup', label: 'Account', reference_to: 'crm_account' }, }, }, }; @@ -159,11 +164,17 @@ const canonicalObjectRow = { }, }; -/** A pre-17 standalone action row still carrying the removed `execute` alias. */ +/** The retired `aria` block the legacy action row still carries (`action-aria-removed`, 17.4.0). */ +const RETIRED_ACTION_ARIA = { label: 'Convert the invoice' }; + +/** A protocol-17 standalone action row still carrying the removed `aria` block. */ const legacyActionRow = { type: 'action', name: 'convert', - metadata: { name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', execute: 'convertHandler' }, + metadata: { + name: 'convert', label: 'Convert', type: 'script', objectName: 'crm_invoice', target: 'convertHandler', + aria: RETIRED_ACTION_ARIA, + }, }; describe('migrateStoredMetadata — preview (#4327)', () => { @@ -195,8 +206,8 @@ describe('migrateStoredMetadata — preview (#4327)', () => { const row = report.rows[0]!; expect(row).toMatchObject({ type: 'object', name: 'crm_invoice', outcome: 'pending', state: 'active' }); expect(row.notices.length).toBeGreaterThan(0); - expect(row.notices[0]!.from).toBe('conditionalRequired'); - expect(row.notices[0]!.to).toBe('requiredWhen'); + expect(row.notices[0]!.from).toBe('reference_to'); + expect(row.notices[0]!.to).toBe('reference'); // An already-canonical row is counted, never itemised — otherwise a // healthy deployment's report is a wall of rows that need nothing. expect(report.rows.every((r) => r.outcome !== 'canonical')).toBe(true); @@ -221,8 +232,8 @@ describe('migrateStoredMetadata — apply (#4327)', () => { expect(storedMigrationClean(report)).toBe(true); const stored = JSON.parse(metaRows(tables)[0]!.metadata); - expect(stored.fields.amount.requiredWhen).toBe("record.status == 'sent'"); - expect('conditionalRequired' in stored.fields.amount).toBe(false); + expect(stored.fields.account_id.reference).toBe('crm_account'); + expect('reference_to' in stored.fields.account_id).toBe(false); }); it('goes through the normal write path — history row, fresh checksum, migrate-stored source', async () => { @@ -316,7 +327,7 @@ describe('migrateStoredMetadata — apply (#4327)', () => { // Non-destructive: the stored bytes are exactly as they were. const stored = metaRows(tables).find((r) => r.id === actionRow.id)!; - expect(JSON.parse(stored.metadata).execute).toBe('convertHandler'); + expect(JSON.parse(stored.metadata).aria).toEqual(RETIRED_ACTION_ARIA); }); it('a driver seam that cannot answer refuses the pass before any row is written', async () => { @@ -336,7 +347,7 @@ describe('migrateStoredMetadata — apply (#4327)', () => { const report = await protocol.migrateStoredMetadata({ apply: true }); expect(report.scanned).toBe(0); - expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.amount.conditionalRequired).toBeDefined(); + expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.account_id.reference_to).toBeDefined(); }); it('restricts to the requested types', async () => { @@ -580,7 +591,7 @@ describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', metadata: { name: 'corrupt_thing', label: 'Corrupt', - fields: { amount: { type: 'currency', conditionalRequired: 'x' } }, + fields: { account_id: { type: 'lookup', reference_to: 'x' } }, // an off-contract key the schema rejects and no conversion lowers listViews: 42, }, @@ -594,7 +605,7 @@ describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', expect(report.rewritten).toBe(0); expect(report.rows[0]!.reason).toMatch(/failed spec validation/); expect(historyRows(tables)).toHaveLength(0); - expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.amount.conditionalRequired).toBe('x'); + expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.account_id.reference_to).toBe('x'); }); it('marks a legacy row whose select has no option source `failed` and leaves its bytes alone', async () => { @@ -620,7 +631,7 @@ describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', expect(historyRows(tables)).toHaveLength(0); const stored = JSON.parse(metaRows(tables)[0]!.metadata); expect(stored.fields.status).toEqual({ type: 'select', label: 'Status' }); - expect(stored.fields.amount.conditionalRequired).toBe("record.status == 'sent'"); + expect(stored.fields.account_id.reference_to).toBe('crm_account'); }); it('does not clobber a row a concurrent writer moved — the optimistic lock is real', async () => { @@ -673,7 +684,7 @@ describe('formatStoredMigrationReport (#4327)', () => { const protocol = new ObjectStackProtocolImplementation(engine); const text = formatStoredMigrationReport(await protocol.migrateStoredMetadata()).join('\n'); expect(text).toMatch(/object\/crm_invoice \[env-wide\]/); - expect(text).toMatch(/conditionalRequired → requiredWhen/); + expect(text).toMatch(/reference_to → reference/); }); }); diff --git a/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts b/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts index 17638f54457..4a081af23fe 100644 --- a/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-residue-resave.test.ts @@ -185,7 +185,9 @@ describe('#8421 — `migrate meta --stored` and a residue row', () => { name: 'crm_invoice', label: 'Invoice', fields: { - amount: { type: 'currency', label: 'Amount', conditionalRequired: "record.status == 'sent'" }, + amount: { type: 'currency', label: 'Amount' }, + // `field-reference-to-alias` (17.2.0): the stored seam still lowers it (#25767 retired the ≤16.x kits). + account_id: { type: 'lookup', label: 'Account', reference_to: 'crm_account' }, }, }, }]); @@ -195,7 +197,7 @@ describe('#8421 — `migrate meta --stored` and a residue row', () => { expect(report).toMatchObject({ scanned: 2, rewritten: 1, failed: 0 }); const invoice = metaRows(tables).find((r) => r.name === 'crm_invoice')!; - expect(JSON.parse(invoice.metadata).fields.amount.requiredWhen).toBe("record.status == 'sent'"); + expect(JSON.parse(invoice.metadata).fields.account_id.reference).toBe('crm_account'); }); }); From 87883f052f828a98e54f3b3e8cecd957d19403ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:17:33 +0000 Subject: [PATCH 04/22] =?UTF-8?q?test(metadata-protocol,service-automation?= =?UTF-8?q?,objectql):=20the=20remaining=20pins=20on=20the=20retired=20?= =?UTF-8?q?=E2=89=A416.x=20kits=20ride=2017.x=20kits=20or=20the=20strict?= =?UTF-8?q?=20refusal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running every package whose sources read the conversion registry in full found the rest of the pins that rode a kit the previous commits removed: - packages/metadata-protocol: the served-schema property counts (#17501) move down by exactly the top-level tombstones that left each shape (action 3, agent 2, app 8, dashboard 2, field 1, flow 2, skill 1); the #17502 repeater-row pin moves from the dashboard widget row, which has no tombstone left, to the page component row (`visibility`, `responsive`, 17.x kits), with the dashboard keeping its depth pin through `refreshInterval` and the widget `chartConfig`; the aged-out widget column is pinned as the strict refusal it is now; the #21982 fallback pin rides `errorHandling.retryDelayMs` (`retry-policy-converged`, 17.7.0) instead of `flow.active`, and `flow.active` is pinned refused on both hosts, naming the upgrade guide. - packages/services/service-automation: the script `actionType` stubs are refused at registration as unknown keys naming the guide (nothing strips them on rehydration any more), the fault-edge guard rides the bare contract, and the wait-node round trip holds the `timeout` guidance channel, with the aged-out `timeoutMs` pinned as the strict refusal. - packages/objectql: the rule validator pin shows the chain no longer lowers the aged-out `conditionalRequired` alias and enforces the rule on the canonical `requiredWhen` only. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- ...l.meta-types-degenerate-derivation.test.ts | 19 ++- ...ol.meta-types-unauthorable-columns.test.ts | 124 +++++++++++++----- ...rotocol.save-flow-canonicalization.test.ts | 33 ++++- .../src/validation/rule-validator.test.ts | 26 +++- .../src/builtin/config-parse.test.ts | 37 ++++-- .../src/builtin/screen-nodes.test.ts | 32 +++-- .../src/builtin/wait-node.test.ts | 40 +++--- 7 files changed, 223 insertions(+), 88 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index d1c3b130754..bf9875ed5a4 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -248,7 +248,12 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // is named in the sample below so the served schema is held to // carrying it, not merely to having one more key than before. // [#21795] 49 → 50: `requiresMembershipReach` joined the accepted set. - expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(50); + // [#25767] 50 → 47: the three keys that admitted no instance were ≤16.x + // retirements, and a retirement kit lives one protocol major past the + // major it retired in — at 18 they left the declared set with their + // tombstones. The declared total shrinks by exactly those three; the + // live sample below still holds every live key. + expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(47); // A sample an author would actually address, and the one #17500's // repeater titles need a node to sit on. for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach']) { @@ -327,9 +332,17 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no * when it gained `requiredPermissions` (who may start the flow, checked at * every start door). */ + // [#25767] The ≤16.x tombstones aged out of the declared sets at 18 (a kit + // lives one major past the major it retired in), so each count below moved + // DOWN by exactly the top-level tombstones that left its shape: `agent` + // 26 → 24 (`tools`, `knowledge`), `app` 30 → 22 (`version`, `homePageId`, + // `objects`, `apis`, `sharing`, `embed`, `mobileNavigation`, `aria`), + // `dashboard` 22 → 20 (`aria`, `performance`), `field` 80 → 79 + // (`conditionalRequired`), `flow` 24 → 22 (`template`, `active`), `skill` + // 17 → 16 (`triggerPhrases`). The live sets are unchanged. const CARD_PROPERTY_COUNTS: Record = { - agent: 26, app: 30, dashboard: 22, dataset: 16, field: 80, flow: 24, - hook: 22, object: 47, page: 26, position: 13, report: 21, skill: 17, tool: 14, + agent: 24, app: 22, dashboard: 20, dataset: 16, field: 79, flow: 22, + hook: 22, object: 47, page: 26, position: 13, report: 21, skill: 16, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts b/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts index 5946110fe78..d009f1bb390 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-unauthorable-columns.test.ts @@ -17,9 +17,16 @@ * `saveMetaItem` then refuses. * * Measured on `origin/main` at 74eaab8614 over the whole served registry: - * **80 tombstone nodes across 16 types**. Five of them are `dashboard.widgets[]`'s + * **80 tombstone nodes across 16 types**. Five of them were `dashboard.widgets[]`'s * `actionUrl`, `actionType`, `actionIcon`, `responsive` and `aria` — the row - * this file pins, and the carrier the card was filed on. ⚠️ They are not the + * this file pinned, and the carrier the card was filed on. [#25767] Those five + * were ≤16.x retirements, and a retirement kit lives one protocol major past + * the major it retired in: at 18 they left the shape with their tombstones, so + * the widget row carries nothing to strip any more. The row pin moved to + * `page.regions[].components[]`, whose `visibility` (→ `visibleWhen`, 17.7.0) + * and `responsive` (removed, 17.2.0) tombstones are 17.x kits still on a + * repeater row; `dashboard` keeps the depth pin through `refreshInterval` and + * the five tombstones inside the widget's `chartConfig`. ⚠️ They are not the * whole reachable set: a repeater row in another type reaches an author the * same way, as does the flat schema-driven fallback for a layout-less type and * an inspector that grafts server-only properties into a "More fields" section. @@ -212,10 +219,33 @@ function widgetRow(served: Record | undefined): Record; } -/** The five columns the parse door refuses (`ui/dashboard.zod.ts` tombstones). */ -const RETIRED_WIDGET_COLUMNS = ['actionUrl', 'actionType', 'actionIcon', 'responsive', 'aria'] as const; +/** `page.regions[].components[]`'s row shape, off the served document. */ +function componentRow(served: Record | undefined): Record { + const resolve = (node: any): any => (typeof node?.$ref === 'string' + ? (served as any)?.$defs?.[String(node.$ref).replace('#/$defs/', '')] + : node); + const regions = resolve((served?.properties as any)?.regions); + const region = resolve(regions?.items); + const components = resolve(region?.properties?.components); + const component = resolve(components?.items); + return (component?.properties ?? {}) as Record; +} + +/** + * The two columns the parse door refuses on the pinned repeater row + * (`ui/page.zod.ts` tombstones: `visibility` → `visibleWhen` at 17.7.0, + * `responsive` removed at 17.2.0). [#25767] The five `dashboard.widgets[]` + * columns this file pinned before aged out of the shape with the ≤16.x kits. + */ +const RETIRED_COMPONENT_COLUMNS = ['visibility', 'responsive'] as const; -/** The live columns that must survive beside them — the lit control. */ +/** The live component columns that must survive beside them — the lit control. */ +const LIVE_COMPONENT_COLUMNS = [ + 'aria', 'className', 'dataSource', 'events', 'id', 'label', 'properties', + 'responsiveStyles', 'style', 'type', 'visibleWhen', +] as const; + +/** The widget row's live columns — still the lit control there, with nothing left to strip beside them. */ const LIVE_WIDGET_COLUMNS = [ 'chartConfig', 'colorVariant', 'compareTo', 'dataset', 'description', 'dimensions', 'filter', 'filterBindings', 'id', 'layout', 'options', 'requiresObject', @@ -223,16 +253,29 @@ const LIVE_WIDGET_COLUMNS = [ ] as const; describe('#17502 — the served repeater row offers no column the parse door refuses', () => { - it('control: the pre-strip derivation really did carry the five tombstone columns', () => { + it('control: the pre-strip derivation really did carry the two tombstone columns', () => { // Without this, both halves below could pass over a payload that never // had the nodes — and the pin would be green on `origin/main` too. - const row = widgetRow(preStripDerivation('dashboard')); - for (const key of RETIRED_WIDGET_COLUMNS) { - expect(Object.keys(row), `pre-strip dashboard.widgets row declares ${key}`).toContain(key); + const row = componentRow(preStripDerivation('page')); + for (const key of RETIRED_COMPONENT_COLUMNS) { + expect(Object.keys(row), `pre-strip page.regions[].components row declares ${key}`).toContain(key); expect(acceptsNothing((row as any)[key]), `${key} is a node that admits nothing`).toBe(true); expect(String((row as any)[key].description)).toMatch(/^\[REMOVED\] /); } - expect(Object.keys(row).length).toBe(22); + expect(Object.keys(row).length).toBe(13); + }); + + it('[#25767] control: the former carrier, the pre-strip `dashboard.widgets` row, carries no tombstone any more', () => { + // The five ≤16.x tombstones left the shape with their kits, so the + // derivation and the served payload agree on this row without a strip. + const row = widgetRow(preStripDerivation('dashboard')); + expect(Object.keys(row).sort()).toEqual([...LIVE_WIDGET_COLUMNS].sort()); + expect(Object.entries(row).filter(([, node]) => acceptsNothing(node)).map(([k]) => k)).toEqual([]); + }); + + it('lit: the served `page.regions[].components` row still carries every live column', async () => { + const row = componentRow((await servedSchemas()).get('page')); + expect(Object.keys(row).sort()).toEqual([...LIVE_COMPONENT_COLUMNS].sort()); }); it('lit: the served `dashboard.widgets` row still carries every live column', async () => { @@ -240,10 +283,10 @@ describe('#17502 — the served repeater row offers no column the parse door ref expect(Object.keys(row).sort()).toEqual([...LIVE_WIDGET_COLUMNS].sort()); }); - it('dark: the five retired columns are gone from the served row', async () => { - const row = widgetRow((await servedSchemas()).get('dashboard')); - for (const key of RETIRED_WIDGET_COLUMNS) { - expect(Object.keys(row), `dashboard.widgets must not offer ${key}`).not.toContain(key); + it('dark: the two retired columns are gone from the served component row', async () => { + const row = componentRow((await servedSchemas()).get('page')); + for (const key of RETIRED_COMPONENT_COLUMNS) { + expect(Object.keys(row), `page.regions[].components must not offer ${key}`).not.toContain(key); } }); @@ -304,15 +347,14 @@ describe('#17502 — the served repeater row offers no column the parse door ref // Non-vacuity: without this the two assertions above pass over a ledger // that read nothing at all. `dashboard` is the type that exercises every - // depth — the five repeater-row columns pinned above, three top-level - // tombstones, and FIVE inside the widget's own `chartConfig` — so the - // ledger is proven to reach a row shape and not only the surface. + // depth — one top-level tombstone and FIVE inside the widget's own + // `chartConfig` — so the ledger is proven to reach a row shape and not + // only the surface. [#25767] The five widget-row columns and the + // top-level `aria` / `performance` left this list with the ≤16.x kits; + // the repeater-row depth is pinned on `page` right below instead. // Sorted, so key ORDER is not what is pinned. expect([...(removedByType.get('dashboard') ?? [])].sort()).toEqual([ - ...RETIRED_WIDGET_COLUMNS.map((k) => `dashboard.properties.widgets.items.properties.${k}`), 'dashboard.properties.refreshInterval', - 'dashboard.properties.aria', - 'dashboard.properties.performance', // [#17751] `ChartConfigSchema.aria` retired one level deeper than the // widget row, inside `chartConfig`. 'dashboard.properties.widgets.items.properties.chartConfig.properties.aria', @@ -327,6 +369,11 @@ describe('#17502 — the served repeater row offers no column the parse door ref (k) => `dashboard.properties.widgets.items.properties.chartConfig.properties.${k}`, ), ].sort()); + // [#25767] The repeater-row depth: the two component-row columns pinned + // above are among what the strip took away from `page`. + expect(removedByType.get('page') ?? []).toEqual(expect.arrayContaining( + RETIRED_COMPONENT_COLUMNS.map((k) => `page.properties.regions.items.properties.components.items.properties.${k}`), + )); }); // ⚠️ This control derives with zod's DEFAULT (output) arm only, which is @@ -352,28 +399,43 @@ describe('#17502 — the served repeater row offers no column the parse door ref describe('#17502 — the removal is payload-only: every prescription channel survives', () => { it('the parse still refuses the key with the tombstone prescription, byte for byte', async () => { - const dashboard = getMetadataTypeSchema('dashboard') as z.ZodTypeAny; - const result = dashboard.safeParse({ - name: 'ops', label: 'Ops', - widgets: [{ id: 'w1', type: 'metric', actionUrl: '/x' }], + const page = getMetadataTypeSchema('page') as z.ZodTypeAny; + const result = page.safeParse({ + name: 'ops_page', label: 'Ops', type: 'app', + regions: [{ name: 'main', components: [{ id: 'c1', type: 'object-grid', responsive: { xsmall: { columns: 1 } } }] }], } as never); - expect(result.success, 'a retired widget column is still refused at publish').toBe(false); - const issue = result.error!.issues.find((i) => i.path[i.path.length - 1] === 'actionUrl'); + expect(result.success, 'a retired component column is still refused at publish').toBe(false); + const issue = result.error!.issues.find((i) => i.path[i.path.length - 1] === 'responsive'); expect(issue, 'the refusal names the retired key').toBeDefined(); expect((issue as { expected?: string }).expected).toBe('never'); // The prescription — the FROM -> TO mapping this retirement exists to // deliver — is carried by the refusal, which the strip never touches. - expect(issue!.message).toContain('was removed in @objectstack/spec 17.0.0'); - expect(issue!.message).toContain('header: { actions:'); - expect(issue!.message).toContain('os migrate meta --from 16'); + expect(issue!.message).toContain('was removed in @objectstack/spec 17'); + expect(issue!.message).toContain('responsiveStyles'); + expect(issue!.message).toContain('os migrate meta --from 17'); + }); + + it('[#25767] an aged-out column is refused as a strict unknown key naming the upgrade guide — no prescription, no silent strip', () => { + // The former carrier's `actionUrl` has no tombstone now; the strict + // widget shape refuses it, and the sentence points at the guide that + // carries the prescription the kit used to. + const dashboard = getMetadataTypeSchema('dashboard') as z.ZodTypeAny; + const result = dashboard.safeParse({ + name: 'ops', label: 'Ops', + widgets: [{ id: 'w1', type: 'metric', actionUrl: '/x' }], + } as never); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => /Unrecognized key\(s\) on this dashboard widget: `actionUrl`/.test(i.message)); + expect(issue, JSON.stringify(result.error!.issues)).toBeDefined(); + expect(issue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('the Zod shape still declares the tombstone — nothing is un-retired upstream', () => { // `packages/spec`'s `authorable-surface/` ratchet and the generated // reference pages read this shape, not the served payload, so both keep // publishing the retirement. The strip is a property of ONE emitter. - const row = widgetRow(preStripDerivation('dashboard')); - for (const key of RETIRED_WIDGET_COLUMNS) { + const row = componentRow(preStripDerivation('page')); + for (const key of RETIRED_COMPONENT_COLUMNS) { expect(Object.keys(row)).toContain(key); } }); diff --git a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts index 7f16b9d1821..3f57dcd8fcc 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -450,19 +450,38 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { expect(storedFlow(rows)!.body.nodes[0].config).toEqual({ functionName: 'helpdesk.triage' }); }); - it('an older-retired flow shape (`flow.active`) reaches the canonicalizer\'s verdict too — healed for the verdict, stored raw', async () => { - // `flow-inert-keys-removed` (retired from the load path after 16.1.0) - // tombstones `active`. On a host with no automation service the - // fallback used to judge it in the authoring posture and refuse it - // 422; the service-backed path replays the entry and heals it. Since - // #23487 the fallback replays the same chain, so both hosts agree. + it('an older-retired flow shape (`errorHandling.retryDelayMs`) reaches the canonicalizer\'s verdict too — healed for the verdict, stored raw', async () => { + // `retry-policy-converged` (retired from the load path after 17.7.0) + // renames `errorHandling.retryDelayMs` → `backoffMs`, and the strict + // block refuses the old spelling. On a host with no automation + // service the fallback used to judge it in the authoring posture and + // refuse it 422; the service-backed path replays the entry and heals + // it. Since #23487 the fallback replays the same chain, so both + // hosts agree. ([#25767] This pin rode `flow.active` and + // `flow-inert-keys-removed`, a ≤16.x kit, until the one-major rule + // retired that kit; the aged-out spelling is pinned right below.) const { protocol, rows, engine } = makeProtocol(new Map()); - const body = { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), active: false }; + const body = { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), errorHandling: { retryDelayMs: 1000 } }; await seedStoredFlow(engine, body); const result = await save(protocol, body); expect(result.success).toBe(true); + expect(storedFlow(rows)!.body.errorHandling).toEqual({ retryDelayMs: 1000 }); + }); + + it('[#25767] a spelling whose kit aged out (`flow.active`, retired at or below 16.x) is refused on a stored row too — no step of the chain replays it', async () => { + // The kit left the registry one major past its retirement, so the + // replay heals nothing here: the strict flow shape refuses the key + // as unknown, naming the protocol upgrade guide, on either host. + const { protocol, rows, engine } = makeProtocol(new Map()); + const body = { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), active: false }; + await seedStoredFlow(engine, body); + + await expect(save(protocol, body)).rejects.toThrow( + /Unrecognized key\(s\) on this flow: `active`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/, + ); + // The refused re-save wrote nothing: the row is the one seeded. expect(storedFlow(rows)!.body.active).toBe(false); }); diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index e538fb7d652..85f72e8b407 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -71,20 +71,36 @@ describe('field requiredWhen enforcement (B2)', () => { expect(() => evaluateValidationRules(invoiceFields, { amount: 25 }, 'update', { previous: { status: 'sent' } })).not.toThrow(); }); // #3903 — the `conditionalRequired` alias read is RETIRED. Stored pre-17 - // rows are lowered to `requiredWhen` by the ADR-0087 chain at rehydration - // (`applyConversionsToStoredItem`), so this validator only ever sees the + // rows were lowered to `requiredWhen` by the ADR-0087 chain at rehydration + // (`applyConversionsToStoredItem`), so this validator only ever saw the // canonical key; raw legacy input no longer gets a consumer-side fallback. + // [#25767] That kit was a ≤16.x retirement, and a retirement kit lives one + // protocol major past the major it retired in: at 18 the chain lowers + // nothing here any more. A row still carrying the alias is refused at the + // strict parse door (naming the protocol upgrade guide) and is for + // `os migrate meta --stored` on a release that still carried the step; this + // validator keeps ignoring the alias either way. it('does NOT read the retired conditionalRequired alias (PD #12 — no dialect fallback)', () => { const f = { fields: { amount: { type: 'currency', conditionalRequired: "record.status == 'sent'" } } } as any; expect(() => evaluateValidationRules(f, { status: 'sent' }, 'insert')).not.toThrow(); }); - it('enforces the rule once the stored-conversion chain has lowered the alias', async () => { + it('[#25767] the stored-conversion chain no longer lowers the aged-out alias — the rule is enforced on the canonical key only', async () => { const { applyConversionsToStoredItem } = await import('@objectstack/spec'); - const storedRow = { + const legacyRow = { name: 'invoice', fields: { amount: { type: 'currency', conditionalRequired: "record.status == 'sent'" } }, }; - const converted = applyConversionsToStoredItem('object', storedRow) as any; + const untouched = applyConversionsToStoredItem('object', legacyRow) as any; + // No step of the chain replays the ≤16.x kit: the alias is neither lowered + // nor read, so the rule it once spelled is not enforced from this row. + expect(untouched.fields.amount).toEqual({ type: 'currency', conditionalRequired: "record.status == 'sent'" }); + expect(() => evaluateValidationRules(untouched, { status: 'sent' }, 'insert')).not.toThrow(); + // The canonical spelling is what the chain hands this validator, and it is enforced. + const canonicalRow = { + name: 'invoice', + fields: { amount: { type: 'currency', requiredWhen: "record.status == 'sent'" } }, + }; + const converted = applyConversionsToStoredItem('object', canonicalRow) as any; expect(converted.fields.amount.requiredWhen).toBe("record.status == 'sent'"); expect(() => evaluateValidationRules(converted, { status: 'sent' }, 'insert')).toThrow(ValidationError); }); diff --git a/packages/services/service-automation/src/builtin/config-parse.test.ts b/packages/services/service-automation/src/builtin/config-parse.test.ts index 232802ad7d0..c4e4f109efc 100644 --- a/packages/services/service-automation/src/builtin/config-parse.test.ts +++ b/packages/services/service-automation/src/builtin/config-parse.test.ts @@ -296,29 +296,38 @@ describe('execute-time config parse (#4277)', () => { }); it('script refuses a retired email stub — it used to log a line and report success', async () => { - const engine = engineWith(); - // `registerFlow` strips the retired keys on rehydration (#3903), so what - // reaches the parse is a node with nothing to run. Before #4343 this was a - // green step that delivered no mail. Since #20316 the flow parse behind - // that conversion refuses the stripped node itself — it names no - // `function` — so the flow no longer registers; the run below meets the - // same stripped shape past the doors. - expect(doorRefusal('script', { + // `registerFlow` used to strip the retired keys on rehydration (#3903), so + // what reached the parse was a node with nothing to run. Before #4343 this + // was a green step that delivered no mail; since #20316 the flow parse + // behind that conversion refused the stripped node for naming no + // `function`. [#25767] The `actionType` stubs were ≤16.x retirements, and a + // retirement kit lives one protocol major past the major it retired in: + // their conversion left the registry at 18, so nothing strips the keys any + // more — the strict script shape refuses them outright, naming each key + // and the protocol upgrade guide, and the flow no longer registers. Same + // verdict (no mail is ever sent silently), one door earlier again. + const refusal = doorRefusal('script', { actionType: 'email', template: 'task_done', recipients: ['{record.owner}'], - })).toContain("config leaves out `function`"); - const result = await runStripped(engine, 'script', { + }); + expect(refusal).toMatch(/Unrecognized key\(s\) on this script node config: `actionType`, `template`, `recipients`/); + expect(refusal).toContain('https://objectstack.ai/docs/protocol-upgrade'); + // Naming a callable beside the stub does not rescue it: the retired keys + // are refused, not stripped around `function`. + expect(doorRefusal('script', { function: 'send_mail', actionType: 'email', template: 'task_done', recipients: ['{record.owner}'], - }, ['function']); - expect(result.success).toBe(false); - expect(result.error).toContain('does not satisfy the script contract'); + })).toMatch(/Unrecognized key\(s\) on this script node config: `actionType`, `template`, `recipients`/); }); it('a script parse refusal is a guard — a fault edge does NOT route it', async () => { const engine = engineWith(); + // [#25767] The retired `actionType` / `template` keys no longer ride along + // (they are refused at registration, above), so the guard is exercised on + // the bare contract: the registered node loses its `function` past the + // doors and the execute-time parse refuses it. const result = await runStripped( engine, 'script', - { function: 'post_to_slack', actionType: 'slack', template: 't' }, + { function: 'post_to_slack' }, ['function'], { nodes: [{ id: 'recover', type: 'assignment', label: 'R', config: { recovered: true } }], diff --git a/packages/services/service-automation/src/builtin/screen-nodes.test.ts b/packages/services/service-automation/src/builtin/screen-nodes.test.ts index d9905bd6cb7..90c2f8e4fbd 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.test.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.test.ts @@ -144,6 +144,15 @@ it('canonicalizes a stored `functionName` key to `function` at load (#1870 DX, # * script node that leaves it out — so the stored flow no longer REGISTERS (at * boot: skipped with a `failed to register flow` warn naming it), rather than * registering and refusing at run time. Same verdict, named the same way. + * + * [#25767] The `actionType` branches were ≤16.x retirements, and a retirement + * kit lives one protocol major past the major it retired in: at 18 their + * tombstones and their conversion left the registry together. A stored flow + * still carrying one meets neither channel now — the strict script shape + * refuses the key as unknown, naming the protocol upgrade guide (which carries + * the prescription the kit used to), and the row is for `os migrate meta` on a + * release that still carried the step. The `function`-less refusal below is + * unchanged; the shorthand-to-`function` move is gone with the kit. */ describe('script retired branches, as a stored flow meets them (#4343)', () => { let engine: AutomationEngine; @@ -165,13 +174,15 @@ describe('script retired branches, as a stored flow meets them (#4343)', () => { expect(await engine.getFlow('script_flow')).toBeNull(); }); - it('converts a shorthand `actionType` into the function it always named, and runs it', async () => { + it('[#25767] a stored flow carrying the aged-out `actionType` shorthand no longer registers — refused naming the key and the upgrade guide', async () => { // The one retired value carrying real intent: `actionType` that matched - // no built-in was a function name (#1870), so the conversion moves it - // rather than dropping it — and the node keeps working. - engine.registerFlow('script_flow', scriptFlow({ actionType: 'score_lead' })); - const result = await engine.execute('script_flow', {} as any); - expect(result.success).toBe(true); + // no built-in was a function name (#1870), and the ≤16.x conversion + // moved it to `function`. That kit aged out of the registry, so no step + // of the chain replays it: the key is refused outright, and the flow is + // never registered. + expect(() => engine.registerFlow('script_flow', scriptFlow({ actionType: 'score_lead' }))) + .toThrow(/Unrecognized key\(s\) on this script node config: `actionType`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); + expect(await engine.getFlow('script_flow')).toBeNull(); }); it('accepts the converged shape', async () => { @@ -180,13 +191,16 @@ describe('script retired branches, as a stored flow meets them (#4343)', () => { expect(result.success).toBe(true); }); - it('the tombstones still refuse an AUTHORED key outright, prescription and all', () => { + it('the strict shape still refuses an AUTHORED retired key outright — as an unknown key naming the upgrade guide', () => { // The other channel: no conversion runs in front of a direct parse, so // this is what `tsc` and `os validate` put in front of an author. + // [#25767] The tombstone's own prescription aged out with the kit; the + // refusal now names the key and the guide that carries the prescription. const result = ScriptConfigSchema.safeParse({ function: 'score_lead', actionType: 'email' }); expect(result.success).toBe(false); - expect(result.error!.issues[0]!.message).toMatch(/`script\.config\.actionType` was removed/); - expect(result.error!.issues[0]!.message, 'the prescription teaches the replacement, never a tracker id') + expect(result.error!.issues[0]!.message).toMatch(/Unrecognized key\(s\) on this script node config: `actionType`/); + expect(result.error!.issues[0]!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(result.error!.issues[0]!.message, 'the refusal teaches where the prescription lives, never a tracker id') .not.toMatch(/#\d{3,5}/); expect(ScriptConfigSchema.parse({ function: 'score_lead', diff --git a/packages/services/service-automation/src/builtin/wait-node.test.ts b/packages/services/service-automation/src/builtin/wait-node.test.ts index ac4c65eb0b8..7b7f8a5dc8c 100644 --- a/packages/services/service-automation/src/builtin/wait-node.test.ts +++ b/packages/services/service-automation/src/builtin/wait-node.test.ts @@ -110,6 +110,13 @@ describe('parseIsoDuration', () => { * is EXTRACTED from the live message, never compared to a copy: a hard-coded * `'60000'` would go green the moment someone reworded the prose, which is * exactly when this needs to be checked. + * + * [#25767] The `timeoutMs` tombstone was a ≤16.x retirement, and a retirement + * kit lives one protocol major past the major it retired in: at 18 it left the + * shape, so `timeoutMs` is a strict unknown key whose prescription lives in the + * protocol upgrade guide, which this package does not read. The `timeout` + * misspelling's `guidance` entry is the channel still printing a + * `timerDuration` value here, so it is the one held to the round trip. */ describe("the spec's `timeoutMs` → `timerDuration` prescription round-trips (#6758)", () => { const waitNode = (waitEventConfig: Record) => ({ @@ -122,13 +129,13 @@ describe("the spec's `timeoutMs` → `timerDuration` prescription round-trips (# }; it('every `timerDuration` it prints parses AND yields the wait it promises', () => { - // Channel 1 — the tombstone itself. Channel 2 — the `timeout` misspelling's - // `guidance` entry, which repeats the same advice to an author who has had - // no first failure to learn from. - const tombstone = issueMessage({ eventType: 'timer', timeoutMs: 60_000 }, 'invalid_type'); + // The `timeout` misspelling's `guidance` entry repeats the advice to an + // author who has had no first failure to learn from. ([#25767] The + // `timeoutMs` tombstone, the first channel until the ≤16.x kits aged out, + // is pinned below as the strict refusal it is now.) const guidance = issueMessage({ eventType: 'timer', timeout: 60_000 }, 'unrecognized_keys'); - for (const [channel, message] of Object.entries({ tombstone, guidance })) { + for (const [channel, message] of Object.entries({ guidance })) { // Anti-vacuity: gut either channel and this fails, rather than the loop // below passing because it found nothing to check. expect(message, `${channel}: raised no message at all`).toBeDefined(); @@ -156,20 +163,15 @@ describe("the spec's `timeoutMs` → `timerDuration` prescription round-trips (# } } - // The tombstone does not merely prescribe a value, it claims an EQUALITY: - // "`timeoutMs: N` and `timerDuration: X` the same wait". Read both sides out - // of the message and hold it to that claim. - const claimedMs = Number(/`timeoutMs:\s*(\d+)`/.exec(tombstone!)?.[1]); - expect(claimedMs, 'the tombstone must still name the `timeoutMs` wait it is equating') - .toBeGreaterThan(0); - for (const literal of [...tombstone!.matchAll(/`timerDuration:\s*([^`]+)`/g)].map((m) => m[1])) { - const authored = JSON.parse(literal.replace(/^'(.*)'$/, '"$1"')) as string; - expect( - parseIsoDuration(authored), - `the tombstone equates \`timeoutMs: ${claimedMs}\` with \`timerDuration: ${literal}\`, ` - + 'but they are not the same wait', - ).toBe(claimedMs); - } + }); + + it('[#25767] the aged-out `timeoutMs` is refused as a strict unknown key naming the upgrade guide', () => { + // Its kit (tombstone, entry, conversion) left at 18 under the one-major + // rule, so the prescription that used to equate `timeoutMs: N` with a + // `timerDuration` lives in the guide now, and the parse says where. + const message = issueMessage({ eventType: 'timer', timeoutMs: 60_000 }, 'unrecognized_keys'); + expect(message).toMatch(/`timeoutMs`/); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); }); From 65ebe0ba2031bedf6147ea6b369560b5d16d0862 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:56:11 +0000 Subject: [PATCH 05/22] =?UTF-8?q?test(runtime,service-datasource):=20the?= =?UTF-8?q?=20security-collection=20and=20restore-seam=20pins=20ride=20the?= =?UTF-8?q?=2017.x=20kits;=20the=20aged-out=20=E2=89=A416.x=20spellings=20?= =?UTF-8?q?are=20pinned=20as=20strict=20refusals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two remaining consumer suites of the conversion registry carried fixtures in the ≤16.x dialect the previous commits retired: - packages/runtime: the #12892 two-reader harness and the two real-boot files (the security registrar, the #14491 seeder declaration copy) wrote the stack's `roles`, an RLS `priority` and a `sharedWith.type: 'role'` recipient, each governed by a kit that left the registry. The fixtures now write `positions`, no `priority` and a `position` recipient; the conversion axis is measured on the 17.x kits that still reach the security collections (the `allowRestore` / `allowPurge` grant bits, `accessLevel: 'full'`); and a new harness pin shows the door refusing each aged-out spelling outright — a strict unknown key naming the upgrade guide, an invalid recipient option. The #3878 analytics-entry pins expect `query` refused as a strict unknown key naming the guide instead of the envelope's per-key prescription. - packages/services/service-datasource: the #4456 restore-seam pin shows what the seam replays now — the 17.7.0 `mongo` → `mongodb` driver alias — and that a row still in the ≤16.x config spelling (`file`, `connectionString` / `user`, `uri`) is served as stored: the row the ruling sends through `os migrate meta --stored --apply` on 18.1 or earlier. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- ...plugin-artifact-forward-conversion.test.ts | 100 +++++++++++++----- .../dispatcher-validation-error.real.test.ts | 9 +- packages/runtime/src/http-dispatcher.test.ts | 7 +- ...tandalone-stack-security-registrar.test.ts | 22 ++-- ...lone-stack-seeder-declaration-copy.test.ts | 58 +++++----- .../__tests__/datasource-admin-plugin.test.ts | 31 ++++-- 6 files changed, 149 insertions(+), 78 deletions(-) diff --git a/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts index 55682824d97..724cc284cfc 100644 --- a/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts +++ b/packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts @@ -67,10 +67,19 @@ import { AppPlugin } from './app-plugin.js'; /** * One artifact carrying a legacy/retired shape in every security collection - * the ADR-0087 registry can reach. `engines.protocol: '^17.1.0'` is the real - * incident's declared floor — below the installed `@objectstack/spec`, so the - * door's versioned window opens (the same evidence the 17.1-built hotcrm + * the ADR-0087 registry can STILL reach. `engines.protocol: '^17.1.0'` is the + * real incident's declared floor — below the installed `@objectstack/spec`, so + * the door's versioned window opens (the same evidence the 17.1-built hotcrm * artifact carries). + * + * [#25767] The kits retired at or below protocol 16.x aged out of the registry + * one major on (the `roles` → `positions` collection rename, the RLS + * `priority` tombstone, the `sharedWith.type: 'role'` recipient reconcile), so + * no conversion governs those keys any more: the door refuses each as a strict + * unknown key / invalid option naming the upgrade guide (pinned below), and + * this fixture writes them canonically. The 17.x kits — the `allowRestore` / + * `allowPurge` grant bits (17.2.0) and `accessLevel: 'full'` (17.7.0) — are + * what the conversion axis still measures. */ const ARTIFACT = { manifest: { @@ -80,9 +89,10 @@ const ARTIFACT = { version: '1.0.0', engines: { protocol: '^17.1.0' }, }, - // `roles` → `positions` is a COLLECTION-KEY rename (`stack-roles-to-positions`, - // ADR-0090 D3). The raw reader looks for `positions` and finds nothing. - roles: [{ name: 'sales_rep', label: 'Sales Rep' }], + // Canonical since #25767: the `roles` → `positions` collection-key rename + // (`stack-roles-to-positions`, ADR-0090 D3) aged out, and `roles` is a + // strict unknown key on the stack definition now. + positions: [{ name: 'sales_rep', label: 'Sales Rep' }], permissions: [ { name: 'support_agent', @@ -109,8 +119,9 @@ const ARTIFACT = { allowPurge: false, }, }, - // `priority` is a `retiredKey()` tombstone on the RLS policy - // (`permission-rls-priority-removed`). + // No `priority`: its tombstone (`permission-rls-priority-removed`) + // aged out at #25767, so the key is a strict unknown key on the + // RLS policy now, not a conversion-governed one. rowLevelSecurity: [ { name: 'own_tasks', @@ -118,7 +129,6 @@ const ARTIFACT = { operation: 'select', using: 'assignee == current_user.email', enabled: true, - priority: 10, }, ], }, @@ -129,13 +139,15 @@ const ARTIFACT = { name: 'share_open_deals', type: 'criteria', object: 'crm_deal', - // Both legacy spellings are REJECTED by the current schema: - // `accessLevel: 'full'` (→ 'edit') and the recipient type - // `'role'` (→ 'position'). A raw copy of this item is not merely - // stale — it is unparseable at the next re-validating seam. + // The legacy level is REJECTED by the current schema: + // `accessLevel: 'full'` (→ 'edit', the 17.7.0 conversion). A raw + // copy of this item is not merely stale — it is unparseable at the + // next re-validating seam. The recipient is written canonically: + // the `'role'` → `'position'` reconcile aged out at #25767, and + // `'role'` is an invalid option at the door now. accessLevel: 'full', condition: 'record.status == "open"', - sharedWith: { type: 'role', value: 'sales_mgr' }, + sharedWith: { type: 'position', value: 'sales_mgr' }, }, ], }; @@ -233,19 +245,20 @@ function diffPaths(a: any, b: any, at = ''): string[] { /** * The keys the ADR-0087 conversion layer governs on these collections — the * axis #12844 was about, enumerated from the registry - * (`packages/spec/src/conversions/registry.ts`): the two `permissions` - * entries, the two `sharingRules` entries, and the `roles` -> `positions` - * collection rename. Nothing else in that registry reaches the four security - * collections. + * (`packages/spec/src/conversions/registry.ts`): the `permissions` grant-bit + * entry (`permission-allow-restore-purge-removed`, 17.2.0) and the + * `sharingRules` level entry (`sharing-rule-access-level-full-to-edit`, + * 17.7.0). Nothing else in that registry reaches the four security + * collections: the RLS `priority`, the `'role'` recipient and the `roles` -> + * `positions` rename left it at #25767 (a kit retired at or below 16.x ages + * out one major on) and are strict refusals now, not conversions. */ const CONVERSION_GOVERNED_PATHS = [ 'objects.crm_ticket.allowRestore', 'objects.crm_ticket.allowPurge', 'objects.crm_lead.allowRestore', 'objects.crm_lead.allowPurge', - 'rowLevelSecurity.0.priority', 'accessLevel', - 'sharedWith.type', ]; /** The ADR-0010 envelope `applyProtection` stamps from the manifest. */ @@ -301,16 +314,46 @@ describe('#12892 step 2 — the artifact boot has ONE registrar for its security // seam (Studio re-save through `saveMetaItem`) would apply. const raw = bytes(); expect(raw.permissions[0].objects.crm_ticket.allowRestore).toBe(true); - expect(raw.permissions[0].rowLevelSecurity[0].priority).toBe(10); expect(raw.sharingRules[0].accessLevel).toBe('full'); - expect(raw.sharingRules[0].sharedWith.type).toBe('role'); - expect(raw.positions).toBeUndefined(); - expect(raw.roles).toHaveLength(1); + // Written canonically since #25767: no conversion reaches these now. + expect(raw.permissions[0].rowLevelSecurity[0]).not.toHaveProperty('priority'); + expect(raw.sharingRules[0].sharedWith.type).toBe('position'); + expect(raw.positions).toHaveLength(1); + expect(raw.roles).toBeUndefined(); // And the raw bytes are genuinely unparseable as authored. expect(ObjectStackDefinitionSchema.safeParse(raw).success).toBe(false); }); + it('[#25767] the aged-out ≤16.x spellings are refused at the door outright — strict unknown keys naming the upgrade guide, an invalid recipient option — with no conversion to reach them', () => { + // Each kit retired at or below protocol 16.x left the registry one + // major on: `stack-roles-to-positions`, `permission-rls-priority-removed` + // and the `'role'` recipient reconcile. The strict parse the door + // applies says so by name; the ADR-0057 reader has nothing to convert. + const messages = (r: ReturnType) => + r.success ? '' : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n'); + + const withRoles = { ...bytes(), roles: [{ name: 'legacy_rep', label: 'Legacy Rep' }] }; + const roles = ObjectStackDefinitionSchema.safeParse(withRoles); + expect(roles.success).toBe(false); + expect(messages(roles)).toMatch(/Unrecognized key\(s\) on this stack definition: `roles`/); + expect(messages(roles)).toContain('https://objectstack.ai/docs/protocol-upgrade'); + + const withPriority = bytes(); + withPriority.permissions[0].rowLevelSecurity[0].priority = 10; + const priority = ObjectStackDefinitionSchema.safeParse(withPriority); + expect(priority.success).toBe(false); + expect(messages(priority)).toMatch(/Unrecognized key\(s\) on this RLS policy: `priority`/); + expect(messages(priority)).toContain('https://objectstack.ai/docs/protocol-upgrade'); + + const withRole = bytes(); + withRole.sharingRules[0].sharedWith = { type: 'role', value: 'sales_mgr' }; + const role = ObjectStackDefinitionSchema.safeParse(withRole); + expect(role.success).toBe(false); + expect(messages(role)).toMatch(/sharingRules\.0\.sharedWith\.type: Invalid option/); + expect(messages(role)).toContain('"position"'); + }); + // ── ARTIFACT boot: one registrar ────────────────────────────────────── it('artifact boot: AppPlugin registers NOTHING under the four security kinds — and the door reaches every one of them', async () => { @@ -391,7 +434,6 @@ describe('#12892 step 2 — the artifact boot has ONE registrar for its security expect(perm.objects.crm_ticket).not.toHaveProperty('allowPurge'); expect(perm.objects.crm_lead).not.toHaveProperty('allowRestore'); expect(perm.objects.crm_lead).not.toHaveProperty('allowPurge'); - expect(perm.rowLevelSecurity[0]).not.toHaveProperty('priority'); expect(perm.objects.crm_ticket).toMatchObject({ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, }); @@ -408,10 +450,13 @@ describe('#12892 step 2 — the artifact boot has ONE registrar for its security expect(rule.sharedWith.type).toBe('position'); }); - it('non-artifact boot: positions — the collection-key rename reaches AppPlugin', async () => { + it('non-artifact boot: positions — the canonical collection key reaches AppPlugin', async () => { const bundle = collapse(await readerBundle()); // Before #12844 this reader looked for `positions` on bytes that - // spelled the collection `roles`, and registered NOTHING. + // spelled the collection `roles`, and registered NOTHING; since #25767 + // `roles` has no conversion at all (a strict unknown key at the door), + // so the fixture spells the collection `positions` and this reader + // registers it as authored. expect(bundle.get('position:sales_rep')).toMatchObject({ name: 'sales_rep', label: 'Sales Rep', @@ -449,7 +494,6 @@ describe('#12892 step 2 — the artifact boot has ONE registrar for its security expect(perm.objects.crm_ticket).not.toHaveProperty('allowPurge'); expect(perm.objects.crm_lead).not.toHaveProperty('allowRestore'); expect(perm.objects.crm_lead).not.toHaveProperty('allowPurge'); - expect(perm.rowLevelSecurity[0]).not.toHaveProperty('priority'); const rule = copy.get('sharing_rule:share_open_deals'); expect(rule.accessLevel).toBe('edit'); expect(rule.sharedWith.type).toBe('position'); diff --git a/packages/runtime/src/dispatcher-validation-error.real.test.ts b/packages/runtime/src/dispatcher-validation-error.real.test.ts index a85b4367463..d1361a9262a 100644 --- a/packages/runtime/src/dispatcher-validation-error.real.test.ts +++ b/packages/runtime/src/dispatcher-validation-error.real.test.ts @@ -193,12 +193,17 @@ async function postAnalyticsBody(body: unknown) { } describe('#3878 — entry validation reaches the wire as a 400, service untouched', () => { - it('the retired envelope answers 400 with the tombstone prescription', async () => { + // [#25767] The `{ cube, query: {…} }` envelope's tombstone aged out with the + // ≤16.x kits: the entry refuses `query` as a strict unknown key naming the + // upgrade guide, not with the per-key prescription it used to carry. + it('the retired envelope answers 400 as a strict unknown key naming the upgrade guide', async () => { const { res, query } = await postAnalyticsBody({ cube: 'x', query: { measures: ['count'] } }); expect(res.statusCode).toBe(400); expect(res.body.error.code).toBe('VALIDATION_FAILED'); - expect(res.body.error.message).toContain('top level'); + expect(res.body.error.message).toMatch(/Unrecognized key\(s\) on this analytics query: `query`/); + expect(res.body.error.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(res.body.error.message).not.toContain('top level'); expect(query).not.toHaveBeenCalled(); // A caller mistake, not a server fault: the reporter side-channel stays clear. expect(res.__obsRecordedError).toBeUndefined(); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 0b92e0cfc69..6fedd310dae 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1023,14 +1023,17 @@ describe('HttpDispatcher', () => { return mockAnalytics; }; - it('rejects the retired {cube, query:{...}} envelope with the tombstone prescription', async () => { + // [#25767] The envelope's tombstone aged out with the ≤16.x + // kits: `query` is a strict unknown key naming the upgrade + // guide now, with no per-key prescription. + it('rejects the retired {cube, query:{...}} envelope as a strict unknown key naming the upgrade guide', async () => { const mockAnalytics = service(); await expect(dispatcher.dispatch( 'POST', '/analytics/query', { cube: 'x', query: { measures: ['count'] } }, {}, { request: {} }, )).rejects.toMatchObject({ name: 'ValidationError', code: 'VALIDATION_FAILED', - message: expect.stringContaining('top level'), + message: expect.stringMatching(/Unrecognized key\(s\) on this analytics query: `query`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/), }); expect(mockAnalytics.query).not.toHaveBeenCalled(); }); diff --git a/packages/runtime/src/standalone-stack-security-registrar.test.ts b/packages/runtime/src/standalone-stack-security-registrar.test.ts index 6226a2dd4e2..48856921e64 100644 --- a/packages/runtime/src/standalone-stack-security-registrar.test.ts +++ b/packages/runtime/src/standalone-stack-security-registrar.test.ts @@ -36,11 +36,16 @@ import '@objectstack/service-datasource'; /** * Same probe artifact as the two-reader harness: a legacy shape in every - * security collection, an `engines.protocol` floor that opens the door's - * conversion window. The values asserted below are the door's measured output - * for these bytes (DOOR_COPY in the harness). The range is `>=17.1.0`, not - * `^17.1.0`: this boot runs the ADR-0087 D1 handshake, which refuses a caret on - * 17 from protocol 18, and only the floor opens the window. + * security collection a 17.x conversion still reaches (the `allowRestore` / + * `allowPurge` grant bits, `accessLevel: 'full'`), an `engines.protocol` floor + * that opens the door's conversion window. The values asserted below are the + * door's measured output for these bytes (DOOR_COPY in the harness). The range + * is `>=17.1.0`, not `^17.1.0`: this boot runs the ADR-0087 D1 handshake, + * which refuses a caret on 17 from protocol 18, and only the floor opens the + * window. [#25767] The ≤16.x kits aged out, so `positions` and the `position` + * recipient are written canonically and the RLS carries no `priority`: the + * door refuses each legacy spelling outright now (pinned in the harness), and + * a boot over them would not start. */ const ARTIFACT = { manifest: { @@ -50,7 +55,7 @@ const ARTIFACT = { version: '2.0.0', engines: { protocol: '>=17.1.0' }, }, - roles: [{ name: 'sales_rep', label: 'Sales Rep' }], + positions: [{ name: 'sales_rep', label: 'Sales Rep' }], permissions: [ { name: 'support_agent', @@ -59,7 +64,7 @@ const ARTIFACT = { crm_ticket: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowRestore: true, allowPurge: false }, }, rowLevelSecurity: [ - { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true, priority: 10 }, + { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true }, ], }, ], @@ -71,7 +76,7 @@ const ARTIFACT = { object: 'crm_deal', accessLevel: 'full', condition: 'record.status == "open"', - sharedWith: { type: 'role', value: 'sales_mgr' }, + sharedWith: { type: 'position', value: 'sales_mgr' }, }, ], }; @@ -154,7 +159,6 @@ describe('createStandaloneStack — the artifact boot serves ONE copy of each se expect(got.permission[0]).toMatchObject({ name: 'support_agent', isDefault: false, _packageVersion: '2.0.0' }); expect(got.permission[0].objects.crm_ticket).not.toHaveProperty('allowRestore'); expect(got.permission[0].objects.crm_ticket).toMatchObject({ allowTransfer: false, viewAllRecords: false, modifyAllRecords: false }); - expect(got.permission[0].rowLevelSecurity[0]).not.toHaveProperty('priority'); }, BOOT_TIMEOUT); it('control: the pre-step-2 composition (door + a default AppPlugin) reproduces the raw copy winning', async () => { diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index b1251cdfd47..9f6c55e92e6 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -39,8 +39,9 @@ // The two copies used to differ on two independent axes: // // CONVERSION the ADR-0087 forward conversion: the retired `allowRestore` / -// `allowPurge` grant bits, the RLS `priority`, the legacy -// `sharedWith.type: 'role'` and `accessLevel: 'full'`. +// `allowPurge` grant bits and the legacy `accessLevel: 'full'` +// (the RLS `priority` and `sharedWith.type: 'role'` rode it too, +// until #25767 aged their ≤16.x kits out; see the fixture). // PARSE the door's strict parse: schema defaults the registry copy has // never carried (`allowTransfer` / `viewAllRecords` / // `modifyAllRecords`, `isDefault`, a sharing rule's `active`, a @@ -121,16 +122,19 @@ import '@objectstack/service-datasource'; * Each declaration isolates one axis of triage's question: * * - `permissions[0]` — `allowRestore` / `allowPurge` are keys the current - * `PermissionSet` schema does not carry, and `rowLevelSecurity[].priority` is - * another; the forward conversion strips all three (on both copies since - * #22994), and the door's parse defaults three more grant bits in (on its - * copy only). So the first three read the CONVERSION axis and the defaults - * read the PARSE axis. + * `PermissionSet` schema does not carry; the forward conversion strips both + * (on both copies since #22994), and the door's parse defaults three more + * grant bits in (on its copy only). So the two bits read the CONVERSION axis + * and the defaults read the PARSE axis. (`rowLevelSecurity[].priority` read + * the conversion axis too until #25767: its ≤16.x kit aged out, the key is a + * strict unknown key at the door now, and the fixture no longer carries it.) * - `capabilities[0]` — no `scope`, so the door's schema default is observable * (triage question 3). - * - `sharingRules[0]` `share_legacy_deals` — BOTH legacy spellings at once - * (`sharedWith.type: 'role'`, `accessLevel: 'full'`), the commit 317132495 probe - * bytes. + * - `sharingRules[0]` `share_legacy_deals` — the commit 317132495 probe bytes + * less the `sharedWith.type: 'role'` spelling: that recipient reconcile aged + * out at #25767 (`'role'` is an invalid option at the door now), so the rule + * carries the legacy `accessLevel: 'full'` over a `position` recipient and + * is still the rule the seeder once dropped as unmappable. * - `sharingRules[1]` `share_legacy_level` — the legacy `accessLevel` ALONE, * over a recipient type the seeder accepts, so the `accessLevel` axis is not * masked by the recipient axis. @@ -161,7 +165,7 @@ const ARTIFACT = { rowLevelSecurity: [ { name: 'own_tasks', object: 'crm_task', operation: 'select', - using: 'assignee == current_user.email', enabled: true, priority: 10, + using: 'assignee == current_user.email', enabled: true, }, ], }, @@ -177,7 +181,7 @@ const ARTIFACT = { object: 'crm_deal', accessLevel: 'full', condition: 'record.status == "open"', - sharedWith: { type: 'role', value: 'sales_mgr' }, + sharedWith: { type: 'position', value: 'sales_mgr' }, }, { name: 'share_legacy_level', @@ -398,11 +402,11 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins const door = byName(doorCopy.permission, 'probe_agent'); // [#22994] CONVERGED on the conversion axis. The forward conversion strips - // the retired grant bits and the RLS `priority` from BOTH copies now, so the - // registry's grant map is the authored map less those two keys, and its RLS - // entry is the door's, whole. (Measured raw before #22994: - // `allowRestore: true, allowPurge: false` and `priority: 10` on the registry - // copy only.) + // the retired grant bits from BOTH copies now, so the registry's grant map + // is the authored map less those two keys, and its RLS entry is the door's, + // whole. (Measured raw before #22994: `allowRestore: true, allowPurge: + // false` — and `priority: 10`, whose kit aged out at #25767 — on the + // registry copy only.) expect(reg.objects.crm_ticket).toEqual({ allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, }); @@ -414,8 +418,6 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins { name: 'own_tasks', object: 'crm_task', operation: 'select', using: 'assignee == current_user.email', enabled: true }, ]); expect(reg.rowLevelSecurity).toEqual(door.rowLevelSecurity); - expect(reg.rowLevelSecurity[0]).not.toHaveProperty('priority'); - expect(door.rowLevelSecurity[0]).not.toHaveProperty('priority'); // Keys the door DEFAULTS IN and the registry copy has never had (the parse // axis, #14491's, unchanged). @@ -433,12 +435,13 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins expect(rows.sys_permission_set.filter((r: any) => r.name === 'probe_agent')).toEqual([]); }); - it('`sharing_rule`: the registry copy carries the forward-converted `sharedWith.type` and `accessLevel` the door carries', () => { + it('`sharing_rule`: the registry copy carries the forward-converted `accessLevel` the door carries, and the authored recipient', () => { const regLegacy = byName(registryCopy.sharing_rule, 'share_legacy_deals'); const doorLegacy = byName(doorCopy.sharing_rule, 'share_legacy_deals'); // [#22994] CONVERGED: both copies carry the conversion of the legacy - // `role` / `full` spellings. (Measured raw before #22994: the registry copy - // kept `{ type: 'role' }` and `'full'`.) + // `full` spelling. (Measured raw before #22994: the registry copy kept + // `'full'` — and `{ type: 'role' }`, whose conversion aged out at #25767; + // the recipient is authored `position` now and reaches both copies as is.) expect(regLegacy.sharedWith).toEqual({ type: 'position', value: 'sales_mgr' }); expect(doorLegacy.sharedWith).toEqual({ type: 'position', value: 'sales_mgr' }); expect(regLegacy.sharedWith).toEqual(doorLegacy.sharedWith); @@ -470,13 +473,16 @@ describe('#14491 Q1 + Q4 — which copy the seeders consume, and which copy wins }); }); - it('the rule whose recipient the door converts is SERVED, as a position share, exactly once — and no row is written', () => { + it('the rule the seeder once dropped as unmappable is SERVED, as a position share, exactly once — and no row is written', () => { // AN AUTHORIZATION OUTCOME. `mapRecipientType` still refuses `'role'`, and // used to: before #22994 the registry copy kept the legacy recipient, the // seeder took its `skipped (unmappable recipient)` branch, and this rule had - // NO row (three declared, two seeded). [#22994] The registry copy now - // carries the forward conversion, so the seeder is handed the recipient the - // door's copy says, `position`, which `mapRecipientType` accepts. + // NO row (three declared, two seeded). [#22994] The registry copy carried + // the forward conversion, so the seeder was handed `position`. [#25767] That + // conversion aged out with the ≤16.x kits: a `'role'` recipient is refused + // at the door before any seeder runs (the two-reader harness pins the + // refusal), so the rule authors `position`, and what is measured here is + // that the authored recipient reaches the seeder unchanged on both copies. expect(byName(doorCopy.sharing_rule, 'share_legacy_deals').sharedWith.type).toBe('position'); expect(byName(registryCopy.sharing_rule, 'share_legacy_deals').sharedWith.type).toBe('position'); expect(byName(served, 'share_legacy_deals')).toMatchObject({ diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts index 8b5e421d532..21c9175b112 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts @@ -414,11 +414,18 @@ describe('DatasourceAdminServicePlugin: runtime datasource durability', () => { // #4456 — this restore path is a stored-row rehydration seam (ADR-0087 D2 // addendum, #3903): it reads sys_metadata directly, so it must replay the - // conversion chain itself. A row persisted before the #4410 config gate may - // carry the legacy spellings the factory's deleted `??` fallbacks used to - // tolerate; without the replay, a sqlite `file:` row would silently fall - // back to `:memory:` — the data-loss shape the conversion exists to prevent. - it('restores a pre-#4410 row with legacy config keys CANONICAL (conversion chain replayed)', async () => { + // conversion chain itself. [#25767] The chain it replays is the REGISTRY, + // and a kit retired at or below protocol 16.x leaves the registry one major + // on: the pre-#4410 config-key aliases (`file` → `filename`, `connectionString` + // / `user` → `url` / `username`, the mongo `uri` → `url`) are no longer + // conversions, so a row still spelled that way is served AS STORED — and the + // factory's deleted `??` fallbacks mean a sqlite `file:` row would open + // `:memory:`, the shape the retired conversion used to prevent. Per the + // ruling, such a row is canonicalized once with `os migrate meta --stored + // --apply` on 18.1 or earlier; nothing at 18.2 rewrites it. What the seam + // still replays is every 17.x row — the driver alias `mongo` → `mongodb` + // (`datasource-driver-mongo-to-mongodb`, 17.7.0) below. + it('restores a stored row through the registry: a 17.x kit still replays (the mongo driver alias); the aged-out ≤16.x config aliases are served as stored', async () => { const data = fakeSysMetadataEngine(); const now = new Date().toISOString(); for (const [name, driver, config] of [ @@ -444,14 +451,16 @@ describe('DatasourceAdminServicePlugin: runtime datasource durability', () => { // The list DTO is a summary; `getDatasource` (concrete service) is the // config-bearing read the admin routes serve. const svc = b.service as unknown as DatasourceAdminService; - expect((await svc.getDatasource('legacy_sqlite'))?.config).toEqual({ filename: '/tmp/legacy.db' }); + // Served as stored: no conversion reaches these spellings any more. + expect((await svc.getDatasource('legacy_sqlite'))?.config).toEqual({ file: '/tmp/legacy.db' }); expect((await svc.getDatasource('legacy_pg'))?.config).toEqual({ - url: 'postgresql://db.internal/analytics', - username: 'analyst', - }); - expect((await svc.getDatasource('legacy_mongo'))?.config).toEqual({ - url: 'mongodb://mongo.internal:27017/events', + connectionString: 'postgresql://db.internal/analytics', + user: 'analyst', }); + // The 17.7.0 driver alias still replays; the ≤16.x `uri` alias does not. + const mongo = await svc.getDatasource('legacy_mongo'); + expect(mongo?.driver).toBe('mongodb'); + expect(mongo?.config).toEqual({ uri: 'mongodb://mongo.internal:27017/events' }); }); it('removes the durable sys_metadata row when a datasource is deleted', async () => { From 34e9ff1ffa791d0e886e0e00008694a37ff895ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:04:53 +0000 Subject: [PATCH 06/22] =?UTF-8?q?docs(spec,cli):=20the=2018.2.0=20note=20l?= =?UTF-8?q?eads=20with=20"Upgrading=20from=20protocol=2016=20or=20earlier"?= =?UTF-8?q?=20=E2=80=94=20one=20major=20at=20a=20time,=20stored=20rows=20b?= =?UTF-8?q?efore=20the=20runtime=20moves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer's ruling relayed on the PR (B, ships in 18.2): make the path a ≤16.x project takes prominent, text only. One block, in the two places the ruling names — the changeset, because the 18.2.0 release notes are cut from it, and the top of docs/protocol-upgrade-guide.md: - the default, one major at a time: `@objectstack/cli@17.7.0 migrate meta --from 16 --apply`, its `--stored --apply` for the sys_metadata rows, then the move to 18.2 and `os migrate meta --from 17 --apply`, `os validate`, `os build` — and the rule it follows, each major's CLI in turn, which holds at 19; - the one-shot alternative on `@objectstack/cli@18.1.0`, the last release whose floor is 16; - stored rows are migrated before the deployment's image or dependencies move to 18.2, since 18.2 no longer rewrites a ≤16.x row at read; - what 18.2 does when the first hop is skipped: `--from 16` refuses with MigrationFloorError and rewrites nothing, and a ≤16.x key meets the strict unknown-key refusal with the guide link. No code and no generated file changes; content/docs/upgrading.mdx keeps its floor callout, which names the 18.1 path as the alternative does. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- .../25767-retire-16x-retirement-kits.md | 16 ++++++++++++++ docs/protocol-upgrade-guide.md | 22 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.changeset/25767-retire-16x-retirement-kits.md b/.changeset/25767-retire-16x-retirement-kits.md index cec32a6eb07..5fdc57a7704 100644 --- a/.changeset/25767-retire-16x-retirement-kits.md +++ b/.changeset/25767-retire-16x-retirement-kits.md @@ -10,6 +10,22 @@ feat(spec,cli)!: retirement kits retired at or below protocol 16.x leave the tre Clause-②: no (narrowing: a key retired at or below protocol 16.x is refused as a strict unknown key without its own prescription in the error, and `os migrate meta --from 16` refuses at the support floor instead of replaying step 17) Metadata-protocol: narrowed +**Upgrading from protocol 16 or earlier.** One major at a time, each major's CLI in turn — a major's CLI carries the steps for itself and the major before it, so the same sequence holds at 19: + +```bash +npx @objectstack/cli@17.7.0 migrate meta --from 16 --apply # 16 → 17, on the last 17.x release (its floor is 16) +npx @objectstack/cli@17.7.0 migrate meta --stored --apply # the sys_metadata rows, BEFORE the runtime moves +# move the dependencies / the deployment's image to 18.2, then +os migrate meta --from 17 --apply # 17 → 18 +os validate && os build +``` + +**Alternative, one shot:** `@objectstack/cli@18.1.0` is the last release whose floor is 16, so `npx @objectstack/cli@18.1.0 migrate meta --from 16` (then `--apply`) runs both hops in one command — work the to-do list it prints, then move the dependencies to 18.2 and run `os migrate meta --from 17`, `os validate` and `os build`; its `migrate meta --stored --apply` canonicalizes the stored rows the same way. + +**Stored metadata (`sys_metadata`, Studio-authored) is migrated BEFORE the deployment's image or dependencies move to 18.2.** 18.2 no longer rewrites a ≤16.x row at read: a row left behind is refused by the strict parse until it is migrated. + +**If the first hop is skipped:** on 18.2, `os migrate meta --from 16` refuses with `MigrationFloorError` and rewrites nothing, and a key retired at or below 16.x meets the strict unknown-key refusal with the guide link, `https://objectstack.ai/docs/protocol-upgrade`. + **BREAKING** for a source still carrying a key retired at or below protocol 16.x, and for an operator who runs `os migrate meta --from 16`. - **The rule (ADR-0087, amended 2026-10-26, #25767 ruling D2 = A).** A retirement kit — the `retiredKey()` tombstone, its `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` rows, its semantic entry and its D2 conversion — lives through the protocol major after the one it retired in and is removed when the next major is cut. At 18 that keeps the 17.x and 18.x kits and removes everything retired at or below 16.x; 19 repeats the move one major on. "Never deleted", "permanent" and "forever" in ADR-0087 are amended in place. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index c28c93a1a8f..226229c5bf3 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1,6 +1,26 @@ # Metadata protocol upgrade guide -This file is a pointer. The guide is generated from the ADR-0087 registries and is not committed; published READMEs and CHANGELOGs name this path, so it stays here and names where the guide lives. +This file is a pointer, plus the one note that must not wait for the site. The guide is generated from the ADR-0087 registries and is not committed; published READMEs and CHANGELOGs name this path, so it stays here and names where the guide lives. + +## Upgrading from protocol 16 or earlier + +One major at a time, each major's CLI in turn — a major's CLI carries the steps for itself and the major before it, so the same sequence holds at 19: + +```bash +npx @objectstack/cli@17.7.0 migrate meta --from 16 --apply # 16 → 17, on the last 17.x release (its floor is 16) +npx @objectstack/cli@17.7.0 migrate meta --stored --apply # the sys_metadata rows, BEFORE the runtime moves +# move the dependencies / the deployment's image to 18.2, then +os migrate meta --from 17 --apply # 17 → 18 +os validate && os build +``` + +**Alternative, one shot:** `@objectstack/cli@18.1.0` is the last release whose floor is 16, so `npx @objectstack/cli@18.1.0 migrate meta --from 16` (then `--apply`) runs both hops in one command — work the to-do list it prints, then move the dependencies to 18.2 and run `os migrate meta --from 17`, `os validate` and `os build`; its `migrate meta --stored --apply` canonicalizes the stored rows the same way. + +**Stored metadata (`sys_metadata`, Studio-authored) is migrated BEFORE the deployment's image or dependencies move to 18.2.** 18.2 no longer rewrites a ≤16.x row at read: a row left behind is refused by the strict parse until it is migrated. + +**If the first hop is skipped:** on 18.2, `os migrate meta --from 16` refuses with `MigrationFloorError` and rewrites nothing, and a key retired at or below 16.x meets the strict unknown-key refusal naming this guide. + +## Where the guide lives **Address:** the docs site, one page per protocol major. From 34b068850e5550dfc81e460ee96631886b1527c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:21:45 +0000 Subject: [PATCH 07/22] docs(upgrading): link the protocol upgrade guide by its address, not a site route into the generated directory CI's Check Documentation Links (lychee, offline, --root-dir content) went red on the merged head: content/docs/upgrading.mdx linked the guide as `/docs/protocol-upgrade`, a site route into content/docs/protocol-upgrade/, which is generated by `gen:upgrade-guide` and git-ignored, so a checkout has no file for it. The link now carries the guide's address, the spelling the backward-compatibility page and the refusal text already use, and the one the offline lane excludes by scheme. No other link-checked page on the branch targets an untracked path (resolved against `git ls-files`). Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- content/docs/upgrading.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 32aee922ac9..116e08d7c06 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -235,7 +235,7 @@ The chain reaches back to **protocol 17** — one major below the current one, b rule (ADR-0087, amended 2026-10-26): a retirement kit lives through the major after the one it retired in and leaves when the next major is cut, and the floor rises with it at every major. Below the floor the command refuses with a message -naming the floor and the [protocol upgrade guide](/docs/protocol-upgrade) +naming the floor and the [protocol upgrade guide](https://objectstack.ai/docs/protocol-upgrade) rather than half-migrating you. From 93505c99a07f44008a24b060e8c75d3d9fc61391 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:41:22 +0000 Subject: [PATCH 08/22] fix(ui-core): ActionDef stops mirroring the aged-out `shortcut` and `bulkEnabled` action keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's Build Core went red on the merged head in ui/packages/core: `ActionDef` declared `shortcut?: SpecActionInput['shortcut']` and `bulkEnabled?: SpecActionInput['bulkEnabled']`, two mirrors of spec-17 `retiredKey()` tombstones that resolved to `undefined` for as long as the tombstones stood. The ≤16.x kits left the schema one major on, so the indexed access has nothing to name and tsc refuses it. The two members leave `ActionDef` with their rows in `ACTION_DEF_KEYS` and `SPEC_ACTION_KEYS` (the key inventories the pin tests hold against the spec's `ActionSchema` shape), and `SPEC_ACTION_KEYS` drops its `execute` row for the same reason: the pin that walks the spec shape reported it stale once that tombstone aged out. `RETIRED_ACTION_KEYS` keeps the console's own `execute` rename prescription (a stored row can reach the runner unparsed), and `ActionEngine.registerAction(action, { shortcut, bulkEnabled })` keeps both as host-passed options, as before; the closed-surface pin's comment and the tombstone test's title say what refuses the key now. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- ui/packages/core/src/actions/ActionRunner.ts | 24 +++++++------------ .../actionDef-closed-surface.test.ts | 2 +- .../actions/__tests__/actionKeys.pin.test.ts | 2 +- ui/packages/core/src/actions/actionKeys.ts | 13 ++++++---- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index 8b35266a435..2b458e53144 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -502,22 +502,14 @@ export interface ActionDef { * active language before dispatch, because the runner has no language. */ outcomeMessages?: SpecActionInput['outcomeMessages']; - /** - * @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone — - * authoring it is a hard parse rejection, so this resolves to `undefined` and - * assigning a value is a compile error. A HOST may still pass a shortcut - * explicitly via `ActionEngine.registerAction(action, { shortcut })`; it is - * only no longer sourced from authored metadata. No replacement key. - */ - shortcut?: SpecActionInput['shortcut']; - /** - * @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone — - * authoring it is a hard parse rejection, so this resolves to `undefined` and - * assigning a value is a compile error. Use the list view's `bulkActions` / - * `bulkActionDefs` instead; `ActionEngine.registerAction(action, { bulkEnabled })` - * remains available to a HOST passing it explicitly. - */ - bulkEnabled?: SpecActionInput['bulkEnabled']; + // No `shortcut` and no `bulkEnabled`: spec 17 retired both as `retiredKey()` + // tombstones and this interface mirrored each as `SpecActionInput['…']` + // (always `undefined`) until objectstack#25767 aged the ≤16.x kits out of + // the schema one major on, which left nothing to mirror. A HOST still passes + // either one explicitly through `ActionEngine.registerAction(action, + // { shortcut, bulkEnabled })`; neither is sourced from authored metadata, + // and a bulk action is declared in the list view's `bulkActions` / + // `bulkActionDefs`. } /** diff --git a/ui/packages/core/src/actions/__tests__/actionDef-closed-surface.test.ts b/ui/packages/core/src/actions/__tests__/actionDef-closed-surface.test.ts index e56e2e2f60b..f2937701ec9 100644 --- a/ui/packages/core/src/actions/__tests__/actionDef-closed-surface.test.ts +++ b/ui/packages/core/src/actions/__tests__/actionDef-closed-surface.test.ts @@ -67,7 +67,7 @@ const _typoTarget: ActionDef = { type: 'script', targt: 'saveRecord' }; // A retired SPEC key that is not a tombstone, to show the closure is general // rather than a list of two special cases. -// @ts-expect-error - `bulkEnabled` is a retiredKey() tombstone in spec 17 +// @ts-expect-error - `bulkEnabled` is not an `ActionDef` key: its spec-17 tombstone aged out at objectstack#25767 and the mirror left with it const _retiredBulk: ActionDef = { type: 'api', bulkEnabled: true }; // ── The unified `visible` / `disabled` shape: three arms each ──────────────── diff --git a/ui/packages/core/src/actions/__tests__/actionKeys.pin.test.ts b/ui/packages/core/src/actions/__tests__/actionKeys.pin.test.ts index 1e906fddedb..7c560b9c743 100644 --- a/ui/packages/core/src/actions/__tests__/actionKeys.pin.test.ts +++ b/ui/packages/core/src/actions/__tests__/actionKeys.pin.test.ts @@ -140,7 +140,7 @@ describe('action key inventory (objectstack#4075 step 1)', () => { expect({ missing, stale }).toEqual({ missing: [], stale: [] }); }); - it('`execute` is still a live spec tombstone, so it must not count as known', () => { + it('`execute` is still refused by the spec (the 17 tombstone aged out at objectstack#25767; the strict unknown-key refusal took over), so it must not count as known', () => { const parsed = SpecActionSchema.safeParse({ name: 'mark_done', label: 'Mark Done', diff --git a/ui/packages/core/src/actions/actionKeys.ts b/ui/packages/core/src/actions/actionKeys.ts index 9b0fab5323d..9184acb74c2 100644 --- a/ui/packages/core/src/actions/actionKeys.ts +++ b/ui/packages/core/src/actions/actionKeys.ts @@ -199,8 +199,8 @@ export const ACTION_DEF_KEYS = [ // Spec 17.6.0's success copy per handler outcome (objectui#11344), declared // when the runner learned to read it: the success toast's first rung. 'outcomeMessages', - 'shortcut', - 'bulkEnabled', + // `shortcut` and `bulkEnabled` left with their spec tombstones + // (objectstack#25767): `ActionDef` no longer declares either. ] as const; /** @@ -234,7 +234,6 @@ export const SPEC_ACTION_KEYS = [ 'body', 'bodyExtra', 'bodyShape', - 'bulkEnabled', 'component', 'confirmText', // Added to `ActionSchema` in @objectstack/spec 17.0.0-rc.6. Listed here purely @@ -248,7 +247,12 @@ export const SPEC_ACTION_KEYS = [ 'description', 'disabled', 'errorMessage', - 'execute', + // No `execute`: its spec-17 tombstone (`retiredKey()`, objectstack#3855) aged + // out of `ActionSchema` at objectstack#25767 — the spec now refuses it as an + // unknown key naming the protocol upgrade guide — so the spec no longer + // declares it and this inventory, which restates the spec's declared keys, + // drops the row. `RETIRED_ACTION_KEYS` below still carries the console's own + // rename prescription: a stored row can reach the runner unparsed. // Added to `ActionSchema` in @objectstack/spec 17.5.0 (objectui#11073): the // bulk dispatch contract the action's body is written for, in // `bulkActionDefs`' own `'perRecord' | 'aggregate'` vocabulary. Listed for the @@ -310,7 +314,6 @@ export const SPEC_ACTION_KEYS = [ // question. 'requiresMembershipReach', 'resultDialog', - 'shortcut', 'successMessage', 'target', 'type', From 53e1488e701dcaddef1c75f627450c70da255bb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:06:44 +0000 Subject: [PATCH 09/22] test(spec,ui): the console twins refuse the aged-out App/Dashboard keys on their own; the kits' readers follow the one-major rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract review of 93505c99a0 found the readers of the removed 16.x kits that the removal had not been shipped with: the spec package's own repo tests, the console's derived twins and their pins, and a react test pin on the ActionDef mirror. Spec repo tests (`test:repo`, Test Core 1/8): - Three surviving-kit tests asserted a major-17 ledger row this change deletes (`integration/ConnectorFieldMapping:transform`, `ui/PageHeaderProps:icon`); they now assert the one-major rule — the 17 table is gone and the row with it. - The check-mode leaf-collision fixtures lost their premise: the `data.query.distinct` clause left with the 16.x conversions and the `data/Index:type` tombstone with the 16.x tombstones. Re-picked to `api/PackageInstallManifest:capabilities` against the registered `manifest.capabilities` (kernel/Manifest's row, inherited through `ManifestSchema.extend`) and `system/BookGroup:translations` against `manifest.contributes.translations`. - Five tree-scoped absence kits found the fixtures this change moved onto surviving 17.x keys (migrate-meta-write, migrations.test, view-union-retirement-prescription, lint-liveness-properties); each kit's EXCLUDED set names the file with its reason. Console twins (`@object-ui/types`): `AppComponentSchema` and `DashboardComponentSchema` derive from the spec shapes by reference over a `.passthrough()` base, so the ten tombstones that aged out of `AppSchema` / `DashboardSchema` (version, homePageId, objects, apis, sharing, embed, mobileNavigation, aria; aria, performance) stopped being refused and were kept in silence. A new `agedOutSpecKeyRefusal` helper (`zod/tombstone.zod.ts`) declares each as the console's own refusal: `invalid_type` at the key's path, opening with the spec's unknown-key sentence and the guide sentence (a copy pinned equal to the spec's export, since the mirrors' import boundary admits no spec string), then where the value went. The TS twins carry `?: never`; the parity, twins-by-reference and dashboard-aria pins assert the refusal on both faces with the spec's unknown-key verdict as the control. The strict `DashboardWidgetSchema` needs no arm: its five aged-out keys meet the `.strict()` door, and the report-chart pins now read zod's `unrecognized_keys` there with the spec's guide-naming refusal as the contract leg. driver-sql: the storage/presentation classification of FieldSchema keys drops its `conditionalRequired` row (the test beside it refuses a classification the spec no longer declares). Console pins: the layout conversion-coverage row for `page:card → properties.body[]` is gone with the conversion; the react useActionEngine fixture keeps its stale `shortcut` on an entry of its own (an object literal with a mistyped member is not also checked for excess members, so the directive reported unused once `shortcut` left the mirror). The SDUI manifest is re-recorded for the moved source digests. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- .../src/builtin-column-collision.ts | 5 +- ...ld-schemas-check-mode.retired-keys.test.ts | 27 +++-- .../src/ai/agent-lifecycle-retirement.test.ts | 4 + ...-external-sharing-model-retirement.test.ts | 6 + .../connector-sync-retirement.test.ts | 11 +- .../page-header-breadcrumb-retirement.test.ts | 4 +- .../page-header-show-star-retirement.test.ts | 6 +- .../view-item-owner-hidden-retirement.test.ts | 4 + ...-list-filterable-fields-retirement.test.ts | 7 ++ .../visibility-alias-keys-retirement.test.ts | 3 + scripts/sdui-manifest.sources/core.json | 2 +- scripts/sdui-manifest.sources/types.json | 2 +- .../registry-inputs-spec-parity.test.ts | 40 +++++-- .../test/pageheader-with-actions.test.tsx | 10 +- ...slotted-pages-header-example-11165.test.ts | 7 +- .../ObjectView.relayRungCensus-7559.test.ts | 9 +- .../clientValidation.fieldRules.test.ts | 19 +-- ...ow-node-config.contractKeys-11968.test.tsx | 7 +- .../block-config-schema-parity-8216.test.ts | 21 ++-- .../previews/__tests__/block-config.test.ts | 23 +++- ...eader-subtitle-conversion-coverage.test.ts | 22 +--- ...dgetConfigPanel.retiredActionKeys.test.tsx | 21 +++- .../dashboardAuthoredInputs.test.tsx | 12 +- .../recordDetailsInputs.spec-parity.test.ts | 25 ++-- .../hooks/__tests__/useActionEngine.test.ts | 16 ++- ...hboard-aria-retired-contract-twins.test.ts | 80 ++++++++++--- .../flat-props-refusal-10872.test.ts | 7 +- .../src/__tests__/node-slots-11170.test.ts | 16 ++- .../__tests__/object-view-spec-parity.test.ts | 26 +++- .../object-view-unmirrored-keys-7779.test.ts | 71 +++++++---- .../page-app-dashboard-spec-parity.test.ts | 111 +++++++++++++++--- .../__tests__/public-block-arms-10872.test.ts | 11 +- .../record-details-top-level-9040.test.ts | 44 ++++--- .../report-chart-query-spec-parity.test.ts | 62 ++++++---- .../twins-spec-by-reference-9736.test.ts | 27 ++++- ui/packages/types/src/app.ts | 76 ++++++++++++ ui/packages/types/src/complex.ts | 43 +++++-- ui/packages/types/src/zod/app.zod.ts | 56 ++++++++- ui/packages/types/src/zod/complex.zod.ts | 32 ++++- ui/packages/types/src/zod/tombstone.zod.ts | 56 +++++++++ 40 files changed, 802 insertions(+), 229 deletions(-) diff --git a/packages/drivers/driver-sql/src/builtin-column-collision.ts b/packages/drivers/driver-sql/src/builtin-column-collision.ts index 3aee45540cb..ca3aa5a1aa3 100644 --- a/packages/drivers/driver-sql/src/builtin-column-collision.ts +++ b/packages/drivers/driver-sql/src/builtin-column-collision.ts @@ -119,7 +119,10 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly> = visibleWhen: 'presentation', readonlyWhen: 'presentation', requiredWhen: 'presentation', - conditionalRequired: 'presentation', + // `conditionalRequired` (the pre-17 spelling of `requiredWhen`) was a row + // here while the spec carried its tombstone; the tombstone aged out of + // `FieldSchema` at spec 18.2 (objectstack#25767, ADR-0087 one major on), and + // the test beside this map refuses a classification the spec does not declare. dueLike: 'presentation', // declares a date a deadline (#22227) — read by the renderer's date cells, never by `createColumn`: the column is the date the type already sizes settledWhen: 'presentation', // the deadline's settle predicate (#22227), display only like `visibleWhen` — no DDL reads it conditionalFormatting: 'presentation', // a field's cell formatting rules (#22228), display only — no DDL reads it diff --git a/packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts b/packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts index 3bd36b9bdbe..1c4fadf5e2c 100644 --- a/packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.retired-keys.test.ts @@ -57,16 +57,27 @@ const ROOT_SCRIPTS = fileURLToPath(new URL('../../../scripts', import.meta.url)) useSharedSandbox(ROOT_SCRIPTS); /** Retired keys whose ONLY registered clause names a DIFFERENT def — the defect. */ -const LEAF_COLLIDER_A = 'api/HttpFindQueryParams:distinct'; +const LEAF_COLLIDER_A = 'api/PackageInstallManifest:capabilities'; -/** …matched by this clause, which registers `data/Query`'s key, not this one. */ -const LEAF_COLLIDER_A_CLAUSE = 'data.query.distinct'; +/** + * …matched by this clause, which registers `kernel/Manifest`'s key, not this + * one: `PackageInstallManifestSchema` extends `ManifestSchema`, so the same + * tombstone sits on a second def with no row of its own. Re-picked at spec 18.2 + * (#25767): the fixture was `api/HttpFindQueryParams:distinct` against + * `data.query.distinct` until that clause left with the 16.x conversions. + */ +const LEAF_COLLIDER_A_CLAUSE = 'manifest.capabilities'; -/** The issue's own `.type` shape: an index type dated by a flow node rename. */ -const LEAF_COLLIDER_B = 'data/Index:type'; +/** + * A book group's i18n block: a surviving tombstone (`book.zod.ts`, no row of + * its own) whose leaf a manifest retirement also spells. Re-picked at spec + * 18.2 (#25767) with A: the issue's own fixture, `data/Index:type` against + * `flow.node.type`, aged out with the 16.x tombstones. + */ +const LEAF_COLLIDER_B = 'system/BookGroup:translations'; -/** …registered at protocol 11 by `flow-node-http-callout-rename`. Unrelated. */ -const LEAF_COLLIDER_B_CLAUSE = 'flow.node.type'; +/** …registered by `plugin-manifest-contributes-dead-members-retired` for the manifest's `contributes` block. Unrelated. */ +const LEAF_COLLIDER_B_CLAUSE = 'manifest.contributes.translations'; /** A key no build emits: the aged-out steady state a stale entry decays into. */ const AGED_OUT_KEY = 'data/Object:zzAgedOutTombstone4659'; @@ -242,7 +253,7 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its 'registering one key registers exactly that key — its neighbour with the same leaf still fails', { timeout: SPAWN_TIMEOUT_MS }, () => { - // `data.query.distinct` covered A only by leaf; now even a REAL entry for + // `manifest.capabilities` covered A only by leaf; now even a REAL entry for // B cannot cover A. This is the whole change in one run: membership is per // key, and nothing radiates from one registration to another. seedRetiredKeys({ [CURRENT_MAJOR]: [LEAF_COLLIDER_B] }); diff --git a/packages/spec/src/ai/agent-lifecycle-retirement.test.ts b/packages/spec/src/ai/agent-lifecycle-retirement.test.ts index 83ac9faba7c..1bb072212dd 100644 --- a/packages/spec/src/ai/agent-lifecycle-retirement.test.ts +++ b/packages/spec/src/ai/agent-lifecycle-retirement.test.ts @@ -296,6 +296,10 @@ describe('tree-scoped absence: nothing inside the declared radius still authors const EXCLUDED = new Set([ // This pin authors the key to assert its refusal and its conversion. THIS_FILE, + // The CLI write door's pin (#25767): its fixture authors the pre-retirement + // agent on purpose, to assert `os migrate meta --from 17 --write` rewrites + // it through this kit's D2 conversion — the kit's fixture at the CLI door. + 'packages/cli/test/migrate-meta-write.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement agent on purpose. diff --git a/packages/spec/src/data/object-external-sharing-model-retirement.test.ts b/packages/spec/src/data/object-external-sharing-model-retirement.test.ts index 12b88daf263..a403dea72d2 100644 --- a/packages/spec/src/data/object-external-sharing-model-retirement.test.ts +++ b/packages/spec/src/data/object-external-sharing-model-retirement.test.ts @@ -357,6 +357,12 @@ describe('tree-scoped absence: nothing inside the declared radius still authors 'packages/lint/src/runtime-gate.datasource-writes.test.ts', 'packages/lint/src/runtime-gate.inert-type-writes.test.ts', 'packages/cli/test/i18n-flow-liveness-gate.test.ts', + // The CLI write door's pin and the chain's own replay pin (#25767): each + // authors the pre-retirement object on purpose, to assert the D2 + // conversion strips the key at `--from 17` — this kit's fixtures at the + // two doors that replay it. + 'packages/cli/test/migrate-meta-write.test.ts', + 'packages/spec/src/migrations/migrations.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement object on purpose. diff --git a/packages/spec/src/integration/connector-sync-retirement.test.ts b/packages/spec/src/integration/connector-sync-retirement.test.ts index e21e1a27a85..ca72d945521 100644 --- a/packages/spec/src/integration/connector-sync-retirement.test.ts +++ b/packages/spec/src/integration/connector-sync-retirement.test.ts @@ -289,10 +289,13 @@ describe('connector sync retirement — ADR-0087 registration', () => { ]) { expect(RETIRED_DEFS_BY_MAJOR[18], `${def} must be declared`).toContain(def); } - // The earlier `transform` retirement's key-grain row stays: its def left - // whole, which is the steady state gate (b3) exempts — deleting the row - // would erase the record that the key was ever retired. - expect(RETIRED_KEYS_BY_MAJOR[17]).toContain('integration/ConnectorFieldMapping:transform'); + // The earlier `transform` retirement (17.0.0) was a key-grain row under + // major 17. That whole table aged out at 18.2 (objectstack#25767, ADR-0087 + // amended 2026-10-26: a row lives one major past its tombstone, and the + // record that the key was ever retired is the previous major's release + // tag, not this registry). The row must not come back by itself. + expect(RETIRED_KEYS_BY_MAJOR[17]).toBeUndefined(); + expect(Object.values(RETIRED_KEYS_BY_MAJOR).flat()).not.toContain('integration/ConnectorFieldMapping:transform'); }); it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { diff --git a/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts b/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts index 6b95f1ebaba..057b0ae17a4 100644 --- a/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts +++ b/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts @@ -275,8 +275,10 @@ describe('page:header breadcrumb retirement — the ADR-0087 ledger row', () => it('declares ONE key under major 18', () => { expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(REGISTERED_KEY); const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + // `ui/PageHeaderProps:icon` (17.0.0) stood first in this list until spec + // 18.2: the major-17 table aged out one major on (objectstack#25767, + // ADR-0087), so the row's surviving keys are the two 18 rows. expect(all.filter((k) => k.startsWith('ui/PageHeaderProps:'))).toEqual([ - 'ui/PageHeaderProps:icon', REGISTERED_KEY, // #23650 retired the row's third key the same way. 'ui/PageHeaderProps:showStar', diff --git a/packages/spec/src/ui/page-header-show-star-retirement.test.ts b/packages/spec/src/ui/page-header-show-star-retirement.test.ts index 4f754bf96cb..a0f90f5cfe8 100644 --- a/packages/spec/src/ui/page-header-show-star-retirement.test.ts +++ b/packages/spec/src/ui/page-header-show-star-retirement.test.ts @@ -278,12 +278,14 @@ describe('page:header showStar retirement — the D2 conversion', () => { }); describe('page:header showStar retirement — the ADR-0087 ledger row', () => { - it('declares ONE key under major 18, beside the two this row lost before it', () => { + it('declares ONE key under major 18, beside the one this row lost before it', () => { expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(REGISTERED_KEY); const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + // `ui/PageHeaderProps:icon` (17.0.0) was the third key here until spec + // 18.2: the major-17 table aged out one major on (objectstack#25767, + // ADR-0087), leaving the two 18 rows. expect(all.filter((k) => k.startsWith('ui/PageHeaderProps:')).sort()).toEqual([ 'ui/PageHeaderProps:breadcrumb', - 'ui/PageHeaderProps:icon', REGISTERED_KEY, ]); }); diff --git a/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts b/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts index 6e06345acdf..0224d62f3ae 100644 --- a/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts +++ b/packages/spec/src/ui/view-item-owner-hidden-retirement.test.ts @@ -308,6 +308,10 @@ describe('tree-scoped absence: no ViewItem record or flattened overlay inside th 'packages/spec/src/ui/view-overlay-owner-hidden-retirement.test.ts', // This pin names the keys to assert their absence. THIS_FILE, + // [#25767] The view union's prescription pin authors `owner` on a view + // record on purpose, to assert the branch-typed refusal names this kit's + // prescription. + 'packages/spec/src/ui/view-union-retirement-prescription.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement record on purpose. diff --git a/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts index 1b509afdfba..1d58170d5b6 100644 --- a/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts +++ b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts @@ -361,6 +361,13 @@ describe('tree-scoped absence: no list-view payload inside the declared radius s // The `os lint` twin of that pin: `os lint` never parses, so the lint // rule reports the key by name, and this file authors it to assert that. 'packages/lint/src/list-view-filterable-fields-retired.test.ts', + // [#25767] The view union's prescription pin authors the key at the + // container and overlay doors to assert the branch-typed refusal names this + // kit's prescription, and the liveness advisory's witnesses author it on an + // UNPARSED intake to prove the ledger's `dead` verdict still warns there — + // both took this key over from `striped` when its 16.x tombstone aged out. + 'packages/spec/src/ui/view-union-retirement-prescription.test.ts', + 'packages/lint/src/lint-liveness-properties.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversion's fixture authors the pre-retirement payload on purpose. diff --git a/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts b/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts index fcb9e726557..9015f463365 100644 --- a/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts +++ b/packages/spec/src/ui/visibility-alias-keys-retirement.test.ts @@ -250,6 +250,9 @@ describe('tree-scoped absence: nothing inside the declared radius still authors 'packages/spec/src/ui/component.test.ts', // The view write door's generic moved-key projection, modelled on the old fold. 'packages/metadata-protocol/src/protocol.project-storable-view-body.test.ts', + // [#25767] The view union's prescription pin authors `visibleOn` on a form + // overlay to assert the branch-typed refusal names the alias prescription. + 'packages/spec/src/ui/view-union-retirement-prescription.test.ts', ]); const EXCLUDED_PREFIXES = [ // The D2 conversions' fixtures and the stored-row replay pins author the pre-18 shapes on purpose. diff --git a/scripts/sdui-manifest.sources/core.json b/scripts/sdui-manifest.sources/core.json index 43d8e41c0cd..b7adbd39017 100644 --- a/scripts/sdui-manifest.sources/core.json +++ b/scripts/sdui-manifest.sources/core.json @@ -1 +1 @@ -{"package":"ui/packages/core","sources":110,"sha256":"c4ca58ee32a488b2139abc179f34d5606ba0d89b86fccf8bd5c4d7b69d07f654","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/core","sources":110,"sha256":"ce81e81324c486df041d71d1f6d704e2afe1defd3d26b5273331345014316521","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/types.json b/scripts/sdui-manifest.sources/types.json index b4a5258ce1e..c4f39df9a74 100644 --- a/scripts/sdui-manifest.sources/types.json +++ b/scripts/sdui-manifest.sources/types.json @@ -1 +1 @@ -{"package":"ui/packages/types","sources":81,"sha256":"30832d40db85588aa490e7d3f7b2e2ff689071f4e84176bcab96bacf74060d4f","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/types","sources":81,"sha256":"3b718cefe7a4595e4ea754c8412f4bc38cbfddb2cad98638ea6d5892d0c1eae4","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 3389542e94f..abadb1bd6d1 100644 --- a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -5531,14 +5531,13 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // them fails HERE, naming the key, instead of resurfacing as an unexplained // red in a derived loop. const HARVESTED: Array<[string, string]> = [ - ['element:record_picker', 'displayField'], - ['element:record_picker', 'multiple'], - ['element:record_picker', 'searchFields'], - ['page:card', 'actions'], - ['page:card', 'body'], - ['page:header', 'icon'], - ['page:tabs', 'type'], - ['record:details', 'layout'], + // The first eight (`element:record_picker` `displayField` / `multiple` / + // `searchFields`, `page:card` `actions` / `body`, `page:header` `icon`, + // `page:tabs` `type`, `record:details` `layout`) were 17.0.0 tombstones + // and aged out of their rows at spec 18.2 (objectstack#25767, ADR-0087 + // one major on): no longer LISTED, so the "still listed" leg below has + // nothing to read for them, and the reverse direction never demands an + // unknown key. They are pinned as absent in the describe after this one. // The ninth, harvested at the `@objectstack/spec` 17.5.0 bump // (objectui#11073): objectui#8285's ruled retirement, which emptied // `LAZY_BLOCK_RULED_CARVE_OUTS` in the same change. @@ -5613,6 +5612,31 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(exemptedTombstones).toEqual([]); }); + it('the eight 17.0.0 tombstones that aged out at spec 18.2 are gone from the rows — not listed, not published, not exempted', () => { + // objectstack#25767 (ADR-0087, one major on): the spec no longer declares + // these keys at all and refuses each as an unknown key naming the protocol + // upgrade guide. The three legs a tombstone row used to get still hold for + // an absent key — nothing may publish or exempt it — and the first leg + // inverts: the release no longer lists it. Pinned by name so a key that + // comes back (re-declared upstream, or re-published here) is a red that + // names it rather than an unexplained row in a derived loop. + const AGED_OUT: Array<[string, string]> = [ + ['element:record_picker', 'displayField'], + ['element:record_picker', 'multiple'], + ['element:record_picker', 'searchFields'], + ['page:card', 'actions'], + ['page:card', 'body'], + ['page:header', 'icon'], + ['page:tabs', 'type'], + ['record:details', 'layout'], + ]; + for (const [type, key] of AGED_OUT) { + expect(specListedKeys(type), `${type} lists ${key} again`).not.toContain(key); + expect(Object.keys(UNPUBLISHED_EXEMPTIONS), `${type}.${key} is exempted — an absent key needs no cover`).not.toContain(`${type}.${key}`); + expect(declaredInputs(type) ?? [], `${type} publishes ${key}, a key the contract refuses as unknown`).not.toContain(key); + } + }); + // ── the ARM direction (objectui#4971) ────────────────────────────────────── it.each(covered)('%s declares no arm the spec refuses outright', (type) => { diff --git a/ui/examples/schema-catalog/test/pageheader-with-actions.test.tsx b/ui/examples/schema-catalog/test/pageheader-with-actions.test.tsx index 349255d386f..a63e09f41ff 100644 --- a/ui/examples/schema-catalog/test/pageheader-with-actions.test.tsx +++ b/ui/examples/schema-catalog/test/pageheader-with-actions.test.tsx @@ -187,9 +187,13 @@ describe('the demo satisfies the `page:header` contract (objectui#3906)', () => expect(issuesOf(safeValidateSchema(demo()))).toEqual([]); }); - it('CONTROL — the row refuses the retired `icon` by name', () => { - const issues = issuesOf(row.safeParse({ ...demo().properties, icon: 'users' })); - expect(issues).toEqual(['icon: invalid_type']); + it('CONTROL — the row refuses the retired `icon` as an unknown key of the bag', () => { + // `icon`'s tombstone aged out of the row at spec 18.2 (objectstack#25767): + // the strict row reports it at the bag with `unrecognized_keys`, the + // message naming the key and the protocol upgrade guide. + const result = row.safeParse({ ...demo().properties, icon: 'users' }); + expect(issuesOf(result)).toEqual([': unrecognized_keys']); + expect(result.success ? '' : result.error.issues[0]?.message).toMatch(/`icon`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); it('CONTROL — the mirror refuses a misspelled key inside `properties`', () => { diff --git a/ui/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts b/ui/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts index f34b1bc8990..9efb9e4c484 100644 --- a/ui/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts +++ b/ui/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts @@ -138,15 +138,18 @@ describe('slotted-pages "customize only the header" example (objectui#11165)', ( }); it('CONTROL: the node the page used to teach is refused on `eyebrow` and `icon`', () => { + // Both as unknown keys of the bag now: `icon`'s 17.0.0 tombstone aged out + // of the row at spec 18.2 (objectstack#25767), so it is refused the way + // `eyebrow` always was — by the strict row, naming the key. const old = { ...header(), properties: { ...header().properties, eyebrow: 'ACCOUNT', icon: 'building-2' }, }; expect(issuesOf(safeValidateSchema(old)).sort()).toEqual( - ['properties.icon: invalid_type', 'properties: unrecognized_keys eyebrow'].sort(), + ['properties: unrecognized_keys eyebrow,icon'].sort(), ); expect(issuesOf(row.safeParse(old.properties)).sort()).toEqual( - ['icon: invalid_type', ': unrecognized_keys eyebrow'].sort(), + [': unrecognized_keys eyebrow,icon'].sort(), ); }); }); diff --git a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts index 7cadf5c590e..6313ce791c5 100644 --- a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -466,13 +466,12 @@ const ABSENCES: Record = { // A rung for one of these would relay a value into a void, and the void is // the finding: each is pinned here so that the day a reader appears, this // census reddens instead of the value silently not arriving. - striped: { kind: 'unread', reason: 'Retired by objectstack#7176 (maintainer-ruled 2026-08-10) after every objectui reader was measured as pass-through. Present only as the spec import that carries its tombstone.' }, - bordered: { kind: 'unread', reason: 'Retired by objectstack#7176, as `striped`.' }, + // `striped`, `bordered`, `virtualScroll`, `responsive` and `performance` sat + // here as `unread` while the spec import carried their tombstones; the + // tombstones aged out at spec 18.2 (objectstack#25767), so they are no longer + // `ListViewSchema` members and the census has nothing to declare for them. requiredPermissions: { kind: 'unread', reason: 'The ADR-0066 audience gate `@objectstack/spec` 18 adds to a list view (objectui#12030). The SERVER enforces it before the view is served: a user who lacks the capabilities is never listed the view and is refused it by name, so nothing on the ListView path has a value to consume.' }, - virtualScroll: { kind: 'unread', reason: 'Retired by objectstack#7176, as `striped`.' }, filterableFields: { kind: 'unread', reason: 'Retired by objectstack#24640 (maintainer ruling C, pulled into 18.0): no per-view Filter-panel whitelist replaces it. objectui#13118 removed its one reader, the whitelist branch of `ListView`\'s Filter-panel field list, and this relay\'s rung with it. Present only as the spec import that carries its tombstone from the pin bump that takes the retirement.' }, - responsive: { kind: 'unread', reason: 'Imported from the spec by reference; no objectui renderer reads it.' }, - performance: { kind: 'unread', reason: 'Imported from the spec by reference; no objectui renderer reads it.' }, pageName: { kind: 'unread', reason: 'Page-context key with no reader on the list path.' }, tabs: { kind: 'unread', reason: "Spec view-tab list. ListView has no reader — the object page's tab bar is `ViewTabBar`, driven by `buildViewTabs`, and the `tabs` readers in the tree belong to `plugin-detail`'s DetailView." }, dataSource: { kind: 'unread', reason: "The spec's per-element binding (declared on the list-view arm by objectui#11070). `ListView` has no read of `schema.dataSource`: the binding is resolved one layer up, by the registered `list-view` renderer `ListViewBlock` through `ElementDataSourceGate`, and `renderListView` bypasses that layer. It renders `ListView` directly with `schema={fullSchema}` and takes the ADAPTER as its separate `dataSource` argument (`ds`), not off the node." }, diff --git a/ui/packages/app-shell/src/views/metadata-admin/clientValidation.fieldRules.test.ts b/ui/packages/app-shell/src/views/metadata-admin/clientValidation.fieldRules.test.ts index d20acf79863..71f2154f240 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/clientValidation.fieldRules.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/clientValidation.fieldRules.test.ts @@ -60,18 +60,23 @@ describe('validateMetadataDraft — object field conditional rules (real engine) }); // `conditionalRequired` is no longer one of OUR lint keys — @objectstack/spec - // 17 (#3855) retired it, so the draft is rejected by the spec parse itself, - // and the rejection carries the rename prescription. We assert the author - // still gets told, and told by the spec (AGENTS.md #0.1 — one contract). - it('rejects the retired conditionalRequired alias via the spec tombstone', async () => { + // 17 (#3855) retired it, so the draft is rejected by the spec parse itself. + // Through spec 18.1 the rejection was the tombstone's, at the key's own path, + // carrying the rename prescription; at 18.2 the tombstone aged out one major + // on (objectstack#25767) and the strict field refuses the key as an unknown + // one at the field's path, naming it and the protocol upgrade guide. We + // assert the author still gets told, and told by the spec (AGENTS.md #0.1 — + // one contract). + it('rejects the retired conditionalRequired alias via the spec\'s strict unknown-key refusal', async () => { const res = await validateMetadataDraft( 'object', draftWith({ status: { type: 'text', conditionalRequired: 'record.x == 1' } }), ); expect(res.ok).toBe(false); - const issue = res.issues.find((i) => i.path === 'fields.status.conditionalRequired'); - expect(issue).toBeTruthy(); - expect(issue!.message).toMatch(/requiredWhen/); + const issue = res.issues.find((i) => i.path === 'fields.status' && /`conditionalRequired`/.test(i.message)); + expect(issue, JSON.stringify(res.issues)).toBeTruthy(); + expect(issue!.message).toMatch(/Unrecognized key\(s\)/); + expect(issue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('uses index paths for the ARRAY fields shape', async () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.contractKeys-11968.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.contractKeys-11968.test.tsx index 65a76353785..724aa122bcb 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.contractKeys-11968.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.contractKeys-11968.test.tsx @@ -315,9 +315,12 @@ describe('the contract walk can fail, at every depth it judges (objectui#11968)' expect(violationsOf('wait', [field(['waitEventConfig', 'bogusKey'])])).toEqual([ "wait writes waitEventConfig.bogusKey: 'bogusKey' is not declared in FlowNodeSchema", ]); - // `script.template` survives in the shape only as a `[REMOVED]` tombstone. + // `script.template` survived in the shape as a `[REMOVED]` tombstone through + // spec 18.1; at 18.2 the tombstone aged out one major on (objectstack#25767), + // so the walk reads the key as undeclared — the same verdict as a stray key, + // which is the contract's verdict now too (a strict unknown-key refusal). expect(violationsOf('script', [field(['config', 'template'])])).toEqual([ - "script (node type 'script') writes config.template: 'template' is a retired tombstone in its config contract", + "script (node type 'script') writes config.template: 'template' is not declared in its config contract", ]); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts index 088df3baa66..604268f9e99 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts @@ -312,13 +312,16 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the instruments (objectui#8216 // The descent is derived, so its non-vacuity is measured on both sides: a // public-block arm resolves to its bag — the designer's `title` control is // a declared member there, and the bag still carries the spec's tombstone - // for `icon` — while a top-level arm is untouched by it. + // for `breadcrumb` (17.x) — while a top-level arm is untouched by it. The + // `icon` tombstone (17.0.0) aged out of the bag at spec 18.2 + // (objectstack#25767), so the bag reads it as MISSING now. const header = NODE_ORACLES['page:header']; expect(header, 'no node arm resolved for page:header').toBeTruthy(); expect(listedShapeKeys(header)).toContain('title'); expect(listedShapeKeys(header), 'judged at the node, not at its bag').not.toContain('properties'); expect(judge(header, 'title')).toBeUndefined(); - expect(judge(header, 'icon')?.kind).toBe('RETIRED'); + expect(judge(header, 'breadcrumb')?.kind).toBe('RETIRED'); + expect(judge(header, 'icon')?.kind).toBe('MISSING'); expect(listedShapeKeys(NODE_ORACLES['object-kanban'])).toContain('groupBy'); expect(listedShapeKeys(NODE_ORACLES['object-kanban'])).toContain('type'); }); @@ -329,11 +332,15 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the instruments (objectui#8216 }); it('the RETIRED probe separates a tombstone from an ordinary member', () => { - // `PageHeaderProps.icon` is the canonical tombstone (objectui#3829): still a - // listed key, refused by name. Without this pair the retired direction could - // be reporting nothing at all and read identically. - expect(listedShapeKeys(SPEC_ORACLES['page:header'])).toContain('icon'); - expect(judge(SPEC_ORACLES['page:header'], 'icon')?.kind).toBe('RETIRED'); + // `PageHeaderProps.breadcrumb` is the tombstone the pin still carries + // (objectstack#20758, spec 17.x): a listed key, refused by name. Without + // this pair the retired direction could be reporting nothing at all and + // read identically. (`icon`, the canonical tombstone of objectui#3829, aged + // out at spec 18.2 — objectstack#25767 — and reads MISSING now.) + expect(listedShapeKeys(SPEC_ORACLES['page:header'])).toContain('breadcrumb'); + expect(judge(SPEC_ORACLES['page:header'], 'breadcrumb')?.kind).toBe('RETIRED'); + expect(listedShapeKeys(SPEC_ORACLES['page:header'])).not.toContain('icon'); + expect(judge(SPEC_ORACLES['page:header'], 'icon')?.kind).toBe('MISSING'); expect(shapeMemberTypeName(SPEC_ORACLES['page:header'], 'title')).toBeTruthy(); // …and on the node face, where `retirementTombstone()` lives. expect(judge(NODE_ORACLES['object-kanban'], 'groupField')?.kind).toBe('RETIRED'); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index 75ceddb0e8e..19db07e6f87 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -431,12 +431,23 @@ describe('page:header `icon` — the designer field retired with the spec key (# // criterion is `@object-ui/test-support`'s shared judge (objectui#3809, // converged here by objectui#4947), which OR-s the structural channel this // block used to spell out by hand with the `[REMOVED]` description channel. - it('the spec tombstones `icon` rather than deleting it', () => { - expect(listedShapeKeys(PageHeaderProps)).toContain('icon'); - expect(isShapeKeyTombstoned(PageHeaderProps, 'icon')).toBe(true); - // Non-vacuity for the probe itself: a Zod-internals change that made every - // member read `'never'` would make the line above meaningless, and a live - // key is the only thing that can tell the difference. + it('the spec aged the `icon` tombstone out — the key is no longer listed, and the strict row refuses it as unknown', () => { + // Through spec 18.1 the ADR-0087 D2 tombstone kept `icon` IN the shape as + // `z.never()`; at 18.2 the ≤16.x tombstones aged out one major on + // (objectstack#25767), so `icon` is not a key of the shape at all and the + // strict row refuses it as an unknown key naming the protocol upgrade guide. + expect(listedShapeKeys(PageHeaderProps)).not.toContain('icon'); + const refused = PageHeaderProps.safeParse({ title: 'T', icon: 'users' }); + expect(refused.success).toBe(false); + const root = refused.success ? '' : refused.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(/Unrecognized key\(s\)[^\n]*`icon`/); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + // The tombstone probe's own non-vacuity moves to a tombstone the pin still + // carries: `breadcrumb` (spec 17.x, objectstack#20758) is listed AND reads + // as tombstoned, and a live key is the only thing that can tell the + // difference from a Zod-internals change that made every member `'never'`. + expect(listedShapeKeys(PageHeaderProps)).toContain('breadcrumb'); + expect(isShapeKeyTombstoned(PageHeaderProps, 'breadcrumb')).toBe(true); expect(isShapeKeyTombstoned(PageHeaderProps, 'title')).toBe(false); expect(shapeMemberTypeName(PageHeaderProps, 'title')).toBeTruthy(); }); diff --git a/ui/packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts b/ui/packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts index 63a610675d4..c09fd5db014 100644 --- a/ui/packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts +++ b/ui/packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts @@ -119,22 +119,12 @@ const POSITIONS: ReadonlyArray<{ }), probe: (p) => p.regions[0].components[0].properties.items[0].children[0].properties, }, - { - name: 'page:card → properties.body[]', - page: recordPage({ - regions: [ - { name: 'main', components: [{ type: 'page:card', properties: { body: [legacyHeader('card body line')] } }] }, - ], - }), - // `page-card-body-to-children` MOVES the container key on the way past, so - // the converted node carries the sub-tree under `children`. Read whichever - // spelling survives — this row is about the header inside, not about which - // key the card ends up using. - probe: (p) => { - const props = p.regions[0].components[0].properties; - return (props.children ?? props.body)[0].properties; - }, - }, + // `page:card → properties.body[]` was a row here until spec 18.2: the + // `page-card-body-to-children` conversion that moved the container key on + // the way past aged out with the 16.x kits (objectstack#25767, ADR-0087), so + // a `body` sub-tree is no longer walked — the strict spec refuses `body` on + // the card as an unknown key naming the upgrade guide, and nothing descends + // into it. The surviving rows cover every container key the walk reads. { name: 'page:card → properties.footer[]', page: recordPage({ diff --git a/ui/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.retiredActionKeys.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.retiredActionKeys.test.tsx index 7fdcec146b8..b0992cf1fd1 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.retiredActionKeys.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.retiredActionKeys.test.tsx @@ -11,9 +11,12 @@ * action key (objectstack#7129). * * `actionUrl` / `actionType` / `actionIcon` were retired at the widget level in - * @objectstack/spec 17.0.0-rc.3 (objectstack#5010, ADR-0049 D2). They are - * `retiredKey` tombstones: `DashboardWidgetSchema` types them `never` and - * REFUSES any value — including `''`. Until this pin, `WidgetConfigPanel` + * @objectstack/spec 17.0.0-rc.3 (objectstack#5010, ADR-0049 D2). Through 18.1 + * they were `retiredKey` tombstones: `DashboardWidgetSchema` typed them `never` + * and refused any value — including `''` — at the key's own path. At 18.2 the + * tombstones aged out one major on (objectstack#25767): the strict container + * refuses each as an unknown key, at the widget's path, naming the key and the + * protocol upgrade guide. Either way the spec refuses. Until this pin, `WidgetConfigPanel` * offered a Behavior-group "Click-through URL" field bound to `actionUrl`, and * `DashboardWithConfig` seeded `actionUrl: widget.actionUrl ?? ''` into every * widget config it handed the panel — so every save from Studio persisted a @@ -65,7 +68,17 @@ describe('the spec really does refuse these keys (premise check)', () => { const result = DashboardWidgetSchema.safeParse({ ...SPEC_VALID_WIDGET, [key]: value }); expect(result.success, `'${key}': ${JSON.stringify(value)} must be refused`).toBe(false); if (!result.success) { - expect(result.error.issues.some((i) => i.path[0] === key)).toBe(true); + // The strict unknown-key refusal (objectstack#25767): one issue at the + // widget's own path (`[]`), naming the key and the upgrade guide. The + // tombstone it replaced reported at `[key]`; a pin on that path would + // read the age-out as "no longer refused", which is the opposite of + // what happened. + const refusal = result.error.issues.find( + (i) => i.path.length === 0 && i.message.includes(`\`${key}\``), + ); + expect(refusal, `'${key}': refused by name at the widget's path`).toBeTruthy(); + expect(refusal!.message).toMatch(/Unrecognized key\(s\)/); + expect(refusal!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); } } } diff --git a/ui/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx index b975bfff57f..3f34856c793 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx @@ -270,14 +270,16 @@ describe('the two ruled-out keys stay unpublished — and checkably so (objectui expect(unrecognizedKeys(result)).toContain('title'); }); - it('the spec refuses every `aria` value — the tombstone is still standing', () => { + it('the spec refuses every `aria` value — as an unknown key now that its tombstone aged out (objectstack#25767)', () => { const result = specVerdict({ aria: { ariaLabel: 'Ops' } }); expect(result.success, 'the spec re-admitted dashboard.aria — re-open objectui#5742').toBe(false); + // Through spec 18.1 the tombstone reported at `aria`; the strict + // dashboard now reports at its own path, naming the key and the guide. const ariaIssue = (result as { error: { issues: Array<{ path: unknown[]; message: string }> } }) - .error.issues.find((i) => i.path.join('.') === 'aria'); - expect(ariaIssue, 'no issue at path `aria`').toBeTruthy(); - // The tombstone names the removal; a mere shape error would not. - expect(ariaIssue!.message).toMatch(/removed/); + .error.issues.find((i) => i.path.length === 0 && i.message.includes('`aria`')); + expect(ariaIssue, 'no root issue naming `aria`').toBeTruthy(); + expect(ariaIssue!.message).toMatch(/Unrecognized key\(s\)/); + expect(ariaIssue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it.each(NON_AUTHOR)('$key draws unknown-prop from the real validator — the ruled outcome', ({ key, sample }) => { diff --git a/ui/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts b/ui/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts index 7de2286bd33..74bf57f08e9 100644 --- a/ui/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts +++ b/ui/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts @@ -540,23 +540,26 @@ describe('record:details — registry inputs vs @objectstack/spec', () => { * legible if that helper is ever loosened. */ describe('record:details — `layout` is retired, not merely undocumented (#3818)', () => { - it('the spec REJECTS `layout`, with the ADR-0087 migration message', () => { - // The premise, checked first: if upstream ever un-retires the key, this - // fails before the negative pins below start guarding a dead rule. - expect(specTopLevelKeys()).toContain('layout'); // tombstone is still declared - expect(isTombstoned('layout')).toBe(true); + it('the spec REJECTS `layout` — as an unknown key naming the upgrade guide, its tombstone aged out', () => { + // The premise, checked first: if upstream ever re-declares the key, this + // fails before the negative pins below start guarding a dead rule. Through + // spec 18.1 the tombstone was still declared (`z.never()`); at 18.2 it aged + // out of the row one major on (objectstack#25767), so the key is not + // declared at all and the strict row refuses it as an unknown key. + expect(specTopLevelKeys()).not.toContain('layout'); expect(specAcceptedTopLevelKeys()).not.toContain('layout'); // A rejection case, so the assertion set is the envelope and not a bare - // "it failed": path + code + the named migration text. Both formerly - // published values AND both values the dead renderer branch tested — all - // four are unauthorable now, which is the whole point. + // "it failed": path + code + the text that sends the author on. Both + // formerly published values AND both values the dead renderer branch + // tested — all four are unauthorable now, which is the whole point. for (const value of ['auto', 'custom', 'inline', 'compact']) { const parsed = RecordDetailsProps.safeParse({ layout: value }); expect(parsed.success).toBe(false); - expect(parsed.error?.issues.map((i) => i.path.join('.'))).toContain('layout'); - expect(parsed.error?.issues.map((i) => i.code)).toContain('invalid_type'); - expect(parsed.error?.issues[0]?.message).toMatch(/removed in @objectstack\/spec 17\.0\.0/); + const root = parsed.error?.issues.find((i) => i.path.length === 0 && i.message.includes('`layout`')); + expect(root?.code).toBe('unrecognized_keys'); + expect(root?.message).toMatch(/Unrecognized key\(s\)/); + expect(root?.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); } }); diff --git a/ui/packages/react/src/hooks/__tests__/useActionEngine.test.ts b/ui/packages/react/src/hooks/__tests__/useActionEngine.test.ts index 326ec21021e..8a1bef58501 100644 --- a/ui/packages/react/src/hooks/__tests__/useActionEngine.test.ts +++ b/ui/packages/react/src/hooks/__tests__/useActionEngine.test.ts @@ -37,10 +37,20 @@ describe('useActionEngine', () => { type: 'url', target: '/details', locations: ['list_item'], + // @ts-expect-error `shortcut` is retired on `ActionDef` too, and for the + // same reason as `bulkEnabled` above: the inverse test needs metadata + // that still declares one. On its OWN entry rather than beside + // `global_nav` below: an object literal with a mistyped member is not + // also checked for excess members, so a directive on the second of two + // stale keys in one literal reports unused (TS2578) once `shortcut` + // stops being a declared `never` and becomes an unknown key — which it + // did when its spec tombstone aged out and `ActionDef` stopped + // mirroring it (objectstack#25767). + shortcut: 'ctrl+k', }, { // Deliberately STALE, in the same posture as the `bulkEnabled` and - // `shortcut` keys this fixture also keeps: `global_nav` was retired from + // `shortcut` keys this fixture keeps on other entries: `global_nav` was retired from // the spec's `ACTION_LOCATIONS` in @objectstack/spec 17.0.0-rc.6 // (objectstack#6888), so metadata like this can no longer be authored. // Kept rather than deleted because the coverage that matters now is the @@ -55,10 +65,6 @@ describe('useActionEngine', () => { // pins it: were the location ever re-admitted to the spec, this line // would stop erroring and TS2578 would report the now-unused directive. locations: ['global_nav'], - // @ts-expect-error `shortcut` is retired on `ActionDef` too, and for the - // same reason as `bulkEnabled` above: the inverse test needs metadata - // that still declares one. - shortcut: 'ctrl+k', }, ]; diff --git a/ui/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts b/ui/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts index 46813110867..fbac8f268bb 100644 --- a/ui/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts +++ b/ui/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts @@ -29,16 +29,27 @@ * objectui#9736 closed that half. The interface now extends the spec's own * `Dashboard` input type (`Omit< Dashboard, … >` over the exclusion list its * mirror reads), so it inherits the spec's tombstone as a DECLARED member typed - * as the spec's retired key (the branded `[REMOVED]` mark at the pinned 18.1.0 - * and on objectstack `main`, bare `undefined` through 17.5.0) — `aria` is back in the declared key - * set, but only as a refusal, - * and an authored value is a compile error: the same verdict the Zod twin gives - * at parse. The pins below assert exactly that, with the former neighbours as - * the control. Real enforcement because `packages/types/tsconfig.test.json` is - * chained from this package's `type-check` script (#3009). + * as the spec's retired key (the branded `[REMOVED]` mark at the pinned 18.1.0, + * bare `undefined` through 17.5.0) — `aria` was back in the declared key set, + * but only as a refusal, and an authored value a compile error: the same + * verdict the Zod twin gave at parse. + * + * objectstack#25767 (spec 18.2, ADR-0087) aged the ≤16.x tombstones out of the + * spec one major on. `DashboardSchema` no longer declares `aria` at all: the + * strict container refuses it as an unknown key, at the dashboard's own path, + * naming the key and the protocol upgrade guide. The projection carries nothing + * under the name, so both twins now carry the refusal as their OWN: `aria?: + * never` on the interface, `agedOutSpecKeyRefusal` on the Zod twin (whose + * `.passthrough()` base would otherwise turn a two-major refusal into a silent + * KEEP). The pins below assert exactly that — declared, unwritable, refused at + * its own path with the console's message — with the former neighbours as the + * control and the spec's own refusal as the contract leg. Real enforcement + * because `packages/types/tsconfig.test.json` is chained from this package's + * `type-check` script (#3009). */ import { describe, it, expect } from 'vitest'; +import { DashboardSchema as SpecDashboardSchema } from '@objectstack/spec/ui'; import type { DashboardComponentSchema } from '../complex'; import type { IsRetiredKeyType } from './retired-key-type'; import { DashboardComponentSchema as DashboardComponentZodSchema } from '../zod/index.zod'; @@ -48,13 +59,15 @@ import { DashboardComponentSchema as DashboardComponentZodSchema } from '../zod/ type DeclaredKeys = { [K in keyof T as string extends K ? never : K]: T[K] }; type Declared = keyof DeclaredKeys; -describe('the TS interface declares `aria` only as the spec tombstone (objectui#5830, objectui#9736)', () => { - it('`aria` is declared, typed as the spec\'s retired key; the neighbours it stood beside still are', () => { - // Type-level pins, erased at runtime. `aria` is a DECLARED key again — the - // spec projection carries the tombstone — and its type admits no value: - // the branded `[REMOVED]` mark at the pinned 18.1.0 and on objectstack - // `main`, bare `undefined` through 17.5.0 (both spellings are - // `retired-key-type.ts`'s, objectui#11330). +describe('the TS interface declares `aria` only as a refusal — its own, since the spec tombstone aged out (objectui#5830, objectui#9736, objectstack#25767)', () => { + it('`aria` is declared and admits no value; the neighbours it stood beside still are live', () => { + // Type-level pins, erased at runtime. Through spec 18.1 `aria` was a + // DECLARED key typed as the spec's retired key (the branded `[REMOVED]` + // mark, bare `undefined` through 17.5.0 — both `retired-key-type.ts`'s + // spellings, objectui#11330). At 18.2 the tombstone aged out of the spec + // (objectstack#25767), so the projection carries nothing under the name + // and the member is this face's own `aria?: never` — which the same + // helper reads as a retired-key type (`undefined`, the pinned spelling). const ariaDeclared: 'aria' extends Declared ? true : false = true; const ariaAdmitsNoValue: IsRetiredKeyType extends true ? true : false = true; // The control for the line above, through the same helper: a live @@ -67,15 +80,15 @@ describe('the TS interface declares `aria` only as the spec tombstone (objectui# expect(ariaDeclared && ariaAdmitsNoValue && widgetsIsLive && widgetsDeclared && dateRangeDeclared).toBe(true); }); - it('an authored `aria` value is a compile error — no longer absorbed by the index signature', () => { + it('an authored `aria` value is a compile error — the member is `never`, and no index signature absorbs it', () => { const legal: DashboardComponentSchema = { type: 'dashboard', widgets: [] }; - // @ts-expect-error — `aria` is the spec's retirement tombstone (objectui#9736). + // @ts-expect-error — `aria` is this face's own `?: never` refusal: the spec tombstone it projected aged out (objectstack#25767), and no index signature absorbs it (objectui#9736). const authored: DashboardComponentSchema = { type: 'dashboard', widgets: [], aria: { ariaLabel: 'Ops' } }; expect([legal.type, authored.type]).toEqual(['dashboard', 'dashboard']); }); }); -describe('the Zod twin refuses the key by name — the spec tombstone, inherited by reference', () => { +describe('the Zod twin refuses `aria` by name — its own arm now; the spec contract refuses it as an unknown key', () => { const legal = { type: 'dashboard' as const, widgets: [] }; it('a legal dashboard parses green — the control for the refusal below', () => { @@ -90,12 +103,41 @@ describe('the Zod twin refuses the key by name — the spec tombstone, inherited expect(r.success).toBe(true); }); - it('`aria` is refused at its own path, with the removal message', () => { + it('`aria` is refused at its own path, with the console\'s own message naming the key, the guide and the cut', () => { + // Through 18.1 the refusal was the spec's tombstone, inherited by + // reference (`/removed/` in the message). The twin is `.passthrough()`, + // so once the projection stopped carrying the name only an arm of its own + // keeps the verdict: `agedOutSpecKeyRefusal` (`zod/tombstone.zod.ts`), + // `invalid_type` at `aria`, opening with the sentence the spec answers the + // key with. Pinned on the message's three anchors so a twin that quietly + // dropped the arm (a silent keep) or restated the spec's gone tombstone + // goes red by name. const r = DashboardComponentZodSchema.safeParse({ ...legal, aria: { ariaLabel: 'Ops' } }); expect(r.success).toBe(false); if (r.success) return; const issue = r.error.issues.find((i) => i.path.join('.') === 'aria'); expect(issue, 'no issue at path `aria`').toBeTruthy(); - expect(issue!.message).toMatch(/removed/); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toContain('Unrecognized key(s) on this dashboard: `aria`'); + expect(issue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(issue!.message).toContain('objectstack#25767'); + expect(issue!.message).not.toMatch(/\[REMOVED\]/); + }); + + it('the spec contract refuses `aria` as an unknown key at the dashboard\'s path, naming it and the upgrade guide (objectstack#25767)', () => { + // The contract leg: where the refusal lives now. `DashboardSchema` is + // `.strict()`, so the aged-out key meets the unknown-key refusal — one + // issue at the root, `unrecognized_keys`, naming the key and the protocol + // upgrade guide an author of a ≤16.x document is sent to. + const r = SpecDashboardSchema.safeParse({ name: 'ops', label: 'Ops', widgets: [], aria: { ariaLabel: 'Ops' } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path.length === 0 && i.message.includes('`aria`')); + expect(issue, 'no root issue naming `aria`').toBeTruthy(); + expect(issue!.code).toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/Unrecognized key\(s\)/); + expect(issue!.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + // Control: the same document without the key is the contract's own green. + expect(SpecDashboardSchema.safeParse({ name: 'ops', label: 'Ops', widgets: [] }).success).toBe(true); }); }); diff --git a/ui/packages/types/src/__tests__/flat-props-refusal-10872.test.ts b/ui/packages/types/src/__tests__/flat-props-refusal-10872.test.ts index fa8f470e5f6..68ba80949ba 100644 --- a/ui/packages/types/src/__tests__/flat-props-refusal-10872.test.ts +++ b/ui/packages/types/src/__tests__/flat-props-refusal-10872.test.ts @@ -339,7 +339,12 @@ describe('objectui#10872 batch 10 — what the refusal leaves alone', () => { }); it('some row members are the spec\'s own retirements (non-vacuity)', () => { - expect(RETIRED.map(([label]) => label)).toContain('page:header.icon'); + // `page:header.breadcrumb`, retired at spec 17.x, is the row member that + // still carries a tombstone; `page:header.icon` (retired at 17.0.0) aged out + // of the shape one major on (objectstack#25767) and is an unknown key there + // now, so it is not a RETIRED row member any more. + expect(RETIRED.map(([label]) => label)).toContain('page:header.breadcrumb'); + expect(RETIRED.map(([label]) => label)).not.toContain('page:header.icon'); }); it.each(RETIRED.filter(([label]) => label !== 'page:card.body'))( diff --git a/ui/packages/types/src/__tests__/node-slots-11170.test.ts b/ui/packages/types/src/__tests__/node-slots-11170.test.ts index 2a114aaa9ec..c364ed23adb 100644 --- a/ui/packages/types/src/__tests__/node-slots-11170.test.ts +++ b/ui/packages/types/src/__tests__/node-slots-11170.test.ts @@ -182,6 +182,16 @@ describe('the `page:*` rows are the spec’s positions, by reference (objectui#1 for (const type of row.types) expect(type.startsWith(PAGE_FAMILY_PREFIX), type).toBe(true); for (const s of row.slots) { const spec = bySpecPath.get(s.path); + if (s.retired === true && !spec) { + // A RETIRED row position the spec no longer declares at all: its + // tombstone aged out of the spec one major on (objectstack#25767 — + // `page:card`'s `body`), so the spec's list has no row for it, while + // the renderer still reads the stored spelling (see the module + // header). Only a `retired` position may be console-own this way; a + // live one the spec does not declare is still a red below. + expect(s.path, `\`${row.types[0]}.${s.path}\`: a retired position the spec dropped — only \`body\` is read this way`).toBe('body'); + continue; + } expect(spec, `\`${row.types[0]}\` declares \`${s.path}\`, which the installed spec does not`).toBeDefined(); expect(s.retired === true, `\`${row.types[0]}.${s.path}\`: retirement disagrees with the spec`).toBe(spec!.retired); } @@ -192,7 +202,9 @@ describe('the `page:*` rows are the spec’s positions, by reference (objectui#1 const declared = new Set(pageRows.flatMap((row) => row.slots.map((s) => s.path))); const unplaced = specPositions.map(asPath).filter((p) => p !== 'children' && !declared.has(p)); expect(unplaced, 'the spec declares a page slot no `page:*` row places — measure which renderer reads it').toEqual([]); - // The reading has a population: the spec's list carries the known four. - expect(specPositions.map(asPath)).toEqual(expect.arrayContaining(['children', 'body', 'footer', 'items[].children'])); + // The reading has a population: the spec's list carries the known three + // (`body` left it when its tombstone aged out, objectstack#25767). + expect(specPositions.map(asPath)).toEqual(expect.arrayContaining(['children', 'footer', 'items[].children'])); + expect(specPositions.map(asPath)).not.toContain('body'); }); }); diff --git a/ui/packages/types/src/__tests__/object-view-spec-parity.test.ts b/ui/packages/types/src/__tests__/object-view-spec-parity.test.ts index 066f5072d0c..49cff8bf305 100644 --- a/ui/packages/types/src/__tests__/object-view-spec-parity.test.ts +++ b/ui/packages/types/src/__tests__/object-view-spec-parity.test.ts @@ -364,16 +364,34 @@ describe('the ADR-0087 tombstone filter this guard is built on (#2890)', () => { }); it('reports retired ListView keys as DECLARED but not LIVE', () => { - // objectstack#7176 retired all five (maintainer-ruled 2026-08-10). They flow - // into objectui's ListViewSchema by reference on purpose, tombstones and - // all, so an author writing one gets a named rejection instead of silence. - for (const retired of ['striped', 'bordered', 'virtualScroll', 'responsive', 'performance']) { + // The tombstones the current pin still carries: `filterableFields` + // (objectstack#24640), `pageName` and `tabs` (17.5.0). They flow into + // objectui's ListViewSchema by reference on purpose, tombstones and all, so + // an author writing one gets a named rejection instead of silence. + for (const retired of ['filterableFields', 'pageName', 'tabs']) { expect(declared, `${retired} should still be declared`).toContain(retired); expect(isTombstoned(SpecListViewSchema, retired), `${retired} should read as a tombstone`).toBe(true); expect(live, `${retired} must not read as live`).not.toContain(retired); } }); + it('the five objectstack#7176 retirements aged out of the shape — not declared, refused as unknown keys', () => { + // `striped`, `bordered`, `virtualScroll`, `responsive` and `performance` + // were tombstones through spec 18.1; at 18.2 the ≤16.x kits left the schema + // one major on (objectstack#25767), so the filter no longer sees them — + // neither declared nor live — and the strict container refuses each as an + // unknown key naming the protocol upgrade guide. + for (const aged of ['striped', 'bordered', 'virtualScroll', 'responsive', 'performance']) { + expect(declared, `${aged} is declared again`).not.toContain(aged); + expect(live, `${aged} reads as live`).not.toContain(aged); + const r = SpecListViewSchema.safeParse({ name: 'all', label: 'All', [aged]: true }); + expect(r.success, `${aged} is accepted`).toBe(false); + const msg = r.success ? '' : r.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(msg).toMatch(new RegExp('Unrecognized key\\(s\\)[^\\n]*`' + aged + '`')); + expect(msg).toContain('https://objectstack.ai/docs/protocol-upgrade'); + } + }); + it('does not mistake a live key for a tombstone', () => { // The control. Without it "everything is a tombstone" would pass the above. for (const alive of ['columns', 'filter', 'userActions', 'navigation', 'searchableFields']) { diff --git a/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 4edea928edd..f0539cc1580 100644 --- a/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -381,26 +381,38 @@ const NAMED_VIEW_ABSENT_CONTROL = 'stickyHeader'; * spec bump that moves a key moves this. */ const PROTOCOL_LIST_VIEW_KEYS = [ - 'addRecord', 'allowPrinting', 'appearance', 'aria', 'bordered', 'bulkActionDefs', 'bulkActions', + 'addRecord', 'allowPrinting', 'appearance', 'aria', 'bulkActionDefs', 'bulkActions', 'calendar', 'chart', 'columns', 'compactToolbar', 'conditionalFormatting', 'data', 'description', 'emptyState', 'exportOptions', 'fieldOrder', 'filter', 'filterableFields', 'gallery', 'gantt', 'grouping', 'hiddenFields', 'inlineEdit', 'kanban', 'label', 'map', 'name', 'navigation', - 'pageName', 'pagination', 'performance', 'requiredPermissions', 'resizable', 'responsive', 'rowActions', 'rowColor', - 'rowHeight', 'searchableFields', 'selection', 'sharing', 'showRecordCount', 'sort', 'striped', - 'tabs', 'timeline', 'tree', 'type', 'userActions', 'userFilters', 'virtualScroll', + 'pageName', 'pagination', 'requiredPermissions', 'resizable', 'rowActions', 'rowColor', + 'rowHeight', 'searchableFields', 'selection', 'sharing', 'showRecordCount', 'sort', + 'tabs', 'timeline', 'tree', 'type', 'userActions', 'userFilters', ] as const; /** - * Eight of those 51 are RETIREMENT TOMBSTONES, refused by name with the - * protocol's own `[REMOVED]` text (seven until the pin move to - * `@objectstack/spec` 18.0.0 took the retirement of `filterableFields`, - * objectui#13261; five until `@objectstack/spec` 17.5.0 retired `pageName` and - * `tabs`, objectui#11073). ⚠️ They are why a raw key-count diff reads - * objectui as "narrower" and is wrong to: not declaring a key the protocol - * refuses is agreement, not a gap. Derived by the tombstone's own marker, not - * listed by hand. + * Three of those 46 are RETIREMENT TOMBSTONES, refused by name with the + * protocol's own `[REMOVED]` text (`filterableFields` since the pin move to + * `@objectstack/spec` 18.0.0 took its retirement, objectui#13261; `pageName` + * and `tabs` since 17.5.0, objectui#11073). Eight until spec 18.2: the five + * objectstack#7176 retirements (`bordered`, `performance`, `responsive`, + * `striped`, `virtualScroll`) were tombstones through 18.1 and AGED OUT of the + * shape one major on (objectstack#25767, ADR-0087) — the protocol declares + * nothing under those names now and refuses each as an unknown key naming the + * protocol upgrade guide ({@link PROTOCOL_AGED_OUT_KEYS}). ⚠️ The tombstones + * are why a raw key-count diff reads objectui as "narrower" and is wrong to: + * not declaring a key the protocol refuses is agreement, not a gap. Derived by + * the tombstone's own marker, not listed by hand. */ -const PROTOCOL_RETIRED_KEYS = ['bordered', 'filterableFields', 'pageName', 'performance', 'responsive', 'striped', 'tabs', 'virtualScroll'] as const; +const PROTOCOL_RETIRED_KEYS = ['filterableFields', 'pageName', 'tabs'] as const; + +/** + * The five objectstack#7176 retirements after their tombstones aged out + * (objectstack#25767): not members of the protocol's shape any more, refused + * as unknown keys. Listed so the age-out is a reading here, not a silent + * shrink of {@link PROTOCOL_LIST_VIEW_KEYS}. + */ +const PROTOCOL_AGED_OUT_KEYS = ['bordered', 'performance', 'responsive', 'striped', 'virtualScroll'] as const; /** * The FIFTH bucket — LIVE protocol members DECLARED here and read off no named view BY @@ -1784,22 +1796,39 @@ function foldFlagMap(): Record { } describe('objectui#7924 — the unread members, in the FOUR buckets the ruling was written in plus the server-enforced fifth (objectui#12341) and the retired-ahead-of-the-pin sixth (objectui#13118), derived against the installed protocol (bucket ① empty since objectui#10758)', () => { - it('the protocol declares 51 keys for a named list view, and the instrument that reads them fires', () => { + it('the protocol declares 46 keys for a named list view, and the instrument that reads them fires', () => { const keys = protocolListViewKeys(); expect(keys).toEqual([...PROTOCOL_LIST_VIEW_KEYS]); - expect(keys).toHaveLength(51); + // 46 since spec 18.2 aged the five objectstack#7176 tombstones out + // (objectstack#25767); 51 through 18.1. + expect(keys).toHaveLength(46); + for (const aged of PROTOCOL_AGED_OUT_KEYS) expect(keys, `${aged} is declared again`).not.toContain(aged); // Firing control + its negative twin, on the same query that produces every // "the protocol declares this nowhere" zero below. expect(keys).toContain(NAMED_VIEW_READ_CONTROL); expect(keys).not.toContain(NAMED_VIEW_ABSENT_CONTROL); }); - it('eight of the 51 are RETIREMENT TOMBSTONES, refused BY NAME — so objectui not declaring them LIVE is agreement, not narrowness', () => { + it('three of the 46 are RETIREMENT TOMBSTONES, refused BY NAME — so objectui not declaring them LIVE is agreement, not narrowness', () => { expect(protocolRetiredKeys()).toEqual([...PROTOCOL_RETIRED_KEYS]); const base = { label: 'Directory', columns: ['name', 'email'] }; // The accept leg first: without it every refusal below is a schema that - // refuses everything rather than a reading of these eight keys. + // refuses everything rather than a reading of these keys. expect(SpecObjectListViewSchema.safeParse(base).success).toBe(true); + // The five aged-out keys (objectstack#25767): refused too, but as UNKNOWN + // keys at the view's own path, the one issue naming the key and the + // protocol upgrade guide — a deleted member's refusal, by design now. + for (const key of PROTOCOL_AGED_OUT_KEYS) { + const r = SpecObjectListViewSchema.safeParse({ ...base, [key]: true }); + expect(r.success, `the protocol accepts the aged-out \`${key}\``).toBe(false); + if (!r.success) { + const issues = r.error.issues as readonly Issue[]; + expect(refusedAt(issues, key)).toBe(false); + const root = issues.filter((i) => (i.path ?? []).length === 0).map((i) => i.message ?? '').join('\n'); + expect(root).toMatch(new RegExp('Unrecognized key\\(s\\)[^\\n]*`' + key + '`')); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + } + } for (const key of PROTOCOL_RETIRED_KEYS) { const r = SpecObjectListViewSchema.safeParse({ ...base, [key]: true }); expect(r.success, `the protocol no longer refuses the retired \`${key}\``).toBe(false); @@ -1812,10 +1841,10 @@ describe('objectui#7924 — the unread members, in the FOUR buckets the ruling w || (i.message ?? '').includes('was removed in @objectstack/spec'))).toBe(true); } } - // …and none of the eight is declared LIVE on the objectui face. Five were - // never declared; the two objectui#8980 declared are `?: never` tombstones - // since objectui#11073, and `filterableFields` since objectui#13118, each - // refused by name on this face as on the protocol's. + // …and none of the three is declared LIVE on the objectui face: the two + // objectui#8980 declared are `?: never` tombstones since objectui#11073, + // and `filterableFields` since objectui#13118, each refused by name on this + // face as on the protocol's. (The five aged-out keys were never declared.) const { names, tombstones } = namedListViewMembers(); const declaredLive = names.filter((n) => !tombstones.includes(n)); expect(PROTOCOL_RETIRED_KEYS.filter((k) => declaredLive.includes(k))).toEqual([]); diff --git a/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts b/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts index f2c10756ce1..48700e708b1 100644 --- a/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts +++ b/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts @@ -40,6 +40,8 @@ */ import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '@objectstack/spec/shared'; import { AppSchema as SpecAppSchema, DashboardSchema as SpecDashboardSchema, @@ -64,6 +66,11 @@ interface Case { local: string[]; } +/** The App keys whose spec tombstones aged out at 18.2 (objectstack#25767); the twin's own refusals. */ +const AGED_OUT_APP_KEYS = ['version', 'homePageId', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation', 'aria'] as const; +/** The Dashboard keys of the same cut. */ +const AGED_OUT_DASHBOARD_KEYS = ['aria', 'performance'] as const; + const CASES: Record = { Page: { spec: SpecPageSchema, @@ -116,17 +123,54 @@ const CASES: Record = { // (objectui#11363) is a refusal of the same kind: the spec refuses it, and // the mode is read only from `AppSchemaRenderer`'s prop or an // `app-schema-renderer` node, never from this document. - local: ['title', 'logo', 'favicon', 'mobileNavMode', 'layout', 'menu', 'actions'], + // The eight `AGED_OUT_APP_KEYS` are refusals of the same kind, since spec + // 18.2 (objectstack#25767): each was a spec tombstone the twin inherited + // by reference through 18.1, aged out of `AppSchema` one major on, and is + // declared here only so an authored value is refused by name instead of + // kept in silence. Pinned below. + local: ['title', 'logo', 'favicon', 'mobileNavMode', 'layout', 'menu', 'actions', ...AGED_OUT_APP_KEYS], }, Dashboard: { spec: SpecDashboardSchema, oui: OuiDashboardSchema, // `dateRange` left this list with objectui#10334: it now flows in by reference. omitted: ['widgets', 'globalFilters'], - local: [], + // Refusals, not capabilities — the aged-out spec tombstones of + // objectstack#25767, declared so an authored value is refused by name + // (`AGED_OUT_DASHBOARD_KEYS`, pinned below). + local: [...AGED_OUT_DASHBOARD_KEYS], }, }; +/** + * Pins one aged-out key on a twin: declared, unwritable (`invalid_type` at its + * own path, the console's refusal naming the key and the guide), and refused + * by the spec as an unknown key at the container's root — the control that + * the twin's refusal is not standing in for a spec member. + */ +const expectAgedOutRefusal = ( + oui: z.ZodType, spec: z.ZodType, container: string, key: string, legal: Record, specLegal: Record, +) => { + expect(Object.keys(shapeOf(oui)), `'${key}' is no longer declared — an authored value would be KEPT in silence`).toContain(key); + const twin = oui.safeParse({ ...legal, [key]: 'x' }); + expect(twin.success, `'${key}' is accepted again`).toBe(false); + const issue = twin.success ? undefined : twin.error.issues.find((i) => i.path.join('.') === key); + expect(issue, `no issue at path '${key}'`).toBeTruthy(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toContain(`Unrecognized key(s) on this ${container}: \`${key}\``); + // The guide sentence is the spec's own, byte for byte: `zod/tombstone.zod.ts` + // carries a copy (the mirrors' import boundary admits no spec string), and + // this is the pin that keeps the copy equal to `@objectstack/spec/shared`'s export. + expect(issue!.message).toContain(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE); + expect(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE).toContain('https://objectstack.ai/docs/protocol-upgrade'); + expect(issue!.message).toContain('objectstack#25767'); + const specResult = spec.safeParse({ ...specLegal, [key]: 'x' }); + expect(specResult.success, `the spec accepts '${key}' again — the twin's refusal would then hide a live member`).toBe(false); + const root = specResult.success ? '' : specResult.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(new RegExp(`Unrecognized key\\(s\\)[^\\n]*\`${key}\``)); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); +}; + describe.each(Object.keys(CASES))('%s spec parity (objectstack#4115 drift guard)', (name) => { const { spec, oui, omitted, local } = CASES[name]; const specShape = shapeOf(spec); @@ -176,27 +220,39 @@ describe('spec-only keys are now VALIDATED, not passed through (objectstack#4115 it('App declares the package-lock and access keys that used to ride through', () => { const keys = Object.keys(shapeOf(OuiAppSchema)); - // `homePageId` was in this list until spec 17.0.0 retired it; it is now a - // tombstone rather than an authorable key, so it is pinned separately - // below — listing it here would read as "still authorable". - for (const key of ['branding', 'sharing', 'embed', 'objects', 'apis', 'requiredPermissions', '_packageId']) { + // `homePageId` was in this list until spec 17.0.0 retired it, and + // `sharing`, `embed`, `objects` and `apis` until spec 18.2 aged their + // tombstones out: all four are refusals now, pinned in the leg below, and + // listing one here would read as "still authorable". + for (const key of ['branding', 'requiredPermissions', '_packageId']) { expect(keys, `'${key}' is still undeclared`).toContain(key); } }); - it('App REJECTS the retired `homePageId` rather than stripping it', () => { - // objectstack#4667 / #4709 retired the key: it was an ID cross-reference - // with no referential integrity, so a dangling id silently fell back to the - // first nav item. The landing page is now the first reachable nav item (by - // `order`), and the root landing follows `isDefault`. + it.each(AGED_OUT_APP_KEYS)('App `%s`: a spec tombstone that aged out at 18.2 is the twin\'s OWN refusal now, not a silent keep (objectstack#25767)', (key) => { + // objectstack#4667 / #4709 retired `homePageId` at spec 17.0.0 (an ID + // cross-reference with no referential integrity: a dangling id fell back + // to the first nav item in silence; the landing page is the first + // reachable nav item by `order`, the root landing follows `isDefault`), + // and the 2026-06 liveness audit retired the other seven with it. All + // eight were `retiredKey()` tombstones through 18.1, flowing into this + // twin by reference, so the twin REFUSED each at its own path. // - // Rejection, not stripping, is the whole point — objectui's derivation is - // strip-mode, so if the tombstone ever stopped flowing in by reference an - // author would get their landing-page intent silently deleted instead of an - // error naming the migration. That is the failure this pins. - const rejected = OuiAppSchema.safeParse({ type: 'app', name: 'app_x', homePageId: 'home' }); - expect(rejected.success, '`homePageId` is being accepted again').toBe(false); + // At 18.2 the tombstones aged out (objectstack#25767, ADR-0087): the + // spec's strict `AppSchema` refuses the key as an unknown one at the app's + // own path, naming it and the protocol upgrade guide — that is where an + // author is told at every door that parses with the spec (the client + // validator, the server's 422). The twin inherits nothing under the name, + // and is `.passthrough()`, so without an arm of its own it would KEEP the + // value in silence: a refusal the console gave for two majors would + // vanish. So each key is declared here as the console's own refusal + // (`agedOutSpecKeyRefusal`, `zod/tombstone.zod.ts`), the TS twin is + // `?: never` (`../app.ts`), and this pin holds both the twin's verdict and + // the spec's — a twin whose refusal outlived a key the spec re-admitted + // goes red on the control. + expectAgedOutRefusal(OuiAppSchema, SpecAppSchema, 'app', key, { type: 'app', name: 'app_x' }, { name: 'app_x', label: 'App X' }); expect(OuiAppSchema.safeParse({ type: 'app', name: 'app_x' }).success).toBe(true); + expect(SpecAppSchema.safeParse({ name: 'app_x', label: 'App X' }).success).toBe(true); }); it('App validates branding instead of accepting any shape', () => { @@ -205,16 +261,33 @@ describe('spec-only keys are now VALIDATED, not passed through (objectstack#4115 expect(OuiAppSchema.safeParse({ type: 'app', branding: 'blue' }).success).toBe(false); }); - it('Dashboard declares header/refreshIntervalSeconds/performance', () => { + it('Dashboard declares header/refreshIntervalSeconds/protection', () => { const keys = Object.keys(shapeOf(OuiDashboardSchema)); // `refreshIntervalSeconds` is the post-rename spelling (objectui#7783); the // spec still carries `refreshInterval` as a `retiredKey` tombstone, so // asserting the OLD name here would pass on the tombstone and prove nothing. - for (const key of ['header', 'refreshIntervalSeconds', 'performance', 'protection']) { + // `performance` left this list at spec 18.2: it was a tombstone through + // 18.1, aged out of `DashboardSchema` one major on (objectstack#25767), + // and is the twin's own refusal now — pinned in the leg below. + for (const key of ['header', 'refreshIntervalSeconds', 'protection']) { expect(keys, `'${key}' is still undeclared`).toContain(key); } }); + it.each(AGED_OUT_DASHBOARD_KEYS)('Dashboard `%s`: a spec tombstone that aged out at 18.2 is the twin\'s OWN refusal now, not a silent keep (objectstack#25767)', (key) => { + // The #3896 audit close-out retired both at spec 17.0.0 (no dashboard + // renderer applied `aria`; nothing read `performance`); the twin refused + // each by reference through 18.1. The rule and the posture are the App + // leg's above; `dashboard-aria-retired-contract-twins.test.ts` pins the + // `aria` face pair in detail. + expectAgedOutRefusal( + OuiDashboardSchema, SpecDashboardSchema, 'dashboard', key, + { type: 'dashboard', widgets: [] }, { name: 'ops', label: 'Ops', widgets: [] }, + ); + expect(OuiDashboardSchema.safeParse({ type: 'dashboard', widgets: [] }).success).toBe(true); + expect(SpecDashboardSchema.safeParse({ name: 'ops', label: 'Ops', widgets: [] }).success).toBe(true); + }); + it('Dashboard validates header instead of accepting any shape', () => { expect( OuiDashboardSchema.safeParse({ type: 'dashboard', widgets: [], header: { showTitle: true } }).success, diff --git a/ui/packages/types/src/__tests__/public-block-arms-10872.test.ts b/ui/packages/types/src/__tests__/public-block-arms-10872.test.ts index 196ef1a566b..1f6984ab121 100644 --- a/ui/packages/types/src/__tests__/public-block-arms-10872.test.ts +++ b/ui/packages/types/src/__tests__/public-block-arms-10872.test.ts @@ -233,12 +233,17 @@ describe('the bag is the spec row, read by reference (objectui#10872)', () => { .toBe(true); }); - it('a key the spec retired travels with the row — refused at the member', () => { + it('a key the spec retired travels with the row — refused at the bag, by name', () => { + // `page:card`'s `actions` tombstone (17.0.0) aged out of the row one major + // on (objectstack#25767): the strict row refuses the key as an unknown one + // at the bag's own path, naming it and the protocol upgrade guide, rather + // than at the member. Still the row's own refusal, travelling by reference. const document = { type: 'page:card', properties: { title: 'Summary', actions: ['edit'] } }; const result = safeValidateSchema(document); expect(result.success).toBe(false); - expect(result.success ? [] : result.error.issues.map((issue) => issue.path.join('.'))) - .toContain('properties.actions'); + const atBag = result.success ? [] : result.error.issues.filter((issue) => issue.path.join('.') === 'properties'); + expect(atBag.map((issue) => issue.message).join('\n')).toMatch(/Unrecognized key\(s\)[^\n]*`actions`/); + expect(atBag.map((issue) => issue.message).join('\n')).toContain('https://objectstack.ai/docs/protocol-upgrade'); // Control: the spec's own row refuses it too. expect(rowOf('page:card').safeParse({ title: 'Summary', actions: ['edit'] }).success).toBe(false); }); diff --git a/ui/packages/types/src/__tests__/record-details-top-level-9040.test.ts b/ui/packages/types/src/__tests__/record-details-top-level-9040.test.ts index 8c1de6ab00f..8fa76650cc7 100644 --- a/ui/packages/types/src/__tests__/record-details-top-level-9040.test.ts +++ b/ui/packages/types/src/__tests__/record-details-top-level-9040.test.ts @@ -172,13 +172,15 @@ type _KeyofStillResolvesOnThisInterface = Expect< >; /** - * The contract's face, untouched by this removal: the tombstone accepts nothing. - * Its type is the spec's retired-key type — the branded `[REMOVED]` mark at the - * pinned 18.1.0 and on objectstack `main`, - * bare `undefined` through 17.5.0 — and the helper accepts - * exactly those two spellings (objectui#11330). + * The contract's face. Through spec 18.1 the tombstone accepted nothing — its + * type was the spec's retired-key type, the branded `[REMOVED]` mark (bare + * `undefined` through 17.5.0), the two spellings `IsRetiredKeyType` accepts + * (objectui#11330). At 18.2 the ≤16.x tombstones aged out of the spec one + * major on (objectstack#25767, ADR-0087), so the contract no longer declares + * `layout` at all: a `keyof` absence, in the form the interface pin above + * uses, because indexing a key that no longer exists is itself a compile error. */ -type _LayoutOnTheContractFace = Expect>; +type _LayoutOnTheContractFace = Expect>; /** THE CONTROL for the line above, through the same helper: a live contract key is not retired. */ type _ColumnsOnTheContractFaceIsLive = Expect, false>>; @@ -191,7 +193,7 @@ type _ColumnsOnTheContractFaceIsLive = Expect { - // The distinction this leg exists for: a tombstone is still a declared - // member typed `never`, so it fails with `invalid_type` at its own path - // and carries the removal prescription. An undeclared key fails with - // `unrecognized_keys` at the root. Reading them as the same failure is how - // a retired key gets mistaken for a typo. + it('PREMISE (spec-only): `layout` is refused as an unknown key naming it and the upgrade guide — its tombstone aged out', () => { + // Through spec 18.1 this leg read the tombstone: a declared member typed + // `never`, failing with `invalid_type` at its own path and carrying the + // removal prescription, which is what told a retired key from a typo. At + // 18.2 the ≤16.x tombstones aged out of the spec one major on + // (objectstack#25767): the strict container refuses `layout` with + // `unrecognized_keys` at the root, the one issue naming the key and the + // protocol upgrade guide. That is the contract's reading now, and a pin on + // the old path would call the age-out "accepted". for (const value of ['stacked', 'inline', 'compact', 'auto', 'custom']) { const refused = RecordDetailsProps.safeParse({ layout: value }); expect(refused.success, `the contract accepted layout: ${value}`).toBe(false); - const issue = refused.error?.issues.find((i) => i.path.join('.') === 'layout'); - expect(issue?.code).toBe('invalid_type'); - expect(issue?.message).toContain('was removed in @objectstack/spec 17.0.0'); + const issue = refused.error?.issues.find((i) => i.path.length === 0 && i.message.includes('`layout`')); + expect(issue?.code, `layout: ${value} was refused, but not as a named unknown key at the root`).toBe('unrecognized_keys'); + expect(issue?.message).toMatch(/Unrecognized key\(s\)/); + expect(issue?.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); } - // CONTROL A — a near-miss typo of the same key gets the OTHER code, which - // is what makes "by name" a reading rather than a claim. + // CONTROL A — a near-miss typo of the same key gets the SAME code: the spec + // no longer tells the two apart, by design (the guide link is what a + // retired key's author follows). The reading above is therefore "refused + // by name at the root", not "distinguished from a typo". const typo = RecordDetailsProps.safeParse({ layoutt: 'compact' }); expect(typo.success).toBe(false); expect(typo.error?.issues[0]?.code).toBe('unrecognized_keys'); diff --git a/ui/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/ui/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index 81becc69085..1acfd49a022 100644 --- a/ui/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/ui/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -474,21 +474,33 @@ describe('DashboardWidgetSchema derives from the spec', () => { it('REFUSES the four keys objectstack#5010 retired, by name', () => { // Not "strips" — refuses. That distinction is the whole point of // ADR-0049 enforce-or-remove: a stale dashboard carrying `actionUrl` gets - // told where the affordance moved (`header.actions[]`) instead of silently - // losing it, which is what the pre-derivation hand copy did. + // refused instead of silently losing it, which is what the pre-derivation + // hand copy did. Through spec 18.1 each was a tombstone reporting at its + // own path with the move prescription (`header.actions[]`); at 18.2 the + // tombstones aged out one major on (objectstack#25767, ADR-0087), so the + // key meets this schema's `.strict()` door instead — the objectui#6002 + // ruled outcome for an undeclared widget key: zod's `unrecognized_keys` + // issue at the widget's path, naming the key. Loud, which is what the + // ruling asked; the prescription moved to the spec's own strict widget, + // whose refusal names the key and the protocol upgrade guide (the contract + // leg below). Unlike the `app` / `dashboard` twins, this schema is not + // `.passthrough()`, so no arm of its own is needed to keep the refusal. + const widget = { id: 'w1', type: 'bar', dataset: 'pipeline', dimensions: ['stage'], values: ['amount'] }; for (const key of ['actionUrl', 'actionType', 'actionIcon', 'aria'] as const) { - const result = DashboardWidgetSchema.safeParse({ - id: 'w1', - type: 'bar', - dataset: 'pipeline', - dimensions: ['stage'], - values: ['amount'], - [key]: key === 'aria' ? { ariaLabel: 'Pipeline' } : 'x', - }); + const value = key === 'aria' ? { ariaLabel: 'Pipeline' } : 'x'; + const result = DashboardWidgetSchema.safeParse({ ...widget, [key]: value }); expect(result.success, `'${key}' must be refused, not accepted`).toBe(false); if (!result.success) { - expect(result.error.issues.some((i) => i.path[0] === key)).toBe(true); + const refusal = result.error.issues.find((i) => i.path.length === 0 && i.code === 'unrecognized_keys'); + expect(refusal, `'${key}' must be refused at the widget's path`).toBeDefined(); + expect((refusal as { keys?: string[] }).keys, `'${key}' must be named by the refusal`).toContain(key); } + // The contract leg: the spec's strict widget names the key AND the guide. + const spec = SpecDashboardWidgetSchema.safeParse({ ...widget, [key]: value }); + expect(spec.success, `the spec accepts '${key}' again`).toBe(false); + const root = spec.success ? '' : spec.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(new RegExp(`Unrecognized key\\(s\\)[^\\n]*\`${key}\``)); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); } }); @@ -512,19 +524,23 @@ describe('DashboardWidgetSchema derives from the spec', () => { }); expect(result.success, '`responsive` must be refused, not accepted').toBe(false); if (!result.success) { - const issue = result.error.issues.find((i) => i.path[0] === 'responsive'); - expect(issue, '`responsive` must be reported by name').toBeDefined(); - // The tombstone names its own retirement, not #5010's — if this ever reads - // `#5010` the key was folded into the wrong retirement upstream. - // Was `'#4876, ADR-0049 D2'`. 17.3.0 stripped the `#NNNN` half of that - // citation and kept the ADR reference and the prescription, so the - // assertion keeps the durable half plus the repair the author acts on. - expect(issue?.message).toContain('ADR-0049 D2'); - expect(issue?.message).toContain('Delete the key.'); - // And it must point at the surviving home for breakpoint behaviour rather - // than just saying "removed". - expect(issue?.message).toContain('page.components[].responsive'); + // Its tombstone aged out with the others at spec 18.2 (objectstack#25767): + // the refusal is this schema's `.strict()` unknown-key issue at the + // widget's path, naming the key; the per-retirement citation + // (`ADR-0049 D2`) and the pointer at `page.components[].responsive` + // (itself retired, #11027) left with the tombstone text, and the guide + // sentence lives on the spec's own refusal (the contract leg). + const issue = result.error.issues.find((i) => i.path.length === 0 && i.code === 'unrecognized_keys'); + expect(issue, '`responsive` must be reported at the widget\'s path').toBeDefined(); + expect((issue as { keys?: string[] }).keys, '`responsive` must be named by the refusal').toContain('responsive'); } + const spec = SpecDashboardWidgetSchema.safeParse({ + id: 'w1', type: 'bar', dataset: 'pipeline', dimensions: ['stage'], values: ['amount'], responsive: { sm: { columns: 1 } }, + }); + expect(spec.success, 'the spec accepts `responsive` again').toBe(false); + const root = spec.success ? '' : spec.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(/Unrecognized key\(s\)[^\n]*`responsive`/); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); }); it('refuses `responsive` on the TypeScript interface too', () => { diff --git a/ui/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/ui/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts index 65e55dcba70..356a4e4576e 100644 --- a/ui/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts +++ b/ui/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -44,6 +44,7 @@ import { describe, it, expect } from 'vitest'; import type { App, Dashboard, Page } from '@objectstack/spec/ui'; +import { AppSchema as SpecAppSchema } from '@objectstack/spec/ui'; import type { AppComponentSchema } from '../app'; import type { DashboardComponentSchema } from '../complex'; import type { PageNodeSchema } from '../layout'; @@ -104,15 +105,33 @@ describe('admitted spec keys land on the twin with the SPEC\'S type, not `any`', }); describe('spec tombstones surface on the twin as a refusal — the verdict the mirror gives', () => { - it('App `version` / Dashboard `refreshInterval`: authoring a value is a compile error AND a parse failure', () => { - // @ts-expect-error — `version` is the spec's `retiredKey()` tombstone. - const app: AppComponentSchema = { type: 'app', version: '1.0.0' }; + it('Dashboard `refreshInterval`: authoring a value is a compile error AND a parse failure', () => { // @ts-expect-error — `refreshInterval` is the #15680 rename's tombstone. const dashboard: DashboardComponentSchema = { type: 'dashboard', widgets: [], refreshInterval: 30 }; - expect(AppMirror.safeParse(app).success).toBe(false); expect(DashboardMirror.safeParse(dashboard).success).toBe(false); }); + it('App `version`: its tombstone aged out of the spec (objectstack#25767) — the spec refuses it as an unknown key, and the twin refuses it on its own', () => { + // Through spec 18.1 `version` was a `retiredKey()` tombstone the twin + // inherited by reference and refused. At 18.2 the ≤16.x tombstones aged + // out one major on: the spec's strict `AppSchema` refuses the key as an + // unknown one at the app's path, naming it and the protocol upgrade guide; + // the twin, with no member to inherit over its `.passthrough()` base, + // carries the refusal as its OWN arm (`agedOutSpecKeyRefusal`) and its own + // `version?: never` — so the verdict the mirror gives is unchanged: a + // compile error AND a parse failure at the key's own path. + // @ts-expect-error — `version` is the twin's own `?: never`: the spec tombstone it projected aged out (objectstack#25767). + const app: AppComponentSchema = { type: 'app', version: '1.0.0' }; + const spec = SpecAppSchema.safeParse({ name: 'app_x', label: 'App X', version: '1.0.0' }); + expect(spec.success).toBe(false); + const root = spec.success ? '' : spec.error.issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(/Unrecognized key\(s\)[^\n]*`version`/); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + const twin = AppMirror.safeParse(app); + expect(twin.success).toBe(false); + expect(twin.success ? [] : twin.error.issues.map((i) => [i.code, i.path.join('.')])).toContainEqual(['invalid_type', 'version']); + }); + it('the controls: the same documents without the tombstone key pass both faces', () => { const app: AppComponentSchema = { type: 'app' }; const dashboard: DashboardComponentSchema = { type: 'dashboard', widgets: [], refreshIntervalSeconds: 30 }; diff --git a/ui/packages/types/src/app.ts b/ui/packages/types/src/app.ts index 8a451885cdd..5998a721410 100644 --- a/ui/packages/types/src/app.ts +++ b/ui/packages/types/src/app.ts @@ -747,6 +747,82 @@ export interface AppComponentSchema extends BaseSchema, Omit` projection above; the projection now carries + * nothing under the name, so the refusal is this face's own — `?: never` is + * the twin of `zod/app.zod.ts`'s `agedOutSpecKeyRefusal` arm, and an authored + * value is a compile error here as it is a parse error there. The same holds + * for the seven members after it (`homePageId`, `objects`, `apis`, `sharing`, + * `embed`, `mobileNavigation`, `aria`). Pinned by + * `__tests__/page-app-dashboard-spec-parity.test.ts` and + * `__tests__/twins-spec-by-reference-9736.test.ts`. + * + * @deprecated Not a key of this contract. An app is versioned by its owning + * package: `manifest.version`. + */ + version?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. The landing page is the first + * reachable `navigation` item (by `order`); the root landing follows `isDefault`. + */ + homePageId?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Objects belong to the stack + * (`defineStack({ objects })`); an app reaches them through its navigation. + */ + objects?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Declare endpoints on the stack, + * `defineStack({ apis })` (ADR-0121) — read the protocol upgrade guide's + * `declarative-apis-endpoints-live` entry before moving one. + */ + apis?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Public access is granted per form + * view (`FormView.sharing`). + */ + sharing?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Embedding is a per-form-view + * surface (`FormView.sharing`), not an app-level switch. + */ + embed?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. The console's mobile navigation + * mode is the `mobileNavMode` prop of `AppSchemaRenderer` or the same key of + * an `app-schema-renderer` node (see {@link AppComponentSchema.mobileNavMode}). + */ + mobileNavigation?: never; + + /** + * Aged-out spec tombstone (objectstack#25767); see `version` above. + * + * @deprecated Not a key of this contract. Declare `aria` on the page + * component that renders the DOM node (`page.components[].aria`). + */ + aria?: never; + /** * Required permissions (ObjectStack Spec v2.0.1) * Permissions required to access this application diff --git a/ui/packages/types/src/complex.ts b/ui/packages/types/src/complex.ts index 3525e7c7597..03ae21801c4 100644 --- a/ui/packages/types/src/complex.ts +++ b/ui/packages/types/src/complex.ts @@ -2609,7 +2609,9 @@ export interface DashboardComponentSchema extends BaseSchema, Omit` projection above. Pinned by // `__tests__/dashboard-daterange-spec-10334.test.ts`. - // `aria` was DECLARED here until objectui#5830, under a comment claiming - // alignment with @objectstack/spec AriaPropsSchema — by then the opposite of - // the contract: the spec removed `dashboard.aria` at the objectstack-ai/objectstack#3896 audit - // close-out (no dashboard renderer ever applied it), so - // `DashboardSchema.shape.aria` is a tombstone that refuses any value, the - // Zod twin (`zod/complex.zod.ts`) inherits that refusal through - // `SpecDashboardFields`, and `plugin-dashboard` has no `schema.aria` read - // site. Since objectui#9736 this interface inherits the same tombstone from - // the spec projection (`aria?: undefined`), so an authored `aria` is now a - // compile error rather than `any` through the index signature. Pinned by - // `__tests__/dashboard-aria-retired-contract-twins.test.ts`. + /** + * RETIRED upstream at @objectstack/spec 17.0.0 (the objectstack#3896 audit + * close-out: no dashboard renderer ever applied it); its tombstone aged out + * of `DashboardSchema` at spec 18.2 (objectstack#25767, ADR-0087 one major + * on). The history, so nobody re-declares it: `aria` was DECLARED here until + * objectui#5830 under a comment claiming alignment with the spec's + * `AriaPropsSchema` — by then the opposite of the contract — and from + * objectui#9736 this interface inherited the spec's tombstone through the + * `Omit< Dashboard, … >` projection above (`aria?: undefined`), so an + * authored value was a compile error rather than `any` through the index + * signature. The projection now carries nothing under the name, so the + * refusal is this face's own: `?: never` is the twin of + * `zod/complex.zod.ts`'s `agedOutSpecKeyRefusal` arm, and `plugin-dashboard` + * still has no `schema.aria` read site. Pinned by + * `__tests__/dashboard-aria-retired-contract-twins.test.ts`. + * + * @deprecated Not a key of this contract. ARIA attributes are live on + * `page.aria`, `page.components[].aria` and the list view's `aria`. + */ + aria?: never; + /** + * Aged-out spec tombstone of the same cut (objectstack#25767); see `aria` + * above. No renderer or runtime read it. Pinned by + * `__tests__/page-app-dashboard-spec-parity.test.ts`. + * + * @deprecated Not a key of this contract. + */ + performance?: never; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `dashboard` reads NEITHER * content channel: no renderer read consumes `body` or `children` for this diff --git a/ui/packages/types/src/zod/app.zod.ts b/ui/packages/types/src/zod/app.zod.ts index 2fcf7eab128..8a347afee01 100644 --- a/ui/packages/types/src/zod/app.zod.ts +++ b/ui/packages/types/src/zod/app.zod.ts @@ -39,7 +39,7 @@ import { EvaluatedExpressionSchema as SpecEvaluatedExpressionSchema, } from '@objectstack/spec/shared'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; -import { aliasKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { agedOutSpecKeyRefusal, aliasKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import type { AppMenuItem, NavigationItemType } from '../app.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -749,6 +749,60 @@ export const AppComponentSchema = BaseSchema.extend(SpecAppFields.shape).extend( areas: z.array(NavigationAreaSchema).optional().describe('Navigation areas (business-domain partitions)'), contextSelectors: z.array(AppContextSelectorSchema).optional().describe('App-level scope dropdowns injected into nav items as {} vars'), actions: retirementTombstone(APP_ACTIONS_REFUSAL), + // The eight `App` keys the spec retired at 17.0.0 (#4667 and the 2026-06 + // liveness audit) and carried as `retiredKey()` tombstones through 18.1 — + // flowing into this twin by reference, so an authored value was refused + // HERE at the key's own path. At spec 18.2 the tombstones aged out of + // `AppSchema` one major on (objectstack#25767, ADR-0087): the spec refuses + // each as an unknown key naming the upgrade guide, and `SpecAppFields` no + // longer carries the name — so without these arms `BaseSchema`'s + // `.passthrough()` would turn a two-major refusal into a silent KEEP. Each + // is the console's own refusal now (`agedOutSpecKeyRefusal`, the reasoning + // on its docblock); the TS twins are `?: never` in `../app.ts`, and the pins + // are `../__tests__/page-app-dashboard-spec-parity.test.ts` and + // `../__tests__/twins-spec-by-reference-9736.test.ts`. + version: agedOutSpecKeyRefusal( + 'app', 'App.version', 'version', '17.0.0', + 'An app is versioned by its owning package: use `manifest.version`. Delete the key.', + ), + homePageId: agedOutSpecKeyRefusal( + 'app', 'App.homePageId', 'homePageId', '17.0.0', + 'The landing page is the first reachable `navigation` item (by `order`), and the root landing follows ' + + '`isDefault`; an id cross-reference with no referential integrity fell back to the first item in silence. ' + + 'Delete the key.', + ), + objects: agedOutSpecKeyRefusal( + 'app', 'App.objects', 'objects', '17.0.0', + 'Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. ' + + 'Delete the key.', + ), + apis: agedOutSpecKeyRefusal( + 'app', 'App.apis', 'apis', '17.0.0', + 'Declare the endpoint on the stack instead, `defineStack({ apis })` — a surface that EXECUTES under its own ' + + 'publish gates (ADR-0121): read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide ' + + 'before moving it, since the move is a security review, not a rename. Delete the key here.', + ), + sharing: agedOutSpecKeyRefusal( + 'app', 'App.sharing', 'sharing', '17.0.0', + 'Public access is granted per form view (`FormView.sharing`, the public-data-collection surface); no ' + + 'public-app route ever read an app-level block. Delete the key.', + ), + embed: agedOutSpecKeyRefusal( + 'app', 'App.embed', 'embed', '17.0.0', + 'Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch; no iframe route ever read ' + + 'it. Delete the key.', + ), + mobileNavigation: agedOutSpecKeyRefusal( + 'app', 'App.mobileNavigation', 'mobileNavigation', '17.0.0', + 'No renderer, `packages/mobile` included, ever read it. The console\'s mobile navigation mode is the ' + + '`mobileNavMode` prop of `AppSchemaRenderer` or the same key of an `app-schema-renderer` node, never a key of ' + + 'this document. Delete the key.', + ), + aria: agedOutSpecKeyRefusal( + 'app', 'App.aria', 'aria', '17.0.0', + 'No renderer read app-level ARIA attributes. Declare `aria` on the page component that renders the DOM node ' + + '(`page.components[].aria`; `page.aria` and the list view\'s `aria` are live too). Delete the key.', + ), }); /** diff --git a/ui/packages/types/src/zod/complex.zod.ts b/ui/packages/types/src/zod/complex.zod.ts index 0b8afaa90d9..6a3e504e123 100644 --- a/ui/packages/types/src/zod/complex.zod.ts +++ b/ui/packages/types/src/zod/complex.zod.ts @@ -17,7 +17,7 @@ */ import { z } from 'zod'; -import { aliasKeyRefusal, durationKeyRenamedGuidance, handlerKeyRefusal, retiredNodeType, retirementTombstone } from './tombstone.zod.js'; +import { agedOutSpecKeyRefusal, aliasKeyRefusal, durationKeyRenamedGuidance, handlerKeyRefusal, retiredNodeType, retirementTombstone } from './tombstone.zod.js'; import { ChartTypeSchema as SpecChartTypeSchema, checkDashboardWidgetChartMeasureArity, @@ -1519,8 +1519,13 @@ const WidgetComponentSlotUnion = z.core.$constructor( * consumed keys, undeclared widget keys refuse loudly — zod's * `unrecognized_keys` issue names every offending key. The spec's own * tombstones (`actionUrl` / `actionType` / `actionIcon` / `aria` / - * `responsive`) stay DECLARED `z.never()` members, so they keep their specific - * removal messages rather than degrading to a generic unknown-key error. + * `responsive`) stayed DECLARED `z.never()` members through spec 18.1, keeping + * their specific removal messages; at 18.2 they aged out of the spec's widget + * shape one major on (objectstack#25767, ADR-0087) and meet this `.strict()` + * door like any other undeclared key — loud, named, and sent to the spec's + * own refusal (which names the protocol upgrade guide) for the prescription. + * No arm of this schema's own is needed to keep that refusal, unlike the + * `.passthrough()` `app` / `dashboard` twins (`agedOutSpecKeyRefusal`). * * ⚠️ This schema owns spec-family widgets only. A component node in the widget * slot (`type: 'metric-card'`) is routed to @@ -1726,6 +1731,27 @@ export const DashboardComponentSchema = BaseSchema.extend(SpecDashboardFields.sh + 'What it renders instead: `columns`, `dateRange`, `description`, `gap`, `globalFilters`, ' + '`header`, `label`, `name`, `refreshIntervalSeconds`, `type`, `widgets`.', ), + // `aria` and `performance`: spec 17.0.0 tombstones (the #3896 audit + // close-out) that this twin inherited by reference and refused at the key's + // own path through 18.1. At spec 18.2 both aged out of `DashboardSchema` one + // major on (objectstack#25767, ADR-0087): the spec refuses each as an unknown + // key naming the upgrade guide, `SpecDashboardFields` no longer carries the + // name, and without these arms `BaseSchema`'s `.passthrough()` would turn the + // refusal into a silent KEEP. Each is the console's own refusal now + // (`agedOutSpecKeyRefusal`, the reasoning on its docblock); the TS twins are + // `?: never` in `../complex.ts`, and the pins are + // `../__tests__/dashboard-aria-retired-contract-twins.test.ts` and + // `../__tests__/page-app-dashboard-spec-parity.test.ts`. + aria: agedOutSpecKeyRefusal( + 'dashboard', 'dashboard.aria', 'aria', '17.0.0', + 'No dashboard renderer ever applied it, so declared ARIA attributes never reached the DOM, and ' + + '`plugin-dashboard` has no `schema.aria` read site. Delete the key; ARIA attributes are live on `page.aria`, ' + + '`page.components[].aria` and the list view\'s `aria`.', + ), + performance: agedOutSpecKeyRefusal( + 'dashboard', 'dashboard.performance', 'performance', '17.0.0', + 'No renderer or runtime read it; dashboard performance tuning was never implemented. Delete the key.', + ), }); /** diff --git a/ui/packages/types/src/zod/tombstone.zod.ts b/ui/packages/types/src/zod/tombstone.zod.ts index dce8bca7d97..cbc0ea6d017 100644 --- a/ui/packages/types/src/zod/tombstone.zod.ts +++ b/ui/packages/types/src/zod/tombstone.zod.ts @@ -16,6 +16,21 @@ import { z } from 'zod'; +/** + * The sentence every strict unknown-key refusal of `@objectstack/spec` carries + * (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, `@objectstack/spec/shared`), restated + * here rather than imported: the mirrors' import boundary + * (`../__tests__/imported-defaults-8317.test.ts`) walks every value read of a + * spec binding inside `zod/*.ts` and admits only schemas wrapped in + * `stripImportedDefaults`, vocabularies and refinements. A string is none of + * those, so it crosses as a copy — pinned byte-equal to the spec's export by + * `../__tests__/page-app-dashboard-spec-parity.test.ts`, so the two doors + * cannot drift. + */ +const RETIRED_KEY_UPGRADE_GUIDE_SENTENCE = + 'Keys retired before the previous protocol major are no longer prescribed here — ' + + 'see the protocol upgrade guide: https://objectstack.ai/docs/protocol-upgrade.'; + /** * Declare an ADR-0049 RETIREMENT TOMBSTONE: a key that stays declared but is * unwritable, so an authored value is REFUSED loudly instead of being silently @@ -65,6 +80,47 @@ export function retirementTombstone(guidance: string) { return z.never({ error: guidance }).optional().describe(guidance); } +/** + * Declare the console's OWN refusal for a key whose `@objectstack/spec` + * tombstone AGED OUT (objectstack#25767, ADR-0087 one major on). A key the spec + * retired at major N is a `retiredKey()` tombstone through N+1.x and leaves the + * spec's shape at the next major's cut; from then on the spec's strict + * container refuses it as an UNKNOWN key — `unrecognized_keys` at the + * container's own path, naming the key and the protocol upgrade guide. + * + * A twin derived from that shape by reference (`specFieldsExcept(...)` over + * `BaseSchema`, which is `.passthrough()`) inherits nothing under the name once + * the tombstone is gone: a refusal the console gave at the key's own path for + * two majors would turn into a silent KEEP. So the refusal moves to this side + * as the console's own arm, in the posture `app.zod.ts`'s `mobileNavMode` and + * `actions` set: {@link retirementTombstone}'s primitive, `invalid_type` at the + * key's own path, one string feeding both channels. The guidance opens with the + * sentence the spec answers the key with and the guide sentence the spec's + * refusal carries (a copy pinned equal to the spec's export, see + * {@link RETIRED_KEY_UPGRADE_GUIDE_SENTENCE}), then says what happened to the + * key and where its value went. The TS twin is `?: never`. + * + * ⛔ Asserts no spec behaviour that does not exist: the spec's tombstone text is + * gone and is not restated; the unknown-key refusal is the only spec verdict + * named. The other case keeps its rule — a key the twin NEVER refused gets no + * hand-written refusal (`complex.ts` `DashboardComponentSchema`, `title`). + * + * @param container the noun the spec's refusal uses (`'app'`, `'dashboard'`) + * @param def the spec def and key as its ledger spelled it (`'App.version'`) + * @param key the member name, spelled into the lead sentence + * @param retiredIn the spec version that retired the key (`'17.0.0'`) + * @param remedy where the value went — one or two sentences ending in `Delete the key.` + */ +export function agedOutSpecKeyRefusal(container: string, def: string, key: string, retiredIn: string, remedy: string) { + const guidance = + `Unrecognized key(s) on this ${container}: \`${key}\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ` + + `\`${def}\` was retired in @objectstack/spec ${retiredIn}, and its tombstone aged out of the spec's shape one ` + + 'major on (objectstack#25767, ADR-0087): the spec refuses the key as an unknown one, and this node — derived ' + + 'from that shape by reference over a `.passthrough()` base — refuses it by name here rather than keeping it in ' + + `silence. ${remedy}`; + return retirementTombstone(guidance); +} + /** How a handler key sits on the TypeScript face (objectui#6124, measured per key). */ export type HandlerKeyDisposition = /** A host-supplied function REACHES a renderer at runtime — the TS twin keeps its function type. */ From 38292259a70a96e7ef3d1e0308389fa43f5f04c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:58:28 +0000 Subject: [PATCH 10/22] fix(ui/types,cli): the aged-out-key refusal imports the spec's guide sentence; the guidance floor follows the one-major rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on 6d80aba775 stopped twice on readers of the removed kits. Type Check UI stopped at `check:spec-symbols` (before tsc): the console's own refusal of an aged-out spec key (`agedOutSpecKeyRefusal`, zod/tombstone.zod.ts) carried a hand-written const under the spec's own export name, `RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, and the gate refuses a declaration that shadows a spec export (a reader takes it for the spec's definition) and the alignment claim its docblock made. The sentence is now imported from `@objectstack/spec/shared`. The mirrors' import boundary (imported-defaults-8317) gains the fourth declared kind of read it enumerates — a spec TEXT, held name → binding with its own assertion that each entry is a non-empty string the mirrors read raw and not a schema — beside vocabularies, type positions and refinements; a string has no Zod graph and no default, so it cannot write a value into a parsed document, which is all the boundary polices. The parity test keeps asserting the refusal carries the spec's sentence; its comment no longer describes a copy. The SDUI manifest's types record is re-recorded for the moved source digest. Test Core 4/8 and 6/8 (packages/cli): migrate-meta-engine-guidance pins an anti-vacuity floor of the entries whose guidance was rewritten, 244 ids, and asserts the chain's family still holds each. 75 of them are major-17 entries this change retired one major on, so the family lost each by name. The floor is the rewritten entries the registry still holds — the 75 rows leave with their guidance, 169 remain — and the file says the rule: an entry retired one major on leaves both the chain and this list. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- .../test/migrate-meta-engine-guidance.test.ts | 84 ++----------------- .../__tests__/imported-defaults-8317.test.ts | 35 +++++++- .../page-app-dashboard-spec-parity.test.ts | 6 +- ui/packages/types/src/zod/tombstone.zod.ts | 30 +++---- 4 files changed, 54 insertions(+), 101 deletions(-) diff --git a/packages/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index a34e992ac1e..f14df030969 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -33,7 +33,8 @@ * * - The covered set is every semantic entry in the registry, so an entry added * later — in any family, a new family included — is held to the same line on - * arrival; and the set must still contain every entry the rewrites covered, + * arrival; and the set must still contain every entry the rewrites covered + * that the chain still carries (an entry retired one major on leaves both), * so it cannot turn every assertion below into a loop over nothing. * - Each block is asserted PRESENT in stdout before it is asserted clean, so a * renderer change that stopped printing the prose fails here instead of @@ -75,41 +76,31 @@ const TRACKER_ID = /#\d{4,5}\b/; /** * The entries rewritten when each family was brought to this line — the * anti-vacuity floor. An entry that carried no tracker id to begin with needs no - * row here: the whole directory is held. + * row here: the whole directory is held. An entry that has since AGED OUT of + * the chain leaves this floor with its guidance (ADR-0087, one major on: + * objectstack#25767 removed the major-17 step at 18.2, and the 75 rows it + * carried left here), so the floor is the rewritten entries the registry still + * holds — 169 at that cut — never a row the chain can no longer print. */ const REWRITTEN = [ 'action-bulk-dispatch-contract-undeclared', - 'action-descriptor-is-async-retired', - 'action-descriptor-resume-authority-default-flip', 'action-engine-facade-find-query-envelope', - 'action-session-roles-to-positions', - 'actor-user-roles-to-positions', 'address-location-value-unknown-keys-refused', 'admin-export-wildcard-removed', 'admin-scope-business-unit-blank-refused', 'advanced-plugin-lifecycle-config-retired', - 'aggregation-node-distinct-retired', 'ai-conversation-analytics-duration-unit-in-key', 'analytics-authorable-unknown-keys-refused', 'analytics-date-range-array-two-bounds-required', - 'analytics-query-request-envelope-retired', 'analytics-time-dimension-date-range-vocabulary-closed', 'api-assembled-entry-split', 'api-error-retry-after-unit-in-key', 'api-runtime-config-durations-unit-in-key', - 'api-runtime-create-withdrawn', - 'apimethod-enum-shrink', - 'approval-escalation-enabled-default-flip', 'assembled-package-body-plugins-envelope', 'audience-posture-default-invite-only', - 'audit-log-action-enum-retired', - 'audit-log-action-restore-retired', - 'auth-config-unadvertised-reserved-features', - 'authoring-schemas-strict-unknown-keys', 'automation-flow-list-route-retired', 'automation-runs-cursor-retired', 'autonumber-default-unique-organization', - 'batch-row-result-schema-shape', 'branded-identifier-schemas-retired', 'cache-warmup-scheduled-strategy-retired', 'cbp-master-detail-required-forced', @@ -117,65 +108,40 @@ const REWRITTEN = [ 'change-management-duration-keys-retired', 'change-management-family-retired', 'cli-command-contribution-retired', - 'client-delete-result-success', 'client-meta-reset-result-reset', 'cloud-subpath-retired', 'cluster-driver-dangling-values-removed', - 'connector-inline-authentication-publish-refused', 'dashboard-header-modal-target-page-only', 'dashboard-widget-chart-config-structure-refused', - 'dashboard-widget-compareto-offset', 'dashboard-widget-metric-family-multi-measure-refused', 'dashboard-widget-stage-order-non-funnel-refused', - 'data-driver-find-stream-retired', - 'data-driver-query-omit-object', - 'data-engine-batch-retired', - 'data-field-changed-event-retired', 'data-file-value-duration-unit-in-key', 'data-nosql-query-options-timeout-unit-in-key', 'dataset-filter-nested-relation-equality-array-refused-at-save', 'dataset-measure-aggregate-field-type-refused', 'dataset-measure-selecting-aggregate-field-type-refused', - 'datasource-config-inline-credential-refused', 'datasource-config-mongo-options-credential-refused', - 'datasource-config-placeholder-refused', 'datasource-config-postgres-url-unparseable-refused', 'datasource-config-url-query-credential-refused', - 'datasource-config-url-userinfo-refused', 'datasource-credentialsref-mongo-composed-no-username-refused', 'datasource-credentialsref-mongo-url-no-user-refused', - 'declarative-apis-endpoints-live', - 'delete-by-id-before-hook-repoint-retired', 'device-request-response-interval-unit-in-key', - 'driver-aggregate-undeclared-key-aliases-removed', - 'driver-capabilities-inert-bits-removed', 'driver-options-timeout-to-timeout-ms', - 'driver-sql-distinct-bare-filter-typed', 'driver-sql-unresolvable-where-column-refused', 'driver-sql-upsert-cross-row-identity-merge-refused', 'driver-turso-config-local-path-wasm-retired', 'element-data-source-and-object-block-filter-rule-array', 'engine-dotted-filter-refused', - 'engine-dotted-projection-refused', - 'engine-find-formula-filter-refused', - 'engine-find-formula-order-by-refused', - 'engine-update-upsert-retired', - 'enhanced-api-error-field-errors-renamed', 'epoch-instant-keys-renamed', 'esignature-config-deadline-keys-retired', - 'etl-pipeline-layer-retired', 'evaluated-expression-slots-source-required', 'event-name-schema-retired', - 'export-axis-opt-in', - 'export-field-meta-constraints-retired', 'export-job-family-retired', - 'external-lookup-message-queue-families-retired', 'field-currency-scale-refused', 'field-max-length-malformed-or-misplaced-refused', 'field-min-length-malformed-or-misplaced-refused', 'field-multiple-non-capable-type-refused', 'field-predicate-reference-traversal-refused', - 'field-runtime-create-withdrawn', 'field-scale-precision-integer-refused', 'filter-between-blank-endpoint-refused', 'filter-between-field-reference-endpoint-refused', @@ -186,26 +152,18 @@ const REWRITTEN = [ 'filter-ne-array-comparand-refused', 'filter-preset-ordering-comparand-refused', 'filter-query-face-comparands-refused-at-save', - 'filter-regex-options-retired', 'filter-text-operator-declared-type-refused', 'flow-decision-branch-expression-absent-refused', 'flow-decision-edge-branching-first-match', 'flow-edge-condition-evaluated-slot-source-required', 'flow-predicate-slot-blank-string-refused', - 'flow-retry-max-retries-required', - 'hook-context-session-roles-retired', - 'hook-register-empty-object-target-refused', 'hook-register-undispatched-lifecycle-event-refused', 'hot-reload-inert-state-strategies-retired', 'hot-reload-watch-placeholder-retired', - 'http-request-errors-total-retired', - 'http-server-runtime-vocabulary-retired', 'identity-api-key-schema-retired', - 'import-run-automations-declared-default-corrected', 'incident-response-deadline-keys-retired', 'incident-response-family-retired', 'inline-grid-column-currency-scale-refused', - 'job-retry-policy-constraints-tightened', 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', 'kernel-context-preview-mode-retired', 'kernel-event-bus-retention-unit-in-key', @@ -222,7 +180,6 @@ const REWRITTEN = [ 'metadata-manager-config-cache-ttl-unit-in-key', 'metadata-manager-config-inert-cache-keys-retired', 'metadata-plugin-additional-types-retired', - 'notification-list-cursor-retired', 'object-block-sort-item-array', 'object-grid-data-view-data-converged', 'object-index-unknown-keys-refused', @@ -230,52 +187,33 @@ const REWRITTEN = [ 'package-api-contracts-unmounted-entries-retired', 'package-install-request-unknown-keys-refused', 'package-rollback-response-retired', - 'package-uninstall-explicit-all-tenants', 'packages-list-pagination-retired', 'platform-timezone-columns-iana-domain-refused', - 'plugin-activation-events-retired', 'plugin-auto-restart-never-reinitialised', 'plugin-manifest-contributes-dead-members-retired', 'plugin-manifest-contributes-routes-retired', 'plugin-manifest-dead-containers-retired', 'plugin-manifest-kind-globs-retired', - 'plugin-manifest-loading-retired', - 'plugin-runtime-family-retired', 'plugin-security-scan-result-surface-retired', 'plugin-security-scanner-retired', - 'position-permissions-column-retired', - 'query-array-string-agg-retired', - 'query-cursor-retired', - 'query-distinct-retired', - 'query-field-node-object-form-retired', - 'query-joins-retired', - 'query-window-functions-retired', 'record-chatter-position-vocabulary-converged', - 'record-details-sections-object-form', 'rest-api-endpoint-handler-status-retired', 'rest-api-plugin-durations-unit-in-key', 'rest-server-config-dead-keys-retired', - 'rest-server-openapi31-block-removed', 'rls-predicate-array-comparand-refused', 'rls-predicate-cross-class-field-comparison-refused', 'rls-predicate-stored-list-ordering-refused', - 'runtime-httpserver-wrapper-retired', 'schedule-flow-acting-organization-required', 'scim-provider-object-retired', 'screen-field-lookup-reference-required', 'send-template-input-org-retired', 'session-payload-positions-security-axis', 'session-user-language-retired', - 'sharing-execution-context-retired', - 'sharing-rule-recipient-reconcile', - 'sort-node-direction-rejected', - 'spec-type-alias-input-suffix-retired', 'stack-themes-carrier-retired', 'stack-top-level-unknown-keys-refused', 'standard-error-code-batch-members-retired', 'standard-error-code-concurrent-limit-exceeded-retired', 'startup-orchestrator-retired', - 'storage-service-list-retired', 'strategy-context-aggregation-method-narrowed', 'structured-region-body-pause-and-end-refused', 'sys-account-issuer-retired', @@ -292,7 +230,6 @@ const REWRITTEN = [ 'tenant-schema-cache-ttl-unit-in-key', 'tenant-timeouts-unit-in-key', 'time-update-interval-sub-day-retired', - 'tool-requires-confirmation-retired', 'training-deadline-keys-retired', 'training-family-retired', 'translation-per-app-settings-platform-only', @@ -302,26 +239,19 @@ const REWRITTEN = [ 'ui-form-field-length-malformed-refused', 'ui-form-field-precision-scale-integer-refused', 'ui-form-view-predicate-features-root-refused', - 'ui-interaction-config-family-retired', 'ui-list-view-groupbyfield-padded-refused', 'ui-list-view-grouping-field-padded-refused', 'ui-mcp-connect-agent-unknown-keys-refused', - 'ui-notification-action-embed-config-retired', 'ui-object-grid-page-size-positive-integer-refused', 'ui-react-list-view-binding-aliases-retired', 'ui-record-blocks-unknown-keys-refused', 'ui-reference-rail-unknown-keys-refused', - 'ui-widget-i18n-family-retired', - 'ups-delegated-from-column-retired', 'view-filter-rule-absent-value-refused', 'view-filter-rule-scalar-operator-array-refused', - 'view-filter-rule-value-shaped-by-operator', - 'view-management-protocol-retired', 'view-overlay-options-bag-judged', 'view-pagination-page-size-default-50', 'wait-node-event-config-required', 'websocket-durations-unit-in-key', - 'workflow-service-slot-retired', ]; interface FamilyEntry { diff --git a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts index fda212249c2..c114c38563b 100644 --- a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -163,6 +163,7 @@ import { import { EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema, EvaluatedExpressionSchema as SpecEvaluatedExpressionSchema, + RETIRED_KEY_UPGRADE_GUIDE_SENTENCE, } from '@objectstack/spec/shared'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; @@ -679,6 +680,13 @@ describe('the import boundary strips every imported default (objectui#8317)', () * There is no Zod graph to walk and no default to remove: the whole effect * of a refinement is `ctx.addIssue`, so it cannot write a value into a * parsed document, which is the only thing this boundary is about. + * - a spec TEXT (objectstack#25767) — a sentence such as + * `RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, which a mirror's own refusal + * restates so the two doors answer an aged-out key with the same words. A + * string has no Zod graph and no default; it cannot write a value into a + * parsed document. It is imported rather than copied because ui's + * `check:spec-symbols` refuses a hand-written declaration under a spec + * export's name, and the assertion below holds each entry to be a string. */ describe('every `@objectstack/spec` value read in the mirrors goes through the boundary', () => { /** `:` for each read that is allowed to stay raw. */ @@ -755,6 +763,18 @@ describe('the import boundary strips every imported default (objectui#8317)', () ['SpecViewMetadataMembers', SpecViewMetadataMembers], ]); + /** + * Spec TEXT a mirror restates in its own refusal, keyed by binding name + * (objectstack#25767): the guide sentence `agedOutSpecKeyRefusal` + * (`tombstone.zod.ts`) opens with, so a console-side refusal of a key whose + * spec tombstone aged out carries the spec's own words. Held as name → + * binding so the assertion below can check each entry really is a string: + * a schema must not reach this list merely by being listed. + */ + const TEXT_EXCEPTIONS = new Map([ + ['RETIRED_KEY_UPGRADE_GUIDE_SENTENCE', RETIRED_KEY_UPGRADE_GUIDE_SENTENCE], + ]); + const isSpecModule = (m: string): boolean => m === '@objectstack/spec' || m.startsWith('@objectstack/spec/'); @@ -835,6 +855,7 @@ describe('the import boundary strips every imported default (objectui#8317)', () .filter((r) => r.kind === 'value' && !r.wrapped) .filter((r) => !VOCABULARY_EXCEPTIONS.has(`${r.file}:${r.owner}`)) .filter((r) => !REFINEMENT_EXCEPTIONS.has(r.name)) + .filter((r) => !TEXT_EXCEPTIONS.has(r.name)) .filter((r) => !(SCHEMA_RECORDS.has(r.name) && r.wrappedViaMember)); expect( offenders.map((r) => `${r.file}:${r.line} ${r.name} (in \`${r.owner ?? ''}\`)`), @@ -859,6 +880,18 @@ describe('the import boundary strips every imported default (objectui#8317)', () } }); + it('every declared text exception is a STRING the mirrors read, not a schema in disguise', () => { + expect(TEXT_EXCEPTIONS.size, 'the list is empty — delete it rather than leave a hole').toBeGreaterThan(0); + for (const [name, binding] of TEXT_EXCEPTIONS) { + expect(typeof binding, `${name} is not a string`).toBe('string'); + expect((binding as string).length, `${name} is empty`).toBeGreaterThan(0); + expect('_zod' in Object(binding), `${name} is a schema; a schema keeps the strip`).toBe(false); + const matching = reads.filter((r) => r.name === name && r.kind === 'value'); + expect(matching.length, `declared exception ${name} matches no read — delete it`).toBeGreaterThan(0); + for (const r of matching) expect(r.wrapped, `${r.file}:${r.line} wraps ${name}; a string is not stripped`).toBe(false); + } + }); + it('every declared refinement exception is a LIVE FUNCTION, not a schema in disguise', () => { expect(REFINEMENT_EXCEPTIONS.size, 'the list is empty — delete it rather than leave a hole').toBeGreaterThan(0); for (const [name, binding] of REFINEMENT_EXCEPTIONS) { @@ -890,7 +923,7 @@ describe('the import boundary strips every imported default (objectui#8317)', () it('every symbol the mirrors import is covered by the differential above', () => { const differential = new Set(IMPORTED.map(([n]) => n)); const missing = [...new Set(reads.map((r) => r.name.replace(/^Spec/, '')))] - .filter((n) => !differential.has(n) && !REFINEMENT_EXCEPTIONS.has(n)); + .filter((n) => !differential.has(n) && !REFINEMENT_EXCEPTIONS.has(n) && !TEXT_EXCEPTIONS.has(n)); expect( missing, 'a schema imported by a mirror is not in this file\'s `IMPORTED` list, so nothing measures ' + diff --git a/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts b/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts index 48700e708b1..7dc74ddb4dd 100644 --- a/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts +++ b/ui/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts @@ -158,9 +158,9 @@ const expectAgedOutRefusal = ( expect(issue, `no issue at path '${key}'`).toBeTruthy(); expect(issue!.code).toBe('invalid_type'); expect(issue!.message).toContain(`Unrecognized key(s) on this ${container}: \`${key}\``); - // The guide sentence is the spec's own, byte for byte: `zod/tombstone.zod.ts` - // carries a copy (the mirrors' import boundary admits no spec string), and - // this is the pin that keeps the copy equal to `@objectstack/spec/shared`'s export. + // The guide sentence is the spec's own: `zod/tombstone.zod.ts` imports it from + // `@objectstack/spec/shared` (a declared TEXT exception of the mirrors' import + // boundary, imported-defaults-8317), so the twin's refusal carries the spec's words. expect(issue!.message).toContain(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE); expect(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE).toContain('https://objectstack.ai/docs/protocol-upgrade'); expect(issue!.message).toContain('objectstack#25767'); diff --git a/ui/packages/types/src/zod/tombstone.zod.ts b/ui/packages/types/src/zod/tombstone.zod.ts index cbc0ea6d017..6e483dd158c 100644 --- a/ui/packages/types/src/zod/tombstone.zod.ts +++ b/ui/packages/types/src/zod/tombstone.zod.ts @@ -15,21 +15,7 @@ */ import { z } from 'zod'; - -/** - * The sentence every strict unknown-key refusal of `@objectstack/spec` carries - * (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, `@objectstack/spec/shared`), restated - * here rather than imported: the mirrors' import boundary - * (`../__tests__/imported-defaults-8317.test.ts`) walks every value read of a - * spec binding inside `zod/*.ts` and admits only schemas wrapped in - * `stripImportedDefaults`, vocabularies and refinements. A string is none of - * those, so it crosses as a copy — pinned byte-equal to the spec's export by - * `../__tests__/page-app-dashboard-spec-parity.test.ts`, so the two doors - * cannot drift. - */ -const RETIRED_KEY_UPGRADE_GUIDE_SENTENCE = - 'Keys retired before the previous protocol major are no longer prescribed here — ' - + 'see the protocol upgrade guide: https://objectstack.ai/docs/protocol-upgrade.'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '@objectstack/spec/shared'; /** * Declare an ADR-0049 RETIREMENT TOMBSTONE: a key that stays declared but is @@ -96,9 +82,13 @@ export function retirementTombstone(guidance: string) { * `actions` set: {@link retirementTombstone}'s primitive, `invalid_type` at the * key's own path, one string feeding both channels. The guidance opens with the * sentence the spec answers the key with and the guide sentence the spec's - * refusal carries (a copy pinned equal to the spec's export, see - * {@link RETIRED_KEY_UPGRADE_GUIDE_SENTENCE}), then says what happened to the - * key and where its value went. The TS twin is `?: never`. + * refusal carries — `RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, imported from + * `@objectstack/spec/shared` rather than restated (ui's `check:spec-symbols` + * refuses a hand-written declaration under a spec export's name); the mirrors' + * import boundary (`../__tests__/imported-defaults-8317.test.ts`) admits the + * read as a declared TEXT exception, a sentence being unable to write a value + * into a parsed document — then says what happened to the key and where its + * value went. The TS twin is `?: never`. * * ⛔ Asserts no spec behaviour that does not exist: the spec's tombstone text is * gone and is not restated; the unknown-key refusal is the only spec verdict @@ -112,13 +102,13 @@ export function retirementTombstone(guidance: string) { * @param remedy where the value went — one or two sentences ending in `Delete the key.` */ export function agedOutSpecKeyRefusal(container: string, def: string, key: string, retiredIn: string, remedy: string) { - const guidance = + const agedOutGuidance = `Unrecognized key(s) on this ${container}: \`${key}\`. ${RETIRED_KEY_UPGRADE_GUIDE_SENTENCE} ` + `\`${def}\` was retired in @objectstack/spec ${retiredIn}, and its tombstone aged out of the spec's shape one ` + 'major on (objectstack#25767, ADR-0087): the spec refuses the key as an unknown one, and this node — derived ' + 'from that shape by reference over a `.passthrough()` base — refuses it by name here rather than keeping it in ' + `silence. ${remedy}`; - return retirementTombstone(guidance); + return retirementTombstone(agedOutGuidance); } /** How a handler key sits on the TypeScript face (objectui#6124, measured per key). */ From c61ede6f9139521acb188f11f561442c50758098 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:24:00 +0000 Subject: [PATCH 11/22] test(cli): the unreachable-archive fixture spells its postgres user as `username` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test Core's cli slice 2/4 (the 4/8 shard; 3/8 at the previous head) failed audit-mail-bodies.archive case 7: the fixture's declared postgres archive carried `user`, the pre-17 spelling of `username`, which the `user` → `username` conversion rewrote at artifact load until that kit aged out of the chain one major on (ADR-0087). The strict config door now refuses the key by name — the command exits 1 with the refusal as its whole JSON and no `archive` entry, which is not the case the test pins (a declared archive this boot cannot CONNECT, reported `not-connected` and `failed-degraded`). The fixture spells the live key; the case's reading is unchanged and the file is green alone. Claude-Session: https://claude.ai/code/session_01H37vtTJRVBf1LkyYNW5yfo Co-authored-by: Claude --- .../migrate/audit-mail-bodies.archive.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts b/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts index 801592cecb3..c6f44db17f4 100644 --- a/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts +++ b/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts @@ -288,7 +288,7 @@ beforeAll(async () => { // engine instead of failing. unreachableDir = mkdtempSync(join(tmpdir(), 'os-24899-unreachable-')); writeArtifact(unreachableDir, [ - { name: 'archive', driver: 'postgres', config: { host: '127.0.0.1', port: 1, database: 'archive', user: 'os24899' }, autoConnect: true }, + { name: 'archive', driver: 'postgres', config: { host: '127.0.0.1', port: 1, database: 'archive', username: 'os24899' }, autoConnect: true }, ]); unreachableRun = await runCommand(unreachableDir, join(unreachableDir, 'main.db'), ['--apply', '--yes', '--json']); }, HOOK_TIMEOUT_MS); From c35a0c1d6801a05df681cf34b859b25fad90355a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:27:37 +0000 Subject: [PATCH 12/22] fix(ui/types): the list view twin and three public-block arms refuse the spec keys that aged out at 18.2 The re-review of 2718c3cf14 found the console's `ListViewSchema` twin built like the App and Dashboard twins this PR already fixed: `BaseSchema` (`.passthrough()`) plus the spec list view's members by reference. The five list view tombstones that aged out of the spec at 18.2 (`striped`, `bordered`, `virtualScroll`, `responsive`, `performance`) left the projection with no refusal of their own, so an authored `striped: true` parsed green and was kept. Each is now an `agedOutSpecKeyRefusal` arm on the twin. The TS face is derived from the twin (`ListViewInferred`), so each key is `?: undefined` there with no hand-kept member; the page `list-view` node's `properties` bag reads the twin's shape and refuses the five too, and its flat spelling gets the row's own retirement. A parse census of the other removed component tombstones found three more flat spellings kept in silence on public-block arms: `page:header` `icon`, `page:card` `actions` and `record:details` `layout`. `flatPropRefusals` reads the spec row's key set, and the row no longer carries the name, so `BaseSchema` kept a flat value. Each arm now declares the console's own refusal; the bag spelling was already refused by the strict row. Pins: list-view-aged-out-spec-keys-25767 (twin, bag, flat, TS face) and public-block-aged-out-spec-keys-25767 (flat, bag), each with the spec's own unknown-key verdict as the control. Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- ...list-view-aged-out-spec-keys-25767.test.ts | 124 ++++++++++++++++++ ...lic-block-aged-out-spec-keys-25767.test.ts | 92 +++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 6 +- ui/packages/types/src/zod/objectql.zod.ts | 73 ++++++++--- .../types/src/zod/public-blocks.zod.ts | 33 +++++ 5 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 ui/packages/types/src/__tests__/list-view-aged-out-spec-keys-25767.test.ts create mode 100644 ui/packages/types/src/__tests__/public-block-aged-out-spec-keys-25767.test.ts diff --git a/ui/packages/types/src/__tests__/list-view-aged-out-spec-keys-25767.test.ts b/ui/packages/types/src/__tests__/list-view-aged-out-spec-keys-25767.test.ts new file mode 100644 index 00000000000..f18e2857cbb --- /dev/null +++ b/ui/packages/types/src/__tests__/list-view-aged-out-spec-keys-25767.test.ts @@ -0,0 +1,124 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The list view twin refuses the five spec tombstones that aged out at 18.2 + * (objectstack#25767, ADR-0087 one major on). + * + * `zod/objectql.zod.ts`'s `ListViewSchema` is `BaseSchema` (`.passthrough()`) + * extended with the spec list view's members by reference. Through spec 18.1 + * that projection carried `striped`, `bordered`, `virtualScroll` (retired by + * objectstack#7176) and `responsive`, `performance` (the #3896 audit + * close-out) as `retiredKey()` tombstones, so the twin refused each at the + * key's own path. At 18.2 the tombstones aged out of the spec's shape: the + * spec refuses each as an UNKNOWN key at the view's root, naming the protocol + * upgrade guide, and the projection carries nothing under the name — so + * without an arm of its own the twin would KEEP an authored `striped: true` + * in silence. The arms are the console's own refusals + * (`agedOutSpecKeyRefusal`, `zod/tombstone.zod.ts`). + * + * Each key is pinned on three faces, with the spec's own verdict as control: + * + * - the flat twin (`ListViewSchema`): `invalid_type` at the key's path, the + * message naming the key, the spec's guide sentence and the age-out; + * - the page `list-view` node (`ListViewBlockSchema`), whose `properties` + * bag reads the twin's shape: refused in the bag and, through the row's + * own retirement, written flat — `responsive` excepted, which is a node- + * level key of the spec's page component and keeps that meaning flat; + * - the TypeScript face: `ListViewSchema` is derived from the twin + * (`ListViewInferred = z.input<…>`), so each key is `?: undefined` there and + * an authored value is a compile error. There is no hand-kept `?: never` + * member to keep in step, as with the twin's `body` / `children`. + * + * The control: the spec's `ListViewSchema` refuses each key as an unknown one. + * If the spec ever re-admits a key, the twin's refusal would be hiding a live + * member, and the control goes red first. + */ + +import { describe, it, expect } from 'vitest'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '@objectstack/spec/shared'; +import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { ListViewSchema as OuiListViewSchema, ListViewBlockSchema } from '../zod/objectql.zod.js'; +import type { ListViewSchema } from '../objectql'; + +const AGED_OUT_LIST_VIEW_KEYS = ['striped', 'bordered', 'virtualScroll', 'responsive', 'performance'] as const; + +const TWIN_LEGAL = { type: 'list-view', objectName: 'contact' }; +const BLOCK_LEGAL = { type: 'list-view', properties: { data: { provider: 'object', object: 'contact' } } }; +const SPEC_LEGAL = { columns: ['name'] }; + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +const issueAt = (issues: readonly Issue[], path: string) => issues.find((i) => i.path.join('.') === path); + +const expectAgedOutIssue = (issue: Issue | undefined, key: string) => { + expect(issue, `no issue at '${key}' — the value is kept in silence`).toBeTruthy(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toContain(`Unrecognized key(s) on this list view: \`${key}\``); + expect(issue!.message).toContain(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE); + expect(issue!.message).toContain('objectstack#25767'); +}; + +describe('list view: the five spec tombstones that aged out at 18.2 are the twin\'s own refusals (objectstack#25767)', () => { + it('accept legs: a list view without them parses on every face', () => { + expect(OuiListViewSchema.safeParse(TWIN_LEGAL).success).toBe(true); + expect(ListViewBlockSchema.safeParse(BLOCK_LEGAL).success).toBe(true); + expect(SpecListViewSchema.safeParse(SPEC_LEGAL).success).toBe(true); + }); + + it.each(AGED_OUT_LIST_VIEW_KEYS)('`%s`: the spec refuses it as an unknown key (the control)', (key) => { + const r = SpecListViewSchema.safeParse({ ...SPEC_LEGAL, [key]: true }); + expect(r.success, `the spec accepts '${key}' again — the twin's refusal would hide a live member`).toBe(false); + const issues = r.success ? [] : (r.error.issues as readonly Issue[]); + expect(issueAt(issues, key), `the spec refuses '${key}' at its own path — a tombstone came back`).toBeUndefined(); + const root = issues.filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(new RegExp(`Unrecognized key\\(s\\)[^\\n]*\`${key}\``)); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + }); + + it.each(AGED_OUT_LIST_VIEW_KEYS)('`%s`: the flat twin refuses it by name', (key) => { + expect(Object.keys(OuiListViewSchema.shape), `'${key}' is no longer declared`).toContain(key); + const r = OuiListViewSchema.safeParse({ ...TWIN_LEGAL, [key]: true }); + expect(r.success, `the twin keeps '${key}'`).toBe(false); + expectAgedOutIssue(r.success ? undefined : issueAt(r.error.issues as readonly Issue[], key), key); + }); + + it.each(AGED_OUT_LIST_VIEW_KEYS)('`%s`: a page `list-view` node refuses it in its `properties` bag', (key) => { + const r = ListViewBlockSchema.safeParse({ ...BLOCK_LEGAL, properties: { ...BLOCK_LEGAL.properties, [key]: true } }); + expect(r.success, `the bag keeps '${key}'`).toBe(false); + expectAgedOutIssue(r.success ? undefined : issueAt(r.error.issues as readonly Issue[], `properties.${key}`), key); + }); + + it.each(AGED_OUT_LIST_VIEW_KEYS.filter((k) => k !== 'responsive'))( + '`%s`: a page `list-view` node refuses it written flat, with the row\'s own retirement', + (key) => { + const r = ListViewBlockSchema.safeParse({ ...BLOCK_LEGAL, [key]: true }); + expect(r.success, `the node keeps a flat '${key}'`).toBe(false); + expectAgedOutIssue(r.success ? undefined : issueAt(r.error.issues as readonly Issue[], key), key); + }, + ); + + it('the TypeScript face: each key is `?: undefined`, so an authored value is a compile error', () => { + const ok: ListViewSchema = { type: 'list-view', objectName: 'contact' }; + // @ts-expect-error — `striped` aged out of the spec (objectstack#25767); the twin's arm types it `undefined`. + const striped: ListViewSchema = { type: 'list-view', objectName: 'contact', striped: true }; + // @ts-expect-error — `bordered`, the same. + const bordered: ListViewSchema = { type: 'list-view', objectName: 'contact', bordered: true }; + // @ts-expect-error — `virtualScroll`, the same. + const virtualScroll: ListViewSchema = { type: 'list-view', objectName: 'contact', virtualScroll: true }; + // @ts-expect-error — `responsive`, the same. + const responsive: ListViewSchema = { type: 'list-view', objectName: 'contact', responsive: { breakpoint: 'md' } }; + // @ts-expect-error — `performance`, the same. + const performance: ListViewSchema = { type: 'list-view', objectName: 'contact', performance: { lazyLoad: true } }; + // The compile-time verdict and the parse verdict agree. + for (const authored of [striped, bordered, virtualScroll, responsive, performance]) { + expect(OuiListViewSchema.safeParse(authored).success).toBe(false); + } + expect(OuiListViewSchema.safeParse(ok).success).toBe(true); + }); +}); diff --git a/ui/packages/types/src/__tests__/public-block-aged-out-spec-keys-25767.test.ts b/ui/packages/types/src/__tests__/public-block-aged-out-spec-keys-25767.test.ts new file mode 100644 index 00000000000..77ed02ce490 --- /dev/null +++ b/ui/packages/types/src/__tests__/public-block-aged-out-spec-keys-25767.test.ts @@ -0,0 +1,92 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Three public-block arms refuse the flat spelling of a row member whose spec + * tombstone aged out at 18.2 (objectstack#25767, ADR-0087 one major on). + * + * Each public-block arm (`zod/public-blocks.zod.ts`) is `BaseSchema` + * (`.passthrough()`) plus `flatPropRefusals` over its spec row, which refuses + * a row member written flat on the node by name — and, for a member the row + * RETIRED (a `z.never` tombstone), with the row's own retirement. Through spec + * 18.1 `page:header`'s `icon`, `page:card`'s `actions` and `record:details`'s + * `layout` were such tombstones. At 18.2 they aged out of the rows: the + * strict row refuses `properties.KEY` as an unknown key naming the upgrade + * guide, and the spec's page component refuses a flat one the same way, but + * `flatPropRefusals` reads the row's key set and no longer sees the name — so + * `BaseSchema` would KEEP a flat value in silence. Each arm now declares the + * console's own refusal (`agedOutSpecKeyRefusal`, `zod/tombstone.zod.ts`). + * + * Pinned per key: the flat refusal (`invalid_type` at the key, naming the key, + * the spec's guide sentence and the age-out); the bag refusal (the row's own + * unknown-key verdict); and the control — the spec's `PageComponentSchema` + * refuses the flat key as an unknown one at the node's root. No TypeScript + * face restates these nodes (`zod-mirror-parity.test.ts` says so per arm). + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { RETIRED_KEY_UPGRADE_GUIDE_SENTENCE } from '@objectstack/spec/shared'; +import { PageComponentSchema as SpecPageComponentSchema } from '@objectstack/spec/ui'; +import { PageCardBlockSchema, PageHeaderBlockSchema, RecordDetailsBlockSchema } from '../zod/public-blocks.zod.js'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; + +interface Case { + type: string; + key: string; + value: unknown; + oui: z.ZodType; + properties: Record; +} + +const CASES: Case[] = [ + { type: 'page:header', key: 'icon', value: 'star', oui: PageHeaderBlockSchema, properties: { title: 'Accounts' } }, + { type: 'page:card', key: 'actions', value: [], oui: PageCardBlockSchema, properties: {} }, + { type: 'record:details', key: 'layout', value: 'auto', oui: RecordDetailsBlockSchema, properties: {} }, +]; + +describe.each(CASES)('$type `$key`: a row tombstone that aged out at 18.2 is refused flat by the arm itself (objectstack#25767)', ({ type, key, value, oui, properties }) => { + const node = { type, properties }; + + it('accept leg: the node without it parses on both faces', () => { + expect(oui.safeParse(node).success).toBe(true); + expect(SpecPageComponentSchema.safeParse(node).success).toBe(true); + }); + + it('the spec refuses it written flat as an unknown key at the node (the control)', () => { + const r = SpecPageComponentSchema.safeParse({ ...node, [key]: value }); + expect(r.success, `the spec accepts a flat '${key}' again`).toBe(false); + const root = r.success ? '' : (r.error.issues as readonly Issue[]).filter((i) => i.path.length === 0).map((i) => i.message).join('\n'); + expect(root).toMatch(new RegExp(`Unrecognized key\\(s\\) on this page component[^\\n]*\`${key}\``)); + expect(root).toContain('https://objectstack.ai/docs/protocol-upgrade'); + }); + + it('the arm refuses it written flat, by name, instead of keeping it', () => { + expect(Object.keys((oui as unknown as { shape: Record }).shape)).toContain(key); + const r = oui.safeParse({ ...node, [key]: value }); + expect(r.success, `the arm keeps a flat '${key}'`).toBe(false); + const issue = r.success ? undefined : (r.error.issues as readonly Issue[]).find((i) => i.path.join('.') === key); + expect(issue, `no issue at '${key}'`).toBeTruthy(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toContain(`Unrecognized key(s) on this page component: \`${key}\``); + expect(issue!.message).toContain(RETIRED_KEY_UPGRADE_GUIDE_SENTENCE); + expect(issue!.message).toContain('objectstack#25767'); + expect(issue!.message).toContain(`\`properties.${key}\` is refused as an unknown key too`); + }); + + it('the arm refuses it in the `properties` bag with the row\'s own unknown-key verdict', () => { + const r = oui.safeParse({ ...node, properties: { ...properties, [key]: value } }); + expect(r.success, `the bag keeps '${key}'`).toBe(false); + const bag = r.success ? [] : (r.error.issues as readonly Issue[]).filter((i) => i.path.join('.') === 'properties'); + const message = bag.map((i) => i.message).join('\n'); + expect(bag.map((i) => i.code)).toContain('unrecognized_keys'); + expect(message).toMatch(new RegExp(`Unrecognized key\\(s\\) on this \`${type}\`[^\\n]*\`${key}\``)); + expect(message).toContain('https://objectstack.ai/docs/protocol-upgrade'); + }); +}); diff --git a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts index 89fd88bc6a1..0fd3d590b24 100644 --- a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4929,11 +4929,11 @@ const EXCLUSIONS: Readonly> = { // pinned in `./content-channel-public-blocks-9256.test.ts`; each entry below // names them where they apply. 'public-blocks.zod.ts#PageHeaderBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `page:header` literal + `properties`, which IS `ComponentPropsMap['page:header']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `page:header` literal + `properties`, which IS `ComponentPropsMap['page:header']`, + an objectstack#25767 `agedOutSpecKeyRefusal` for the flat `icon` (the row's tombstone aged out at spec 18.2, so `flatPropRefusals` no longer sees the name), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageTabsBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:tabs` literal + `properties`, which IS `ComponentPropsMap['page:tabs']`, + the `onTabChange` runtime-slot refusal (objectui#6124) its renderer's read requires, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageCardBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `page:card` literal + `properties`, which IS `ComponentPropsMap['page:card']`, + the objectui#10872 batch-6 `body` / `children` refusals (its renderer reads the child list, whose home is the row's `properties.children`); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `page:card` literal + `properties`, which IS `ComponentPropsMap['page:card']`, + an objectstack#25767 `agedOutSpecKeyRefusal` for the flat `actions` (the row's tombstone aged out at spec 18.2, so `flatPropRefusals` no longer sees the name), + the objectui#10872 batch-6 `body` / `children` refusals (its renderer reads the child list, whose home is the row's `properties.children`); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageAccordionBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:accordion` literal + `properties`, which IS `ComponentPropsMap['page:accordion']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageSectionBlockSchema': @@ -4943,7 +4943,7 @@ const EXCLUSIONS: Readonly> = { 'public-blocks.zod.ts#PageSidebarBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:sidebar` literal + `properties`, which IS `ComponentPropsMap['page:sidebar']`, + the objectui#10872 batch-6 `body` / `children` refusals (its renderer reads the child list, whose home is the row's `properties.children`); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordDetailsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `record:details` literal + `properties`, which IS `ComponentPropsMap['record:details']`, + `dataSource`, which IS the spec's `ElementDataSourceSchema` (objectui#12294), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `record:details` literal + `properties`, which IS `ComponentPropsMap['record:details']`, + an objectstack#25767 `agedOutSpecKeyRefusal` for the flat `layout` (the row's tombstone aged out at spec 18.2, so `flatPropRefusals` no longer sees the name), + `dataSource`, which IS the spec's `ElementDataSourceSchema` (objectui#12294), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordHighlightsBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `record:highlights` literal + `properties`, which IS `ComponentPropsMap['record:highlights']`, + `dataSource`, which IS the spec's `ElementDataSourceSchema` (objectui#12294), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordRelatedListBlockSchema': diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index f71a4af7268..4f267f32d47 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -93,7 +93,7 @@ import { // reference. import { InlineGridColumnSchema as SpecInlineGridColumnSchema } from '@objectstack/spec/data'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; -import { aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; +import { agedOutSpecKeyRefusal, aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; import { DataTableSchema, DrillDownConfigSchema, PivotTableSchema } from './data-display.zod.js'; // The kanban CARD vocabulary has one authority (`./complex.zod.ts`); the // `object-kanban` lane below reads it rather than restating it (objectui#8913). @@ -2259,24 +2259,24 @@ export const ListViewSchema = BaseSchema // `scripts/check-spec-symbol-derivation.mjs` instead of hidden one hop away. // // Imported here: data, filter, sort, searchableFields, - // filterableFields, resizable, striped, bordered, compactToolbar, selection, navigation, + // filterableFields, resizable, compactToolbar, selection, navigation, // pagination, chart, tree, rowHeight, grouping, rowColor, hiddenFields, fieldOrder, - // rowActions, bulkActions, bulkActionDefs, virtualScroll, inlineEdit, userActions, - // appearance, tabs, addRecord, showRecordCount, allowPrinting, emptyState, responsive, - // performance. + // rowActions, bulkActions, bulkActionDefs, inlineEdit, userActions, + // appearance, tabs, addRecord, showRecordCount, allowPrinting, emptyState. // - // `striped`, `bordered` and `virtualScroll` are in that list because the spec - // pin still carries them, NOT because objectui offers them: objectstack#7176 - // retired all three (maintainer-ruled 2026-08-10) after measuring every - // objectui reader as pass-through — no renderer ever applied one. objectui's - // own declarations and the forwarding chain came out with objectui#4649; what - // is left here is the by-reference import, which is exactly what must stay. - // It carries the spec's `retiredKey()` tombstones in on the GA bump, so an - // author writing one gets a rejection from the protocol rather than silence. - // Do NOT add them to LIST_VIEW_LOCAL_OVERRIDES to "clean this up" — that - // excludes the tombstone and hands the key back to `BaseSchema`'s - // passthrough, turning a loud rejection into a silently-accepted dead key. - // Re-forwarding needs an implementation card filed first (the ruling's text). + // `striped`, `bordered`, `virtualScroll`, `responsive` and `performance` are + // NOT in that list any more: the spec carried them as `retiredKey()` + // tombstones through 18.1 (objectstack#7176 retired the first three, + // maintainer-ruled 2026-08-10, after measuring every objectui reader as + // pass-through; the #3896 audit close-out the last two), and this import + // brought each tombstone in by reference, so an author got a rejection from + // the protocol rather than silence. At spec 18.2 the tombstones aged out of + // the shape one major on (objectstack#25767, ADR-0087), so the projection + // carries nothing under those names and `BaseSchema`'s passthrough would + // turn the loud rejection into a silently-accepted dead key. The five are + // the console's OWN refusals now, declared in the `.extend({...})` below + // (`agedOutSpecKeyRefusal`). Re-forwarding any of them needs an + // implementation card filed first (the ruling's text). .extend(specFieldsExcept(stripImportedDefaults(SpecListViewSchema).shape, LIST_VIEW_LOCAL_OVERRIDES).shape) .extend({ // Component discriminator — load-bearing for the ObjectQLComponentSchema union. @@ -2454,6 +2454,45 @@ export const ListViewSchema = BaseSchema // ⚠️ This arm feeds its own TypeScript face (`ListViewInferred` below), so these two members are // what put `body?: undefined` / `children?: undefined` on `ListViewSchema`; there is no separate // `?: never` pair to keep in step. + // + // The five list view keys the spec retired at 17.0.0 and carried as + // `retiredKey()` tombstones through 18.1 — flowing in by reference through + // the projection above, so an authored value was refused at the key's own + // path. At spec 18.2 the tombstones aged out of `ListViewSchema` one major + // on (objectstack#25767, ADR-0087): the spec refuses each as an unknown key + // naming the upgrade guide, and the projection no longer carries the name, + // so without these arms `BaseSchema`'s `.passthrough()` would KEEP an + // authored `striped: true` in silence. Each is the console's own refusal + // now (`agedOutSpecKeyRefusal`, the reasoning on its docblock). As with + // `body` / `children` above, the TypeScript face is derived from this arm + // (`ListViewInferred`), so each key is `?: undefined` on `ListViewSchema` + // with no hand-kept twin; `ListViewPropsBag` reads this shape, so a page + // `list-view` node refuses the five in its `properties` bag and, through + // the row's own retirement, written flat. Pinned by + // `../__tests__/list-view-aged-out-spec-keys-25767.test.ts`. + striped: agedOutSpecKeyRefusal( + 'list view', 'ListView.striped', 'striped', '17.0.0', + 'Every measured reader only copied it forward and no renderer ever applied it (objectstack#7176), so ' + + 'there is no authorable striped-rows switch. Delete the key.', + ), + bordered: agedOutSpecKeyRefusal( + 'list view', 'ListView.bordered', 'bordered', '17.0.0', + 'Every measured reader only copied it forward and no renderer ever applied it (objectstack#7176); the ' + + 'grid frame is the renderer\'s own constant, not authorable. Delete the key.', + ), + virtualScroll: agedOutSpecKeyRefusal( + 'list view', 'ListView.virtualScroll', 'virtualScroll', '17.0.0', + 'Every measured reader only copied it forward and no grid ever virtualized off it (objectstack#7176). ' + + 'Large datasets page via `pagination`. Delete the key.', + ), + responsive: agedOutSpecKeyRefusal( + 'list view', 'ListView.responsive', 'responsive', '17.0.0', + 'No renderer ever read it; the grid is responsive by its own layout rules. Delete the key.', + ), + performance: agedOutSpecKeyRefusal( + 'list view', 'ListView.performance', 'performance', '17.0.0', + 'No renderer or runtime read it; list view performance tuning was never implemented. Delete the key.', + ), }) // ⭐ THE SPEC'S OBJECT-LEVEL CHECKS, re-attached (objectui#7715, ruling B1). // diff --git a/ui/packages/types/src/zod/public-blocks.zod.ts b/ui/packages/types/src/zod/public-blocks.zod.ts index fcc0b3b9868..e90471842f9 100644 --- a/ui/packages/types/src/zod/public-blocks.zod.ts +++ b/ui/packages/types/src/zod/public-blocks.zod.ts @@ -231,6 +231,7 @@ import { import { BaseSchema } from './base.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; import { + agedOutSpecKeyRefusal, aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, @@ -498,6 +499,20 @@ export const PageHeaderBlockSchema = BaseSchema.extend({ ...NODE_ENVELOPE, // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. ...flatPropRefusals('page:header', stripImportedDefaults(SpecPageHeaderProps)), + // The row's `icon` was a `retiredKey()` tombstone through spec 18.1, so the + // spread above gave a flat `icon` the row's own retirement. At 18.2 it aged + // out of `PageHeaderProps` one major on (objectstack#25767, ADR-0087): the + // row refuses `properties.icon` as an unknown key, and the spec's page + // component refuses a flat one the same way, but the spread no longer sees + // the name, so `BaseSchema`'s `.passthrough()` would KEEP a flat value in + // silence. The console's own refusal (`agedOutSpecKeyRefusal`), pinned by + // `../__tests__/public-block-aged-out-spec-keys-25767.test.ts`. + icon: agedOutSpecKeyRefusal( + 'page component', 'PageHeaderProps.icon', 'icon', '17.0.0', + 'No renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the ' + + 'header\'s own props, and `properties.icon` is refused as an unknown key too. The header\'s identity is ' + + 'drawn by the record chrome (`recordChrome`), and each action carries its own `icon`. Delete the key.', + ), properties: propsBag('page:header', stripImportedDefaults(SpecPageHeaderProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -555,6 +570,15 @@ export const PageCardBlockSchema = BaseSchema.extend({ ...NODE_ENVELOPE, // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. ...flatPropRefusals('page:card', stripImportedDefaults(SpecPageCardProps)), + // `actions` aged out of `PageCardProps` at spec 18.2 (objectstack#25767, + // ADR-0087), as `page:header`'s `icon` did: the console's own refusal of the + // flat spelling, which `BaseSchema`'s `.passthrough()` would otherwise keep. + actions: agedOutSpecKeyRefusal( + 'page component', 'PageCardProps.actions', 'actions', '17.0.0', + 'No renderer ever read it: the card renderer has no actions area, and `properties.actions` is refused as an ' + + 'unknown key too. Author the buttons as components in the card\'s `properties.children` or ' + + '`properties.footer` (`element:button`, `record:quick_actions`), which is what renders. Delete the key.', + ), properties: propsBag('page:card', stripImportedDefaults(SpecPageCardProps)), // objectui#10872 batch 6: the child list is the row's `children` member, in the bag. body: retirementTombstone(PAGE_CARD_CHILD_LIST), @@ -685,6 +709,15 @@ export const RecordDetailsBlockSchema = BaseSchema.extend({ ...NODE_ENVELOPE, // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. ...flatPropRefusals('record:details', stripImportedDefaults(SpecRecordDetailsProps)), + // `layout` aged out of `RecordDetailsProps` at spec 18.2 (objectstack#25767, + // ADR-0087), as `page:header`'s `icon` did: the console's own refusal of the + // flat spelling, which `BaseSchema`'s `.passthrough()` would otherwise keep. + layout: agedOutSpecKeyRefusal( + 'page component', 'RecordDetailsProps.layout', 'layout', '17.0.0', + 'Its `auto` | `custom` semantics were never implemented, and `properties.layout` is refused as an unknown ' + + 'key too. The body is chosen by what you author: `properties.sections` renders explicit groups, and ' + + 'omitting it falls back to the object\'s `highlightFields`. Delete the key.', + ), properties: propsBag('record:details', stripImportedDefaults(SpecRecordDetailsProps)), // objectui#12294: the one-record binding this block's renderer reads (`RECORD_BLOCK_DATA_SOURCE`). dataSource: RECORD_BLOCK_DATA_SOURCE, From a07453bd544943d4ba978f01c790a10ee1c9eafb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:29:50 +0000 Subject: [PATCH 13/22] test(ui/types): the list view drift guard names the aged-out refusals as their own category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `list-view-spec-parity`'s rogue-key check read the five new `agedOutSpecKeyRefusal` arms as objectui-only fields. They are neither a sanctioned local extension nor a spec member: a fifth category, `AGED_OUT_SPEC_REFUSALS`, checked like `HANDLER_KEY_REFUSALS` — each member must be declared on the arm, refuse every value, and be undeclared by the spec, so the set cannot park a live field. Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- .../__tests__/list-view-spec-parity.test.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/ui/packages/types/src/__tests__/list-view-spec-parity.test.ts b/ui/packages/types/src/__tests__/list-view-spec-parity.test.ts index 13f8a8b9152..caf5866b60c 100644 --- a/ui/packages/types/src/__tests__/list-view-spec-parity.test.ts +++ b/ui/packages/types/src/__tests__/list-view-spec-parity.test.ts @@ -147,6 +147,19 @@ const HANDLER_KEY_REFUSALS = new Set([ */ const PAGE_COMPONENT_ENVELOPE = new Set(['dataSource']); +/** + * A FIFTH category (objectstack#25767): spec keys whose `retiredKey()` + * tombstones aged out of the spec's `ListViewSchema` at 18.2, one major on + * (ADR-0087). Through 18.1 each flowed in by reference and refused at its own + * path; the spec now refuses each as an unknown key, and this arm declares the + * refusal itself (`agedOutSpecKeyRefusal`) so `BaseSchema.passthrough()` does + * not keep an authored value. Like {@link HANDLER_KEY_REFUSALS} the arm accepts + * nothing under these names, and the test below checks that — plus that the + * spec really declares none of them — so this set cannot park a live field. + * Full pins: `./list-view-aged-out-spec-keys-25767.test.ts`. + */ +const AGED_OUT_SPEC_REFUSALS = new Set(['striped', 'bordered', 'virtualScroll', 'responsive', 'performance']); + describe('ListView spec parity (#2231 drift guard)', () => { it('covers every @objectstack/spec ListView field (spec cannot grow a field objectui ignores)', () => { // Fails when the spec adds a field that objectui neither imports nor envelope-owns — @@ -170,11 +183,25 @@ describe('ListView spec parity (#2231 drift guard)', () => { !ENVELOPE.has(k) && !SANCTIONED_LOCAL.has(k) && !HANDLER_KEY_REFUSALS.has(k) && - !PAGE_COMPONENT_ENVELOPE.has(k), + !PAGE_COMPONENT_ENVELOPE.has(k) && + !AGED_OUT_SPEC_REFUSALS.has(k), ); expect(rogue).toEqual([]); }); + it('every AGED_OUT_SPEC_REFUSALS member really refuses and is no spec member — the set cannot hide a field (objectstack#25767)', () => { + const node = (extra: Record) => ({ type: 'list-view', objectName: 'accounts', ...extra }); + // ⭐ CONTROL first: the bare node parses, or the loop below proves nothing. + expect(OuiListViewSchema.safeParse(node({})).success).toBe(true); + for (const key of AGED_OUT_SPEC_REFUSALS) { + expect(ouiKeys.has(key), `${key} is listed but not declared on the arm`).toBe(true); + expect(specShape[key], `the spec declares ${key} again — triage it as a spec field instead`).toBeUndefined(); + for (const value of [true, false, {}, 'x']) { + expect(OuiListViewSchema.safeParse(node({ [key]: value })).success, `${key} accepted ${JSON.stringify(value)}`).toBe(false); + } + } + }); + it('every PAGE_COMPONENT_ENVELOPE member is the spec binding — the set cannot hide a local field (objectui#11070)', () => { const node = (extra: Record) => ({ type: 'list-view', objectName: 'accounts', ...extra }); for (const key of PAGE_COMPONENT_ENVELOPE) { From 045298a695fe5601cac99e5c5fb7889124c371c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:39:42 +0000 Subject: [PATCH 14/22] chore(sdui): re-record the core and types source digests after the merge of main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node scripts/gen-sdui-manifest-node.mjs` over the merged tree: the manifest itself is unchanged (sha256 1f6e1ccb925a…, 106 components); the core and types records move with main's sources and this round's ui/packages/types edits. Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- scripts/sdui-manifest.sources/core.json | 2 +- scripts/sdui-manifest.sources/types.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/sdui-manifest.sources/core.json b/scripts/sdui-manifest.sources/core.json index 8fe6fe5a4f1..2e99b1ba1a8 100644 --- a/scripts/sdui-manifest.sources/core.json +++ b/scripts/sdui-manifest.sources/core.json @@ -1 +1 @@ -{"package":"ui/packages/core","sources":112,"sha256":"810fe0e8f6db868fd443540cb2e30a85f691a48a08d58ba7ba8db0c577d579e7","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/core","sources":112,"sha256":"51d6d3cec12b4cc7e6b5fa3253c589d726fa83109ec3e5f5728ee77ba1a8f81d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/types.json b/scripts/sdui-manifest.sources/types.json index ea9118118b3..c0edd77e518 100644 --- a/scripts/sdui-manifest.sources/types.json +++ b/scripts/sdui-manifest.sources/types.json @@ -1 +1 @@ -{"package":"ui/packages/types","sources":81,"sha256":"f8450c19832a17c2f45e0164ec9e6e68cb247efdc00c81998c77e8c42c11da65","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/types","sources":81,"sha256":"749b3a626837cef11dd0e75545f132ec80eeb4e8ed080264ec90cc3ab5d2e670","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From f7c04818bd65e97f065ebcd83a563dc159b2904c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:10:59 +0000 Subject: [PATCH 15/22] test(app-shell): the relay census declares the five aged-out list view keys again `ObjectView.relayRungCensus-7559` reads every `ListViewSchema` member and asks for a relay rung or a declared absence. The five list view keys whose spec tombstones aged out at 18.2 had left that shape, so their `unread` rows were dropped; the twin now declares each as its own refusal (`agedOutSpecKeyRefusal`), which makes them members again. They return as `unread`, each reason naming the age-out and the refusal: an authored value never parses, so there is nothing for a rung to relay. CI's Test UI (15/16) on 045298a695 was red on exactly this. Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- .../views/ObjectView.relayRungCensus-7559.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts index 6313ce791c5..917c248f1da 100644 --- a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -466,10 +466,16 @@ const ABSENCES: Record = { // A rung for one of these would relay a value into a void, and the void is // the finding: each is pinned here so that the day a reader appears, this // census reddens instead of the value silently not arriving. - // `striped`, `bordered`, `virtualScroll`, `responsive` and `performance` sat - // here as `unread` while the spec import carried their tombstones; the - // tombstones aged out at spec 18.2 (objectstack#25767), so they are no longer - // `ListViewSchema` members and the census has nothing to declare for them. + // `striped`, `bordered`, `virtualScroll`, `responsive` and `performance`: + // the spec import carried their tombstones through 18.1; at spec 18.2 the + // tombstones aged out (objectstack#25767), and the twin now declares each as + // its own refusal (`agedOutSpecKeyRefusal`), so an authored value never + // parses and there is nothing for a rung to relay. + striped: { kind: 'unread', reason: 'Retired by objectstack#7176 (maintainer-ruled 2026-08-10) after every objectui reader was measured as pass-through. Its spec tombstone aged out at 18.2 (objectstack#25767); `ListViewSchema` declares it only as the console\'s own refusal (`agedOutSpecKeyRefusal`).' }, + bordered: { kind: 'unread', reason: 'Retired by objectstack#7176, as `striped`; declared only as the console\'s own refusal since the 18.2 age-out (objectstack#25767).' }, + virtualScroll: { kind: 'unread', reason: 'Retired by objectstack#7176, as `striped`; declared only as the console\'s own refusal since the 18.2 age-out (objectstack#25767).' }, + responsive: { kind: 'unread', reason: 'Retired at spec 17.0.0 with no reader in either repo; declared only as the console\'s own refusal since the 18.2 age-out (objectstack#25767).' }, + performance: { kind: 'unread', reason: 'Retired at spec 17.0.0 with no reader in either repo; declared only as the console\'s own refusal since the 18.2 age-out (objectstack#25767).' }, requiredPermissions: { kind: 'unread', reason: 'The ADR-0066 audience gate `@objectstack/spec` 18 adds to a list view (objectui#12030). The SERVER enforces it before the view is served: a user who lacks the capabilities is never listed the view and is refused it by name, so nothing on the ListView path has a value to consume.' }, filterableFields: { kind: 'unread', reason: 'Retired by objectstack#24640 (maintainer ruling C, pulled into 18.0): no per-view Filter-panel whitelist replaces it. objectui#13118 removed its one reader, the whitelist branch of `ListView`\'s Filter-panel field list, and this relay\'s rung with it. Present only as the spec import that carries its tombstone from the pin bump that takes the retirement.' }, pageName: { kind: 'unread', reason: 'Page-context key with no reader on the list path.' }, From 0a482d6898cec8a52153d252ca426e6b5a23edb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 02:44:53 +0000 Subject: [PATCH 16/22] chore(spec): regenerate the merge's generated artifacts over main's side; the branch's aged-out data.json lines stay deleted Main moved packages/spec/authorable-surface/data.json (Seed files rows) and two reference pages; the merge took main's side and these are the regenerated results. Field:conditionalRequired, Index:partial and Index:type stay deleted: they aged out on this branch (objectstack#25767). Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- content/docs/references/api/package-api.mdx | 2 ++ content/docs/references/kernel/manifest.mdx | 1 + packages/spec/authorable-surface/data.json | 8 ++++++++ 3 files changed, 11 insertions(+) diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index ce66fae5096..a16418000aa 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -248,6 +248,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | | **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn | | **records** | `Record[]` | ✅ | Data records | +| **files** | `Record` | optional | Bytes of the package files the records name with `seedFile(path)`, keyed by path. Written by `os build`, which reads each path relative to the package root; the loader stores each one as a file the written record owns. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | @@ -355,6 +356,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | | **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn | | **records** | `Record[]` | ✅ | Data records | +| **files** | `Record` | optional | Bytes of the package files the records name with `seedFile(path)`, keyed by path. Written by `os build`, which reads each path relative to the package root; the loader stores each one as a file the written record owns. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index b06ef84f934..2c6f02925b9 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -102,6 +102,7 @@ Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (cop | **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | | **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn | | **records** | `Record[]` | ✅ | Data records | +| **files** | `Record` | optional | Bytes of the package files the records name with `seedFile(path)`, keyed by path. Written by `os build`, which reads each path relative to the package root; the loader stores each one as a file the written record owns. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 1b74f470a63..dc6c87af478 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -904,11 +904,19 @@ "data/Seed:columns", "data/Seed:env", "data/Seed:externalId", + "data/Seed:files", "data/Seed:locale", "data/Seed:mode", "data/Seed:object", "data/Seed:records", "data/SeedColumn:applyOn", + "data/SeedFileContent:contentType", + "data/SeedFileContent:data", + "data/SeedFileContent:sha256", + "data/SeedFileContent:size", + "data/SeedFileReference:$file", + "data/SeedFileReference:contentType", + "data/SeedFileReference:name", "data/SeedIdentity:org", "data/SeedIdentity:user", "data/SeedLoadResult:errored", From 9a6ec3c94e9bfa0fe2d54b355d90efe737f519a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:03:05 +0000 Subject: [PATCH 17/22] chore(spec): regenerate the merge's generated artifacts over main's side; the branch's aged-out RetryPolicy.retryDelayMs line stays deleted Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- content/docs/references/data/object.mdx | 2 +- content/docs/references/kernel/metadata-plugin.mdx | 2 +- content/docs/references/ui/action.mdx | 6 +++--- content/docs/references/ui/component.mdx | 6 +++--- packages/spec/authorable-surface/system.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 81fcc5f9840..5694cab1198 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -479,7 +479,7 @@ const result = ApiMethod.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible | | **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override | | **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. | -| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused. | +| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (a console route; a record is /apps/APP/OBJECT/record/ID, APP being the app's route segment — see onSuccess.navigate) — every other scheme and a protocol-relative //host are refused. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). | | **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. | | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index bd0d16d97f8..9179ae0ded7 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -322,7 +322,7 @@ const result = MetadataBulkResultSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Locations where this action is visible | | **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override | | **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. | -| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused. | +| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (a console route; a record is /apps/APP/OBJECT/record/ID, APP being the app's route segment — see onSuccess.navigate) — every other scheme and a protocol-relative //host are refused. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). | | **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. | | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index a22ffc7b90f..faf96b23b22 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -36,7 +36,7 @@ const result = ActionSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Locations where this action is visible | | **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override | | **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. | -| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused. | +| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (a console route; a record is /apps/APP/OBJECT/record/ID, APP being the app's route segment — see onSuccess.navigate) — every other scheme and a protocol-relative //host are refused. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). | | **body** | `{ language: 'expression'; source: string } \| { language: 'js'; source: string; capabilities?: Enum<'api.read' \| 'api.write' \| 'api.transaction' \| 'crypto.uuid' \| 'log'>[]; timeoutMs?: integer; … }` | optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`. | | **operation** | `Enum<'update'>` | optional | The declarative single-record field write, mirroring a list view's `bulkActionDefs`: `'update'` applies `patch` (merged under the collected `params`) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. `type` stays at its default `'script'` (the platform action route the write is performed on); `target`/`body`/`method`/`bodyExtra` are refused beside it. `'delete'` and `'custom'` have no row-level form. | @@ -175,7 +175,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. An in-app path is a console route, written without the console's mount prefix; a record is /apps/APP/OBJECT/record/ID, where APP is the app's route segment (its package id; its name also resolves). No app-relative form is declared and no $`{ctx.*}` member names the current app, so write the app segment. Renderers MUST encodeURIComponent values in query positions. | | **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | @@ -337,7 +337,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. | | **name** | `string` | optional | Machine name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label | -| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused. | +| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (a console route; a record is /apps/APP/OBJECT/record/ID, APP being the app's route segment — see onSuccess.navigate) — every other scheme and a protocol-relative //host are refused. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). | | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 7ee138f202f..96b2898cf2d 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -61,7 +61,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. An in-app path is a console route, written without the console's mount prefix; a record is /apps/APP/OBJECT/record/ID, where APP is the app's route segment (its package id; its name also resolves). No app-relative form is declared and no $`{ctx.*}` member names the current app, so write the app segment. Renderers MUST encodeURIComponent values in query positions. | | **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | @@ -153,7 +153,7 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`; on a type:'flow' action the completed run's answer, whose `output` carries the flow's isOutput variables, e.g. $`{result.output.renewal.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. An in-app path is a console route, written without the console's mount prefix; a record is /apps/APP/OBJECT/record/ID, where APP is the app's route segment (its package id; its name also resolves). No app-relative form is declared and no $`{ctx.*}` member names the current app, so write the app segment. Renderers MUST encodeURIComponent values in query positions. | | **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | @@ -228,7 +228,7 @@ const result = ActionButtonPropsSchema.parse(data); | **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional (default: `"script"`) | Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is `operation: 'update'` + `patch` on the default `script` route. | | **name** | `string` | optional | Machine name (lowercase snake_case) | | **label** | `string \| Record` | optional | Display label | -| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused. | +| **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (a console route; a record is /apps/APP/OBJECT/record/ID, APP being the app's route segment — see onSuccess.navigate) — every other scheme and a protocol-relative //host are refused. | | **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only). | | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). | diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index c7672594ecf..25b039d86c2 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -43,7 +43,7 @@ "system/AppInstallResult:seededRecords", "system/AppInstallResult:success", "system/AppInstallResult:version", - "system/AppManifest:dependencies", + "system/AppManifest:dependencies [RETIRED]", "system/AppManifest:description", "system/AppManifest:flows", "system/AppManifest:hasSeedData", From 305427437dfc9496923a28239c9c2df09a305b9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:10:52 +0000 Subject: [PATCH 18/22] feat(spec)!: keep the migration chain and its floor of 16; only the retirement kits age out (#25767 ruling 6434038284) The maintainer's 2026-10-27 ruling keeps kit aging (tombstones and their D2 conversions retired at or below 16.x leave; the strict refusal names the upgrade guide) and withdraws the chain cut. Restored to origin/main: the migration entries (159 major-17 files and the conversionIds links in the three protocol-18 entries), registry.ts.template with MIGRATION_SUPPORT_FLOOR = 16, chain.ts, migrations.test.ts, semantic-relevance.test.ts, the cli migrate meta command and its twelve tests, ADR-0142, and the floor prose in the upgrade guide and the upgrading page. The retirement sentences follow the conversion, not the floor: an edit whose toMajor-17 conversion survives (the twelve retiredAfter 17.7.0 ones) goes back to the house 'Run os migrate meta --from 16' sentence; an edit whose 16.x conversion left says it left with the 16.x conversions and links the guide. ADR-0087's kit-aging amendments are reworded (the kit ages out, the step stays) and its chain amendments are withdrawn; the changeset is rewritten. The release-tag dating source in build-schemas.ts stays: 46 of the 61 deleted [RETIRED] lines predate RETIRED_KEYS_BY_MAJOR, so the registry alone cannot date them. Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- .../25767-retire-16x-retirement-kits.md | 37 +- content/docs/upgrading.mdx | 35 +- ...0087-metadata-protocol-upgrade-contract.md | 112 +- ...e-membership-claim-and-assignment-rules.md | 2 +- docs/protocol-upgrade-guide.md | 25 +- ...it-mail-bodies.archive.integration.test.ts | 2 +- .../migrate/meta.report-order.test.ts | 41 +- .../migrate/meta.stored-flags.test.ts | 8 +- packages/cli/src/commands/migrate/meta.ts | 3 +- ...ate-meta-composed-load-conversions.test.ts | 90 +- .../cli/test/migrate-meta-composed.test.ts | 38 +- .../test/migrate-meta-default-range.test.ts | 9 +- .../test/migrate-meta-engine-guidance.test.ts | 84 +- .../migrate-meta-load-conversions.test.ts | 89 +- .../test/migrate-meta-protocol-range.test.ts | 49 +- packages/cli/test/migrate-meta-write.test.ts | 121 +- packages/cli/test/migrate-meta.e2e.test.ts | 273 ++-- .../package-owned-command-readers.test.ts | 29 +- .../lint/src/validate-expressions.test.ts | 2 +- packages/lint/src/validate-expressions.ts | 2 +- packages/spec/scripts/build-schemas.ts | 6 +- packages/spec/src/api/rest-server.zod.ts | 2 +- .../src/automation/builtin-node-config.zod.ts | 4 +- .../flow-sibling-block-loose-keys.ts | 4 +- .../spec/src/automation/io-node-config.zod.ts | 10 +- .../automation/schemaless-node-config.zod.ts | 6 +- packages/spec/src/conversions/registry.ts | 51 +- packages/spec/src/data/datasource.zod.ts | 12 +- .../src/data/driver/config-registry.zod.ts | 6 +- packages/spec/src/data/hook-body.zod.ts | 2 +- packages/spec/src/data/mapping.zod.ts | 6 +- packages/spec/src/data/object.test.ts | 12 +- packages/spec/src/data/object.zod.ts | 6 +- packages/spec/src/data/query.zod.ts | 2 +- .../spec/src/integration/connector.zod.ts | 2 +- packages/spec/src/migrations/chain.ts | 8 +- .../spec/src/migrations/entries/README.md | 9 +- .../retired-defs/17.api__CreateViewRequest.ts | 4 + .../17.api__CreateViewResponse.ts | 3 + .../retired-defs/17.api__DeleteViewRequest.ts | 3 + .../17.api__DeleteViewResponse.ts | 3 + .../retired-defs/17.api__GetViewRequest.ts | 3 + .../retired-defs/17.api__GetViewResponse.ts | 3 + .../retired-defs/17.api__ListViewsRequest.ts | 3 + .../retired-defs/17.api__ListViewsResponse.ts | 3 + .../retired-defs/17.api__UpdateViewRequest.ts | 3 + .../17.api__UpdateViewResponse.ts | 3 + .../17.automation__ETLDestination.ts | 4 + .../17.automation__ETLEndpointType.ts | 3 + .../17.automation__ETLPipeline.ts | 3 + .../17.automation__ETLPipelineRun.ts | 3 + .../17.automation__ETLRunStatus.ts | 3 + .../retired-defs/17.automation__ETLSource.ts | 3 + .../17.automation__ETLSyncMode.ts | 3 + .../17.automation__ETLTransformation.ts | 3 + .../17.automation__ETLTransformationType.ts | 3 + .../17.data__ExternalDataSource.ts | 14 + .../17.data__ExternalFieldMapping.ts | 12 + .../retired-defs/17.data__ExternalLookup.ts | 11 + .../retired-defs/17.kernel__PluginCaching.ts | 16 + .../17.kernel__PluginCodeSplitting.ts | 3 + .../17.kernel__PluginDependencyResolution.ts | 3 + .../17.kernel__PluginDynamicImport.ts | 3 + .../17.kernel__PluginHotReload.ts | 3 + .../17.kernel__PluginInitialization.ts | 3 + .../17.kernel__PluginLoadingConfig.ts | 3 + .../17.kernel__PluginLoadingStrategy.ts | 3 + .../17.kernel__PluginPerformanceMonitoring.ts | 3 + .../17.kernel__PluginPreloadConfig.ts | 3 + .../17.kernel__PluginSandboxing.ts | 3 + .../17.shared__FieldMappingTransform.ts | 3 + .../retired-defs/17.system__ConsumerConfig.ts | 7 + .../17.system__DeadLetterQueue.ts | 9 + .../17.system__MessageQueueConfig.ts | 14 + .../17.system__MessageQueueProvider.ts | 9 + .../17.system__ServerCapabilities.ts | 4 + .../retired-defs/17.system__ServerEvent.ts | 3 + .../17.system__ServerEventType.ts | 3 + .../retired-defs/17.system__ServerStatus.ts | 3 + .../retired-defs/17.system__TopicConfig.ts | 8 + .../entries/retired-defs/17.ui__DateFormat.ts | 3 + .../entries/retired-defs/17.ui__I18nObject.ts | 3 + .../retired-defs/17.ui__LocaleConfig.ts | 3 + .../retired-defs/17.ui__NumberFormat.ts | 3 + .../entries/retired-defs/17.ui__PluralRule.ts | 3 + .../retired-defs/17.ui__WidgetEvent.ts | 3 + .../retired-defs/17.ui__WidgetLifecycle.ts | 3 + .../retired-defs/17.ui__WidgetManifest.ts | 3 + .../retired-defs/17.ui__WidgetProperty.ts | 3 + .../retired-defs/17.ui__WidgetSource.ts | 3 + .../17.api__AuthFeaturesConfig__magicLink.ts | 15 + .../17.api__AuthFeaturesConfig__passkeys.ts | 20 + ...7.api__ListNotificationsRequest__cursor.ts | 18 + ....api__ListNotificationsResponse__cursor.ts | 3 + ...7.automation__ActionDescriptor__isAsync.ts | 11 + .../17.data__AggregationNode__distinct.ts | 24 + ...7.data__DataEngineUpdateOptions__upsert.ts | 9 + .../17.data__EngineUpdateOptions__upsert.ts | 18 + .../17.data__MongoConfig__password.ts | 8 + .../17.data__MysqlConfig__password.ts | 8 + .../17.data__PostgresConfig__password.ts | 12 + .../17.data__TursoConfig__authToken.ts | 9 + ...ation__ConnectorFieldMapping__transform.ts | 3 + .../17.kernel__Manifest__loading.ts | 22 + .../17.shared__FieldMapping__transform.ts | 3 + ..._ElementRecordPickerProps__displayField.ts | 7 + ....ui__ElementRecordPickerProps__multiple.ts | 3 + ..._ElementRecordPickerProps__searchFields.ts | 3 + .../retired-keys/17.ui__ListView__bordered.ts | 9 + .../retired-keys/17.ui__ListView__striped.ts | 8 + .../17.ui__ListView__virtualScroll.ts | 9 + .../17.ui__ObjectListView__bordered.ts | 8 + .../17.ui__ObjectListView__striped.ts | 8 + .../17.ui__ObjectListView__virtualScroll.ts | 8 + .../17.ui__PageCardProps__actions.ts | 14 + .../17.ui__PageCardProps__body.ts | 3 + .../17.ui__PageHeaderProps__icon.ts | 3 + .../17.ui__PageTabsProps__type.ts | 8 + .../17.ui__RecordDetailsProps__layout.ts | 8 + .../17.action-descriptor-is-async-retired.ts | 45 + ...escriptor-resume-authority-default-flip.ts | 69 + .../17.action-session-roles-to-positions.ts | 59 + .../17.actor-user-roles-to-positions.ts | 79 + .../17.aggregation-node-distinct-retired.ts | 59 + ...nalytics-query-request-envelope-retired.ts | 19 + ....analytics-query-request-format-retired.ts | 16 + .../17.api-runtime-create-withdrawn.ts | 60 + .../semantic/17.apimethod-enum-shrink.ts | 51 + ...pproval-escalation-enabled-default-flip.ts | 50 + .../17.audit-log-action-enum-retired.ts | 77 + .../17.audit-log-action-restore-retired.ts | 82 ++ ...h-config-unadvertised-reserved-features.ts | 32 + ...7.authoring-schemas-strict-unknown-keys.ts | 105 ++ .../17.batch-options-validate-only-retired.ts | 17 + .../17.batch-row-result-schema-shape.ts | 41 + .../17.client-delete-result-success.ts | 50 + ...r-inline-authentication-publish-refused.ts | 27 + .../17.dashboard-widget-compareto-offset.ts | 31 + .../17.data-driver-find-stream-retired.ts | 40 + .../17.data-driver-query-omit-object.ts | 51 + .../semantic/17.data-engine-batch-retired.ts | 47 + .../17.data-field-changed-event-retired.ts | 42 + ...source-config-inline-credential-refused.ts | 26 + ...7.datasource-config-placeholder-refused.ts | 39 + ....datasource-config-url-userinfo-refused.ts | 36 + .../17.declarative-apis-endpoints-live.ts | 64 + ...elete-by-id-before-hook-repoint-retired.ts | 66 + ...ggregate-undeclared-key-aliases-removed.ts | 57 + ....driver-capabilities-inert-bits-removed.ts | 71 + ...7.driver-sql-distinct-bare-filter-typed.ts | 62 + .../17.engine-dotted-projection-refused.ts | 69 + .../17.engine-find-formula-filter-refused.ts | 79 + ...17.engine-find-formula-order-by-refused.ts | 67 + .../17.engine-update-upsert-retired.ts | 25 + ...enhanced-api-error-field-errors-renamed.ts | 22 + .../semantic/17.etl-pipeline-layer-retired.ts | 77 + .../entries/semantic/17.export-axis-opt-in.ts | 57 + ...7.export-field-meta-constraints-retired.ts | 64 + ...l-lookup-message-queue-families-retired.ts | 84 ++ .../17.field-runtime-create-withdrawn.ts | 71 + .../17.filter-regex-options-retired.ts | 68 + .../17.flow-retry-max-retries-required.ts | 34 + .../17.hook-context-session-roles-retired.ts | 43 + ...ok-register-empty-object-target-refused.ts | 54 + .../17.http-request-errors-total-retired.ts | 58 + ....http-server-runtime-vocabulary-retired.ts | 58 + ...-automations-declared-default-corrected.ts | 71 + ....job-retry-policy-constraints-tightened.ts | 25 + .../17.notification-list-cursor-retired.ts | 84 ++ ....package-uninstall-explicit-all-tenants.ts | 36 + .../17.plugin-activation-events-retired.ts | 56 + .../17.plugin-manifest-loading-retired.ts | 66 + .../17.plugin-runtime-family-retired.ts | 56 + .../17.position-permissions-column-retired.ts | 71 + .../17.query-array-string-agg-retired.ts | 35 + .../semantic/17.query-cursor-retired.ts | 26 + .../semantic/17.query-distinct-retired.ts | 26 + ...17.query-field-node-object-form-retired.ts | 35 + .../semantic/17.query-joins-retired.ts | 34 + .../17.query-window-functions-retired.ts | 26 + .../17.record-details-sections-object-form.ts | 47 + .../17.rest-server-openapi31-block-removed.ts | 37 + .../17.runtime-httpserver-wrapper-retired.ts | 47 + .../17.sharing-execution-context-retired.ts | 59 + .../17.sharing-rule-recipient-reconcile.ts | 54 + .../17.sort-node-direction-rejected.ts | 51 + ...17.spec-type-alias-input-suffix-retired.ts | 62 + .../17.storage-service-list-retired.ts | 74 + .../17.tool-requires-confirmation-retired.ts | 69 + ...17.ui-interaction-config-family-retired.ts | 84 ++ ...otification-action-embed-config-retired.ts | 75 + .../17.ui-widget-i18n-family-retired.ts | 100 ++ .../17.ups-delegated-from-column-retired.ts | 66 + ...ew-filter-rule-value-shaped-by-operator.ts | 66 + .../17.view-management-protocol-retired.ts | 58 + .../17.workflow-service-slot-retired.ts | 35 + ...8.datasource-driver-mongo-alias-retired.ts | 19 +- ...sibling-block-loose-config-keys-refused.ts | 20 +- ...b-retry-policy-without-attempts-refused.ts | 24 +- .../spec/src/migrations/migrations.test.ts | 270 +++- .../spec/src/migrations/registry.ts.template | 1294 +++++++++++++++-- .../src/migrations/semantic-relevance.test.ts | 11 +- packages/spec/src/security/sharing.zod.ts | 2 +- packages/spec/src/shared/mapping.test.ts | 10 +- packages/spec/src/shared/mapping.zod.ts | 2 +- .../spec/src/shared/protocol-upgrade-guide.ts | 14 +- .../retired-key-migrate-sentence.test.ts | 19 +- packages/spec/src/shared/retired-key.ts | 8 +- packages/spec/src/stack.zod.ts | 2 +- packages/spec/src/system/book.zod.ts | 2 +- packages/spec/src/system/job.zod.ts | 5 +- packages/spec/src/system/translation.zod.ts | 2 +- packages/spec/src/ui/action.test.ts | 6 +- packages/spec/src/ui/action.zod.ts | 6 +- packages/spec/src/ui/app.zod.ts | 12 +- packages/spec/src/ui/component.zod.ts | 2 +- .../spec/src/ui/dashboard-compareto.test.ts | 8 +- packages/spec/src/ui/dashboard.zod.ts | 6 +- .../spec/src/ui/list-view-export-options.ts | 2 +- packages/spec/src/ui/view.test.ts | 6 +- 220 files changed, 6779 insertions(+), 820 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.data__ExternalDataSource.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.data__ExternalFieldMapping.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.data__ExternalLookup.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ConsumerConfig.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__DeadLetterQueue.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueConfig.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueProvider.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.system__TopicConfig.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__DataEngineUpdateOptions__upsert.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__EngineUpdateOptions__upsert.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__bordered.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__virtualScroll.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__bordered.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__striped.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__virtualScroll.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.engine-dotted-projection-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.engine-find-formula-filter-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.engine-update-upsert-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts diff --git a/.changeset/25767-retire-16x-retirement-kits.md b/.changeset/25767-retire-16x-retirement-kits.md index 5fdc57a7704..41a6c5675a9 100644 --- a/.changeset/25767-retire-16x-retirement-kits.md +++ b/.changeset/25767-retire-16x-retirement-kits.md @@ -1,40 +1,23 @@ --- '@objectstack/spec': minor -'@objectstack/cli': minor '@objectstack/lint': patch '@objectstack/metadata-core': patch --- -feat(spec,cli)!: retirement kits retired at or below protocol 16.x leave the tree; the strict unknown-key refusal names the protocol upgrade guide, and the migration floor rises to 17 +feat(spec)!: retirement kits whose conversion retired at or below protocol 16.x leave the tree; the strict unknown-key refusal names the protocol upgrade guide -Clause-②: no (narrowing: a key retired at or below protocol 16.x is refused as a strict unknown key without its own prescription in the error, and `os migrate meta --from 16` refuses at the support floor instead of replaying step 17) +Clause-②: no (narrowing) Metadata-protocol: narrowed -**Upgrading from protocol 16 or earlier.** One major at a time, each major's CLI in turn — a major's CLI carries the steps for itself and the major before it, so the same sequence holds at 19: +**BREAKING** for a source or a stored `sys_metadata` row still carrying a key whose ≤16.x kit aged out. -```bash -npx @objectstack/cli@17.7.0 migrate meta --from 16 --apply # 16 → 17, on the last 17.x release (its floor is 16) -npx @objectstack/cli@17.7.0 migrate meta --stored --apply # the sys_metadata rows, BEFORE the runtime moves -# move the dependencies / the deployment's image to 18.2, then -os migrate meta --from 17 --apply # 17 → 18 -os validate && os build -``` - -**Alternative, one shot:** `@objectstack/cli@18.1.0` is the last release whose floor is 16, so `npx @objectstack/cli@18.1.0 migrate meta --from 16` (then `--apply`) runs both hops in one command — work the to-do list it prints, then move the dependencies to 18.2 and run `os migrate meta --from 17`, `os validate` and `os build`; its `migrate meta --stored --apply` canonicalizes the stored rows the same way. - -**Stored metadata (`sys_metadata`, Studio-authored) is migrated BEFORE the deployment's image or dependencies move to 18.2.** 18.2 no longer rewrites a ≤16.x row at read: a row left behind is refused by the strict parse until it is migrated. - -**If the first hop is skipped:** on 18.2, `os migrate meta --from 16` refuses with `MigrationFloorError` and rewrites nothing, and a key retired at or below 16.x meets the strict unknown-key refusal with the guide link, `https://objectstack.ai/docs/protocol-upgrade`. - -**BREAKING** for a source still carrying a key retired at or below protocol 16.x, and for an operator who runs `os migrate meta --from 16`. - -- **The rule (ADR-0087, amended 2026-10-26, #25767 ruling D2 = A).** A retirement kit — the `retiredKey()` tombstone, its `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` rows, its semantic entry and its D2 conversion — lives through the protocol major after the one it retired in and is removed when the next major is cut. At 18 that keeps the 17.x and 18.x kits and removes everything retired at or below 16.x; 19 repeats the move one major on. "Never deleted", "permanent" and "forever" in ADR-0087 are amended in place. -- **Before.** A key retired at or below 16.x met a `retiredKey()` tombstone: the parse refused it with the key's own prescription in the error, `os migrate meta --from 16` listed and applied the mechanical edit, and a stored row in that shape was rewritten at read by the ≤16.x conversions. -- **Now.** The key is gone from the shape, so a `strictObject` refuses it as an unknown key. The refusal carries one more sentence, after its fix channels and ahead of its history sentence (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, exported with `PROTOCOL_UPGRADE_GUIDE_URL` from `@objectstack/spec`): it says that keys retired before the previous major are no longer prescribed in the error and names the protocol upgrade guide, `https://objectstack.ai/docs/protocol-upgrade`, where the prescription lives; the CHANGELOG of the release that retired the key carries it too. The 17.x kits still carry their prescription in the error, and they leave when 19 is cut. -- **The floor.** `MIGRATION_SUPPORT_FLOOR` is 17. `os migrate meta --from 16` and below answer `MigrationFloorError`, whose message now names the floor and the guide link, and rewrite nothing. A source authored against protocol 16 reaches 17 on a release that still carries the step (any `@objectstack/cli` up to 18.1), then runs the current release. The house sentence a 17.x retirement's error ends with is respelled accordingly: it says the edit is applied by hand or on a release that still carries the step, and links the guide. -- **Stored rows.** A `sys_metadata` row still in a ≤16.x shape is no longer rewritten at read; canonicalize it once with `os migrate meta --stored --apply` on 18.1 or earlier. `app-hidden-to-unpublished` is among the removed conversions, so a pre-split `hidden: true` app row is no longer rewritten to `_unpublished` by any seam, and the artifact door's refusal of it by id is removed as moot (`@objectstack/metadata-core`). -- **What left.** 50 D2 conversions with `retiredAfter` at or below 16.x (5 at 15.0.0, 45 at 16.1.0) and their fixtures; 55 tombstones already `[RETIRED]` at `@objectstack/spec@17.0.0` whose container is strict; the 159 major-17 entry files (29 retired-key rows, 53 retired-def rows, 77 semantic entries) and the chain's step 17; 61 `[RETIRED]` lines of the authorable surface and their 36 liveness-ledger rows. 58 same-age tombstones whose container strips unknown keys stay, because deleting one would turn a refusal into a silent strip; the four inline-credential refusals are not retirements and are untouched. +- **The rule (ADR-0087, amended 2026-10-26, #25767 ruling D2 = A as scoped by ruling 6434038284).** A retirement kit — the `retiredKey()` tombstone and its D2 conversion with fixtures — lives through the protocol major after the one it retired in and is removed when the next major is cut. At 18 that removes the kits whose conversion retired at or below 16.x; 19 repeats the move one major on. The kit ages out; the migration chain's step stays. +- **Before.** Such a key met a `retiredKey()` tombstone: the parse refused it with the key's own prescription in the error, `os migrate meta --from 16` listed and applied the mechanical edit, and a stored row in that shape was rewritten at read by the ≤16.x conversions. +- **Now.** Where the tombstone sat in a strict container, the key is gone from the shape, so a `strictObject` refuses it as an unknown key. The refusal carries one more sentence, after its fix channels and ahead of its history sentence (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, exported with `PROTOCOL_UPGRADE_GUIDE_URL` from `@objectstack/spec`): it says that keys retired before the previous major are no longer prescribed in the error and names the protocol upgrade guide, `https://objectstack.ai/docs/protocol-upgrade`, where the prescription lives; the CHANGELOG of the release that retired the key carries it too. A tombstone that stays (its container strips unknown keys) ends its error with a sentence that says the edit left `os migrate meta` with the 16.x conversions, to be applied by hand or on a release that still carries the conversion, and links the guide. +- **The chain is unchanged.** `MIGRATION_SUPPORT_FLOOR` stays 16; step 17 and its entry files stay, and `os migrate meta --from 16` still replays 16 → 18, listing every surviving conversion and semantic to-do. Only the edits of the removed conversions are no longer applied for you. +- **Stored rows.** A `sys_metadata` row still carrying such a spelling is no longer rewritten at read; canonicalize it once with `os migrate meta --stored --apply` on 18.2 or earlier, before moving to 18.3. `app-hidden-to-unpublished` is among the removed conversions, so a pre-split `hidden: true` app row is no longer rewritten to `_unpublished` by any seam, and the artifact door's refusal of it by id is removed as moot (`@objectstack/metadata-core`). +- **What left.** 50 D2 conversions with `retiredAfter` at or below 16.x (5 at 15.0.0, 45 at 16.1.0) and their fixtures; 55 tombstones already `[RETIRED]` at `@objectstack/spec@17.0.0` whose container is strict; 61 `[RETIRED]` lines of the authorable surface and their liveness-ledger rows. 58 same-age tombstones whose container strips unknown keys stay, because deleting one would turn a refusal into a silent strip; the four inline-credential refusals are not retirements and are untouched. - **The deletion gate.** `check:authorable-surface` proof 1 ages a tombstone one major (was two). A deleted `[RETIRED]` line is dated by an exact `RETIRED_KEYS_BY_MAJOR` row or, new, by the previous major's release tag: a line already `[RETIRED]` on the surface at `@objectstack/spec@.0.0` was retired before that cut. The tag is immutable, so neither a row a PR writes nor one it deletes moves the clock; where the tag cannot be read the gate dates by the registry alone and says so. No gate was added. -- **Who moves.** Measured over this repository at `963e504cf5`: no `examples/**`, `ui/` or `hotcrm` source writes a removed key (the `ui/` hits for the removed names are the console's own same-named props and type mirrors, not readers of the retired spec keys, per the liveness ledger's measurements). A consumer source still carrying one meets the strict refusal and the guide link at `os validate`. +- **Who moves.** No `examples/**`, `ui/` or `hotcrm` source in this repository writes a removed key (the `ui/` hits for the removed names are the console's own same-named props and type mirrors, not readers of the retired spec keys, per the liveness ledger's measurements). A consumer source still carrying one meets the strict refusal and the guide link at `os validate`. diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 116e08d7c06..ba3a61a145e 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -11,7 +11,7 @@ There are **two upgrades** on this platform, and they run on separate clocks: | Ships as | a Docker image (or the `os` CLI on a host) | a compiled artifact, `dist/objectstack.json` | | Versioned by | our release train (`X.Y.Z`) | your own catalog | | Whose cadence | ours | yours | -| The upgrade action | move the image tag, or the dependency pins, then restart | `os migrate meta --from 17`, then rebuild and ship | +| The upgrade action | move the image tag, or the dependency pins, then restart | `os migrate meta --from 16`, then rebuild and ship | | Touches your metadata? | it never *rewrites* your metadata — but it can start *refusing* it ([measured](#a-runtime-move-can-still-force-metadata-edits)) | yes — you edit your source, guided by the tool's change list | Most upgrade questions are really the question *which of these am I doing*. You @@ -218,7 +218,7 @@ followed by your normal build. ### One command covers every major you skipped ```bash -os migrate meta --from 17 # the major your metadata was authored against +os migrate meta --from 16 # the major your metadata was authored against ``` `--from` is the major **you** wrote against, not the one you are going to. The @@ -229,27 +229,20 @@ them by hand: | You ran | Steps it replays | | :--- | :--- | -| `os migrate meta --from 17` | 18, then every later major this build carries a step for | +| `os migrate meta --from 16` | 17, then every later major this build carries a step for | -The chain reaches back to **protocol 17** — one major below the current one, by -rule (ADR-0087, amended 2026-10-26): a retirement kit lives through the major -after the one it retired in and leaves when the next major is cut, and the floor -rises with it at every major. Below the floor the command refuses with a message -naming the floor and the [protocol upgrade guide](https://objectstack.ai/docs/protocol-upgrade) -rather than half-migrating you. +The chain reaches back to **protocol 16** — that floor is a release-policy +decision, not an accident of what still exists. Below it the command refuses +with a message naming the floor rather than half-migrating you. - **The floor is 17, and it rises one major at every major.** `--from 16` and - below answer `Cannot migrate from protocol N: the chain's support floor is 17` - with the guide link; nothing half-migrates, because the command refuses before - it rewrites anything. Metadata still authored against protocol 16 reaches 17 on - a release that still carries the step — any `@objectstack/cli` up to 18.1 — and - then runs the command above. Keys retired at or below 16.x are no longer - prescribed in the schema's own error: the strict parse refuses them as unknown - keys and points at the guide, which carries the prescription from then on. - Stored `sys_metadata` rows in a ≤16.x shape are no longer rewritten at read - either; canonicalize them once with `os migrate meta --stored --apply` on 18.1 - or earlier before moving past it. + **The floor was raised from 10 to 16.** Earlier releases replayed the chain + from protocol 10, so `--from 10` through `--from 15` used to work and now + answer `Cannot migrate from protocol N: the chain's support floor is 16`. + Nothing half-migrates: the command refuses before it rewrites anything. If + your metadata is still authored against protocol 10–15, reach protocol 16 by + another path first — an older `@objectstack/cli` still carries those steps — + and then run the command above. Useful flags: @@ -332,7 +325,7 @@ has already done. ### The loop ```bash -os migrate meta --from 17 # 1. read the mechanical change list and the to-dos +os migrate meta --from 16 # 1. read the mechanical change list and the to-dos # 2. apply the edits (--write writes the ones it can # trace and lists the rest); resolve the to-dos # and the checklist items below diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index ea7945b587f..341aa1c535d 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -1,6 +1,6 @@ # ADR-0087: Metadata protocol upgrades for AI consumers — conversion over notification, executable migrations, machine-verifiable upgrades -**Status**: Accepted (2026-07-04, #2582) · trued up to as-built 2026-07-15 (see Addendum) · **Amended** (2026-09-30, #20390 ruling A — the artifact-ingestion window decides per entry by each retired conversion's `retiredAfter`; see the amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-09, #22449 ruling B′ — the two D4 projections, the per-major section of `spec-changes.json` and the protocol upgrade guide, are generated at publish rather than committed; see the amendment note under D4, which says what is generated where and where each part landed, and the struck clauses in the P2 phase-status bullet) · **Amended** (2026-10-13, #23038 — the artifact-ingestion window's third disjunct, landed as `58f5c90999`: a floor that is the first release of a major replays that major's conversion step, verdict `'converted-major-step'`; see the 2026-10-13 amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-27, #26220, the maintainer's decision A: D8's predicate 4 gains an index-signature limb, and D9's predicate 1 maps a `src/index.tsx` source entry; see the 2026-10-27 amendment notes under D8's predicate 4 and under D9's unmappable-entry paragraph) · **Amended** (2026-10-26, #25767 ruling D2 = A — a retirement kit (tombstone, `RETIRED_KEYS`/`RETIRED_DEFS` rows, semantic entry, conversion) lives one protocol major after the one it retired in and is then removed; a removed key is refused as a strict unknown key whose refusal names the protocol upgrade guide, and the chain's support floor rises to match; see the 2026-10-26 addendum, which also amends the "never deleted" / "permanent" / "forever" wording in D2, D3, the consequences and the 2026-07-31, 2026-08-01 and 2026-09-13 addenda) +**Status**: Accepted (2026-07-04, #2582) · trued up to as-built 2026-07-15 (see Addendum) · **Amended** (2026-09-30, #20390 ruling A — the artifact-ingestion window decides per entry by each retired conversion's `retiredAfter`; see the amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-09, #22449 ruling B′ — the two D4 projections, the per-major section of `spec-changes.json` and the protocol upgrade guide, are generated at publish rather than committed; see the amendment note under D4, which says what is generated where and where each part landed, and the struck clauses in the P2 phase-status bullet) · **Amended** (2026-10-13, #23038 — the artifact-ingestion window's third disjunct, landed as `58f5c90999`: a floor that is the first release of a major replays that major's conversion step, verdict `'converted-major-step'`; see the 2026-10-13 amendment note under the 2026-09-13 addendum's window bullet) · **Amended** (2026-10-27, #26220, the maintainer's decision A: D8's predicate 4 gains an index-signature limb, and D9's predicate 1 maps a `src/index.tsx` source entry; see the 2026-10-27 amendment notes under D8's predicate 4 and under D9's unmappable-entry paragraph) · **Amended** (2026-10-26, #25767 ruling D2 = A, scoped by ruling 6434038284 of 2026-10-27 — a retirement kit (the `retiredKey()` tombstone and its D2 conversion with fixtures) lives one protocol major after the one it retired in and is then removed; a removed key is refused as a strict unknown key whose refusal names the protocol upgrade guide; the kit ages out, the chain's step stays, and the support floor stays 16; see the 2026-10-26 addendum, which also amends the "never deleted" wording in D2 and the conversion "forever" in the 2026-07-31 and 2026-09-13 addenda) **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0059](./0059-third-party-backward-compatibility-gates.md) (layered backward-compat gates — this ADR is its consumer-facing sequel), [ADR-0078](./0078-no-silently-inert-metadata.md) (no declarable-but-unenforced metadata — the un-checked `engines.protocol` is exactly this class), [ADR-0025](./0025-plugin-package-distribution.md) (§3.2 `engines.protocol` / `engines.platform` compatibility ranges, §3.10 #3 protocol-first check order), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (the authoring population this ADR designs for), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs), AGENTS.md Prime Directive #12 (contract-first, no consumer-side dialect fallbacks — §"Why the conversion layer does not violate PD #12" draws the line) **Consumers**: `@objectstack/spec` (protocol version constant, conversion layer, deprecation/change registries), `@objectstack/cli` (`validate`, `doctor`, `migrate meta`), the runtime metadata loader (handshake + conversion), `@objectstack/mcp` (the AI-native change/migration surface), `@objectstack/create-objectstack`, the Release workflow, and every third-party consumer — whose maintainer is assumed to be an **AI agent** @@ -144,12 +144,16 @@ new shape, and the *fleet* upgrades incrementally. accepts N−1 shapes at load), then retired from the load path in N+1 — but it is **never deleted**: a retired conversion graduates into the D3 migration chain as that major's mechanical step, so the transform history is permanent even though the - runtime only ever carries one major of it. — **amended 2026-10-26 (#25767, D2 = A):** - "never deleted" held for three majors and is withdrawn. The graduated step is - carried for **one further major** (the kit retired in N is in the tree through - N+1 and leaves when N+2 is cut); after that the key is refused as a strict unknown - key, the refusal names the protocol upgrade guide, and the history lives in the - guide, the CHANGELOG and git — see the 2026-10-26 addendum. The window is deliberately **not** a + runtime only ever carries one major of it. — **amended 2026-10-26 (#25767, D2 = A; + scope per ruling 6434038284):** "never deleted" is withdrawn for the CONVERSION, not + for the step. A retired conversion and its tombstone (the kit) are carried for **one + further major** (the kit retired in N is in the tree through N+1 and leaves when + N+2 is cut); after that the key is refused as a strict unknown key, the refusal + names the protocol upgrade guide, and the edit's prescription lives in the guide, + the CHANGELOG and git. The chain's step for that major — its semantic entries and + retired-key / retired-def rows — stays replayable down to the support floor (D3), + so `--from N` keeps working; only the aged-out edit is no longer applied for you + — see the 2026-10-26 addendum. The window is deliberately **not** a notification device — a warning window is worthless to a consumer that arrives three majors later. It is a **fleet-compatibility window**: the period both shapes are loadable, which is what lets a multi-app fleet upgrade incrementally and roll @@ -181,10 +185,7 @@ document). Two sources feed each major's step: **semantic changes** authored for major (one entry per retirement family, even when a lossless D2 conversion also exists for it; D2 carries the mechanical data repair only), and **graduated conversions** (D2 entries retired from the load path). Together the steps form a **permanent, ordered -chain** — the database-migration model applied to metadata source files. (**Amended -2026-10-26**, #25767: "permanent" is bounded by the support floor, which now sits one -major below the current one; a step below the floor is deleted, not kept — see the -2026-10-26 addendum.) +chain** — the database-migration model applied to metadata source files. - **Cross-major is the designed-for case, not an unsupported edge.** `objectstack migrate meta --from 10` composes the steps 10→11→…→current and applies @@ -395,10 +396,7 @@ scaling framework-side execution when the consumers can execute themselves. - The chain is a **forever artifact**: every step back to the support floor must stay replayable, and full-chain CI replay grows linearly with history. Bounded by the explicit support floor (a release-policy decision, revisitable per major) and by - steps being declarative data + fixtures, not code that rots. — **Amended - 2026-10-26** (#25767): the floor is no longer "revisitable"; it is **set by rule** - to one major below the current one and rises at every major cut, so the chain - carries one step and the replay cost is constant rather than linear. + steps being declarative data + fixtures, not code that rots. - Machine-first artifacts raise the release-engineering bar: `spec-changes.json`, conversion fixtures, and the generated guide are new build products that can themselves regress — they get their own CI checks (schema-validated, fixtures @@ -561,12 +559,13 @@ diverged (#3903). This addendum extends the contract to data at rest: no author to teach, and refusing its historical shape would only break data that once worked. D3 keeps every conversion forever precisely so any past major replays forward — a stored row is the perpetual "consumer arriving - late", and the read path is its chain. (**Amended 2026-10-26**, #25767: "forever" - is now one major. A stored row shaped for a major below the floor is no longer - rewritten at read; it is canonicalized once, with `os migrate meta --stored - --apply` on a release that still carries the step, before the deployment moves - past it — the 2026-08-01 finish line is the mechanism, and it is now the - required one, not an optional one.) + late", and the read path is its chain. (**Amended 2026-10-26**, #25767: the + CONVERSION's "forever" is now one major past its retirement. A stored row + carrying a spelling whose conversion aged out is no longer rewritten at read; it + is canonicalized once, with `os migrate meta --stored --apply` on a release that + still carries the conversion, before the deployment moves past it — the + 2026-08-01 finish line is the mechanism, and for those rows it is the required + one, not an optional one.) - **Flows canonicalize at their own seam.** `AutomationEngine.registerFlow` (the rehydration seam PD #12 names) now also replays retired entries; the generic metadata seams deliberately skip `type: 'flow'` because flow-node @@ -595,8 +594,7 @@ diverged (#3903). This addendum extends the contract to data at rest: The addendum above makes a legacy row read canonical *forever*, which is the correctness guarantee — and, read literally, also a promise that the chain runs -on that row forever. (**Amended 2026-10-26**, #25767: both "forever"s are now one -major — see the 2026-10-26 addendum.) `os migrate meta --stored` +on that row forever. `os migrate meta --stored` (`ObjectStackProtocolImplementation.migrateStoredMetadata`) lets a deployment end that for itself: it walks `sys_metadata` (active + draft, all orgs), replays the same `applyConversionsToStoredItem` pass, and re-saves each changed body @@ -1347,15 +1345,17 @@ within-line retirements (17.1 → 17.2), which are the case that created it. ## Addendum (2026-10-26) — retirement kits age out after one major (#25767, ruling D2 = A) -**The ruling.** A *retirement kit* — the `retiredKey()` tombstone, its -`RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` rows, its semantic entry and its D2 -conversion — lives through the protocol major **after** the one it retired in, and is -removed when the next major is cut. At major 18 that keeps the 17.x and 18.x kits and -removes everything retired at or below 16.x; 19 repeats the move one major on. The -"never deleted", "permanent" and "forever" wording in D2, D3, the consequences and -the 2026-07-31 / 2026-08-01 / 2026-09-13 addenda is amended in place above. No gate -was added: the existing deletion gate's clock was shortened and given a second, -registry-independent source. +**The ruling, as scoped.** A *retirement kit* — the `retiredKey()` tombstone and its D2 +conversion with fixtures — lives through the protocol major **after** the one it +retired in, and is removed when the next major is cut. At major 18 that removes the +kits whose conversion retired at or below 16.x; 19 repeats the move one major on. The +maintainer's ruling of 2026-10-27 (#25767 comment 6434038284, 「同意」) kept that part +and **withdrew** the original ruling's reach over the migration chain: the kit ages +out, the step stays. The "never deleted" wording in D2 and the conversion "forever" +in the 2026-07-31 and 2026-09-13 addenda are amended in place above; D3's "permanent, +ordered chain", the "forever artifact" consequence and the 2026-08-01 addendum stand +as written. No gate was added: the existing deletion gate's clock was shortened and +given a second, registry-independent source. **What a removed key meets.** The key leaves the shape entirely, so a `strictObject` refuses it as an unknown key. The refusal carries one more sentence, after its fix @@ -1364,21 +1364,22 @@ channels and ahead of its history sentence (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE` says that keys retired before the previous major are no longer prescribed in the error and names the protocol upgrade guide (`https://objectstack.ai/docs/protocol-upgrade`); the guide and the CHANGELOG carry -the prescription from then on. The 17.x kits still carry theirs in the error. +the prescription from then on. A tombstone that stays (below) but whose conversion +left says so in its own sentence: the edit left `os migrate meta` with the 16.x +conversions, so it is applied by hand or by a release that still carries it. -**The support floor rises with it.** `MIGRATION_SUPPORT_FLOOR` is 17: `os migrate meta ---from 16` (and below) answers `MigrationFloorError`, whose message now names the -floor and the guide link; it rewrites nothing. A source authored against protocol 16 -reaches 17 on a release that still carries the step — any `@objectstack/cli` up to -18.1 — and then runs the current release. The floor is no longer a per-release -decision: it is `CURRENT_MAJOR − 1`, by this rule, at every major cut. +**The chain is not shortened.** `MIGRATION_SUPPORT_FLOOR` stays **16** (#19056): step +17 and its entry files stay, and `os migrate meta --from 16` keeps replaying 16 → 18. +Each step still lists every surviving conversion it graduated — the 12 conversions +with `toMajor: 17` and `retiredAfter: 17.7.0` stay listed under step 17 — and its +semantic to-dos. Moving the floor is its own release-policy decision (D3, "revisitable +per major"), not a by-product of kit aging. **What was removed at 18 (this amendment's own diff).** - 50 D2 conversions with `retiredAfter` at or below 16.x (5 at 15.0.0, 45 at 16.1.0), - with their fixture pairs and the comments that named them; 12 conversions with - `toMajor: 17` and `retiredAfter: 17.7.0` stay, registered under major 17 for the - data-at-rest seams. + with their fixture pairs and the comments that named them. No surviving chain + entry linked one of them (`conversionIds` ∩ the 50 = ∅). - 55 tombstones whose key was already `[RETIRED]` at `@objectstack/spec@17.0.0` and whose container is `strictObject` — the ones whose deletion yields a refusal. 58 tombstones in the same age class sit in containers that **strip** unknown keys @@ -1386,8 +1387,6 @@ decision: it is `CURRENT_MAJOR − 1`, by this rule, at every major cut. silent strip, the very defect PD #12 names, so they stay until their container is made strict. The four inline-credential refusals (`refusedInlineCredentialKey`) are security refusals, not retirements, and are untouched. -- The 159 major-17 entry files (29 retired-key rows, 53 retired-def rows, 77 semantic - entries) and the chain's step 17. - 61 `[RETIRED]` lines of the authorable surface. **The deletion gate (`check:authorable-surface`, proof 1).** `TOMBSTONE_AGE_MAJORS` is @@ -1396,19 +1395,18 @@ decision: it is `CURRENT_MAJOR − 1`, by this rule, at every major cut. release tag: a line already `[RETIRED]` on the authorable surface at `@objectstack/spec@.0.0` was retired before that cut, so its kit has lived its one major. The tag is immutable and lives on origin, so neither a row -a PR writes nor one it deletes moves the clock, and the rows of a removed region -need no backfill to stay datable. Where the tag cannot be read (offline), the gate -falls back to the registry alone and says so — strictly more refusals, never fewer. - -**Stored rows.** A `sys_metadata` row still carrying a ≤16.x spelling is no longer -rewritten at read. Canonicalize it once, `os migrate meta --stored --apply` on 18.1 or -earlier, before moving the deployment past it. Two consequences are specific: -`app-hidden-to-unpublished` (ADR-0045 amendment 2026-08-09) is among the removed -conversions, so a pre-split `hidden: true` app row is no longer rewritten to -`_unpublished` by any seam — the artifact door's refusal of it by id is moot and was -removed; and the 12 surviving `toMajor: 17` conversions have no chain step to be -listed under, so `os migrate meta` cannot enumerate them (they still run at the -stored-row and artifact seams). +a PR writes nor one it deletes moves the clock. The rows alone do not suffice: 46 of +the 61 deleted lines predate the table and no row names them. Where the tag cannot +be read (offline), the gate falls back to the registry alone and says so — strictly +more refusals, never fewer. + +**Stored rows.** A `sys_metadata` row still carrying a spelling whose conversion aged +out is no longer rewritten at read. Canonicalize it once, `os migrate meta --stored +--apply` on 18.2 or earlier, before moving the deployment to 18.3; the ruling +puts that one-time notice in 18.2's release notes. One consequence is specific: `app-hidden-to-unpublished` +(ADR-0045 amendment 2026-08-09) is among the removed conversions, so a pre-split +`hidden: true` app row is no longer rewritten to `_unpublished` by any seam — the +artifact door's refusal of it by id is moot and was removed. **Residuals.** The clock reads the release tag, so a repository whose checkout cannot fetch `refs/tags/@objectstack/spec@.0.0` dates by the registry only; CI's shallow diff --git a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md index 53587924c73..7173a544f19 100644 --- a/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md +++ b/docs/adr/0142-ownership-queue-membership-claim-and-assignment-rules.md @@ -159,7 +159,7 @@ flow's two values plus `inherit` (`packages/spec/src/data/hook.zod.ts#HookSchema | Member | Declared | Enforced | Guarded | |:--|:--|:--|:--| | approver `queue` (`packages/spec/src/automation/approval.zod.ts#ApproverType`) | still parses; comment: *"Re-admit only together with a real ownership-queue implementation (queue entity + membership + claim)"* | `packages/plugins/plugin-approvals/src/approval-service.ts#resolveApproverSpec` has no queue branch: it warns *"approver type 'queue' is not implemented — the slot resolves to nobody"* and returns the literal `queue:` slot, which no reader addresses | `packages/spec/src/automation/approval.zod.ts#NON_AUTHORABLE_APPROVER_TYPES` (published as `xEnumDeprecated` on `#ApprovalNodeApproverSchema`), `#APPROVER_VALUE_BINDINGS` `queue: { source: 'unsupported' }`, `#APPROVER_ORG_SCOPED` `queue: false`; the `xRef.map` keeps `queue` so stored rows render | -| sharing recipient `queue` | absent from the authorable enum `packages/spec/src/security/sharing.zod.ts#ShareRecipientType`; carried by the runtime union `packages/spec/src/contracts/sharing-service.ts#SharingRuleRecipientType` — *"reserved: no `sys_queue` yet, `expandRecipient` returns `[]`, deliberately not authorable"* | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient` falls through to `return []` ("treat as no-op until queue impl lands"); the recompute modules exclude it by name (`packages/plugins/plugin-sharing/src/bu-tree-recompute.ts#BU_TREE_RECIPIENT_TYPES`; `packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts#RECIPIENT_MEMBERSHIP_SOURCES`) | the lint red-line table says *"If it ever becomes authorable it is a work-distribution list, not a BU node — but this table is the place to re-decide that, in `ShareRecipientType` order"* (`packages/lint/src/validate-org-axis-red-lines.ts#validateOrgAxisRedLines`); migration 17 recorded it as *"runtime-reserved and deliberately non-authorable"* (the semantic entry `sharing-rule-recipient-reconcile`, retired with the protocol-17 step at #25767); the dogfood `sharing-rules` row names it as *"the one runtime `SharingRuleRecipientType` member deliberately absent"* and asks to be re-checked when the enum moves (`packages/qa/dogfood/test/authz-conformance.matrix.ts#AUTHZ_CONFORMANCE`); `packages/plugins/plugin-sharing/src/access-level.ts` still says the recipient *"was removed"* | +| sharing recipient `queue` | absent from the authorable enum `packages/spec/src/security/sharing.zod.ts#ShareRecipientType`; carried by the runtime union `packages/spec/src/contracts/sharing-service.ts#SharingRuleRecipientType` — *"reserved: no `sys_queue` yet, `expandRecipient` returns `[]`, deliberately not authorable"* | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient` falls through to `return []` ("treat as no-op until queue impl lands"); the recompute modules exclude it by name (`packages/plugins/plugin-sharing/src/bu-tree-recompute.ts#BU_TREE_RECIPIENT_TYPES`; `packages/plugins/plugin-sharing/src/recipient-membership-recompute.ts#RECIPIENT_MEMBERSHIP_SOURCES`) | the lint red-line table says *"If it ever becomes authorable it is a work-distribution list, not a BU node — but this table is the place to re-decide that, in `ShareRecipientType` order"* (`packages/lint/src/validate-org-axis-red-lines.ts#validateOrgAxisRedLines`); migration 17 records it as *"runtime-reserved and deliberately non-authorable"* (`packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts#entry`); the dogfood `sharing-rules` row names it as *"the one runtime `SharingRuleRecipientType` member deliberately absent"* and asks to be re-checked when the enum moves (`packages/qa/dogfood/test/authz-conformance.matrix.ts#AUTHZ_CONFORMANCE`); `packages/plugins/plugin-sharing/src/access-level.ts` still says the recipient *"was removed"* | No `sys_queue`, no `queue` metadata type and no assignment-rule concept exists anywhere under `packages/spec/src` (zero hits for `assignment_rule` / `AssignmentRule`). The word `queue` already names something else in this tree: the job-queue diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 226229c5bf3..31543c1049f 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1,33 +1,14 @@ # Metadata protocol upgrade guide -This file is a pointer, plus the one note that must not wait for the site. The guide is generated from the ADR-0087 registries and is not committed; published READMEs and CHANGELOGs name this path, so it stays here and names where the guide lives. - -## Upgrading from protocol 16 or earlier - -One major at a time, each major's CLI in turn — a major's CLI carries the steps for itself and the major before it, so the same sequence holds at 19: - -```bash -npx @objectstack/cli@17.7.0 migrate meta --from 16 --apply # 16 → 17, on the last 17.x release (its floor is 16) -npx @objectstack/cli@17.7.0 migrate meta --stored --apply # the sys_metadata rows, BEFORE the runtime moves -# move the dependencies / the deployment's image to 18.2, then -os migrate meta --from 17 --apply # 17 → 18 -os validate && os build -``` - -**Alternative, one shot:** `@objectstack/cli@18.1.0` is the last release whose floor is 16, so `npx @objectstack/cli@18.1.0 migrate meta --from 16` (then `--apply`) runs both hops in one command — work the to-do list it prints, then move the dependencies to 18.2 and run `os migrate meta --from 17`, `os validate` and `os build`; its `migrate meta --stored --apply` canonicalizes the stored rows the same way. - -**Stored metadata (`sys_metadata`, Studio-authored) is migrated BEFORE the deployment's image or dependencies move to 18.2.** 18.2 no longer rewrites a ≤16.x row at read: a row left behind is refused by the strict parse until it is migrated. - -**If the first hop is skipped:** on 18.2, `os migrate meta --from 16` refuses with `MigrationFloorError` and rewrites nothing, and a key retired at or below 16.x meets the strict unknown-key refusal naming this guide. - -## Where the guide lives +This file is a pointer. The guide is generated from the ADR-0087 registries and is not committed; published READMEs and CHANGELOGs name this path, so it stays here and names where the guide lives. **Address:** the docs site, one page per protocol major. - Every major: https://objectstack.ai/docs/protocol-upgrade +- Protocol 16 → 17: https://objectstack.ai/docs/protocol-upgrade/17 - Protocol 17 → 18: https://objectstack.ai/docs/protocol-upgrade/18 -Protocol N's page is `https://objectstack.ai/docs/protocol-upgrade/N`, so a link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet. A hop leaves the site when the migration chain's support floor rises past it, which happens at every major cut since ADR-0087's 2026-10-26 amendment (#25767): the floor is one major below the current one, so the 16 → 17 hop left with the 16.x retirement kits at 18. The schema's strict unknown-key refusal links every retired key here, and a kit's prescription lives in the release that carried it (its CHANGELOG, and the generated guide shipped in that `@objectstack/spec` release). +Protocol N's page is `https://objectstack.ai/docs/protocol-upgrade/N`, so a link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet. A hop leaves the site only when the migration chain's support floor rises past it (ADR-0087 D3). A key whose retirement kit has aged out (ADR-0087's 2026-10-26 amendment, #25767) is refused by the schema's strict parse with a link here; its prescription lives in the guide and the CHANGELOG of the release that retired it. **The copy a release shipped:** every `@objectstack/spec` release after 17.7.0 carries `protocol-upgrade-guide.md` in the package (`node_modules/@objectstack/spec/protocol-upgrade-guide.md`) and attached to its `@objectstack/spec@VERSION` GitHub Release. diff --git a/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts b/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts index c6f44db17f4..801592cecb3 100644 --- a/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts +++ b/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts @@ -288,7 +288,7 @@ beforeAll(async () => { // engine instead of failing. unreachableDir = mkdtempSync(join(tmpdir(), 'os-24899-unreachable-')); writeArtifact(unreachableDir, [ - { name: 'archive', driver: 'postgres', config: { host: '127.0.0.1', port: 1, database: 'archive', username: 'os24899' }, autoConnect: true }, + { name: 'archive', driver: 'postgres', config: { host: '127.0.0.1', port: 1, database: 'archive', user: 'os24899' }, autoConnect: true }, ]); unreachableRun = await runCommand(unreachableDir, join(unreachableDir, 'main.db'), ['--apply', '--yes', '--json']); }, HOOK_TIMEOUT_MS); diff --git a/packages/cli/src/commands/migrate/meta.report-order.test.ts b/packages/cli/src/commands/migrate/meta.report-order.test.ts index abbe8447406..407fda198dc 100644 --- a/packages/cli/src/commands/migrate/meta.report-order.test.ts +++ b/packages/cli/src/commands/migrate/meta.report-order.test.ts @@ -59,7 +59,6 @@ import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, - type MigrationApplication, type MigrationChainResult, type MigrationTodo, } from '@objectstack/spec/migrations'; @@ -285,11 +284,9 @@ describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () = it('prints every semantic notice of every hop crossed, byte-identical and in chain order', () => { const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); const expected = noticeLines(result); - // Anti-vacuity: every hop's catalogue (one hop since #25767 — the support - // floor sits one major below the terminus), and at least one notice whose - // prose spans several terminal lines. - expect(new Set(listedOf(result).map((t) => t.toMajor)).size) - .toBe(MIGRATION_MAJORS.filter((m) => m > MIGRATION_SUPPORT_FLOOR).length); + // Anti-vacuity: more than one hop's catalogue, and at least one notice + // whose prose spans several terminal lines. + expect(new Set(listedOf(result).map((t) => t.toMajor)).size).toBeGreaterThan(1); expect(expected.length).toBeGreaterThan(listedOf(result).length * 3); const header = indexOf(lines, SEMANTIC_HEADER_RE); const printedNotices = lines.slice(header + 1, header + 1 + expected.length); @@ -490,16 +487,6 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m expect(lines.filter((l) => l.trim() !== '').sort()).toEqual(accounted.sort()); }); - it('[#25767] every declared link names a conversion a step above the floor replays', () => { - // A link to a conversion whose step the floor no longer reaches could never - // be paired: the chain stages no edit by it. Such an entry carries no - // `conversionIds` and names the hand edit (the #23543 F4/F5 shape). - for (const link of declaredLinks()) { - const conversion = ALL_CONVERSIONS.find((c) => c.id === link.conversionId); - expect(conversion?.toMajor, `${link.entry} → ${link.conversionId}`).toBeGreaterThan(MIGRATION_SUPPORT_FLOOR); - } - }); - it('pairs every link the registry declares, replayed over the linked conversion\'s own fixture', () => { const links = declaredLinks(); // Anti-vacuity, and the two links authored when the printer learned to pair. @@ -527,31 +514,15 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m it('pairs across hops: a link to a conversion an earlier step replays', () => { // No authored link crosses a hop yet, though `SemanticMigration.conversionIds` // allows one, so this pin adds it: a protocol-18 entry made to judge a - // protocol-17 conversion. [#25767] The support floor is 17, so the chain - // replays no protocol-17 step any more and cannot stage that edit itself: - // `amend` stages it too, from the conversion's own row. What is measured - // is the PRINTER — it pairs a review with a run by `conversionId`, never - // by hop — and everything else is the chain's own result. - const earlier = 'sharing-rule-access-level-full-to-edit'; + // protocol-17 conversion. Everything else is the chain's own result. + const earlier = 'action-execute-to-target'; const conversion = ALL_CONVERSIONS.find((c) => c.id === earlier)!; const judgeId = DECISION_JUDGE; - const earlierEdit: MigrationApplication = { - toMajor: conversion.toMajor, - conversionId: earlier, - surface: conversion.surface, - from: "accessLevel: 'full'", - to: "accessLevel: 'edit'", - path: 'sharingRules[0].accessLevel', - }; const { result, lines } = run( conversion.fixture.before as Record, MIGRATION_SUPPORT_FLOOR, TERMINUS, - (r) => ({ - ...r, - applied: [...r.applied, earlierEdit], - todos: r.todos.map((t) => (t.id === judgeId ? { ...t, conversionIds: [earlier] } : t)), - }), + (r) => ({ ...r, todos: r.todos.map((t) => (t.id === judgeId ? { ...t, conversionIds: [earlier] } : t)) }), ); const judge = todoOf(result, judgeId); const edit = result.applied.find((a) => a.conversionId === earlier); diff --git a/packages/cli/src/commands/migrate/meta.stored-flags.test.ts b/packages/cli/src/commands/migrate/meta.stored-flags.test.ts index 7a993a7b242..c5f860b8c7c 100644 --- a/packages/cli/src/commands/migrate/meta.stored-flags.test.ts +++ b/packages/cli/src/commands/migrate/meta.stored-flags.test.ts @@ -14,13 +14,13 @@ import MigrateMeta, { storedOnlyFlagsIn } from './meta.js'; describe('storedOnlyFlagsIn (#4327)', () => { it('reports the stored-only flags the operator typed', () => { - expect(storedOnlyFlagsIn(['--from', '17', '--apply'])).toEqual(['apply']); + expect(storedOnlyFlagsIn(['--from', '16', '--apply'])).toEqual(['apply']); expect(storedOnlyFlagsIn(['--from=16', '--type=view', '--force'])).toEqual(['force', 'type']); - expect(storedOnlyFlagsIn(['--from', '17', '-y'])).toEqual(['yes']); + expect(storedOnlyFlagsIn(['--from', '16', '-y'])).toEqual(['yes']); }); it('says nothing about a run that typed none of them', () => { - expect(storedOnlyFlagsIn(['--from', '17', '--step', '--json'])).toEqual([]); + expect(storedOnlyFlagsIn(['--from', '16', '--step', '--json'])).toEqual([]); expect(storedOnlyFlagsIn([])).toEqual([]); }); @@ -33,7 +33,7 @@ describe('storedOnlyFlagsIn (#4327)', () => { // from `OS_DATABASE_URL`, so a merely-exported env var would have counted // as "provided" and broken `os migrate meta --from N` for anyone who has // one set. Provenance comes from argv precisely so it cannot. - expect(storedOnlyFlagsIn(['--from', '17'])).toEqual([]); + expect(storedOnlyFlagsIn(['--from', '16'])).toEqual([]); expect(storedOnlyFlagsIn(['--stored', '--database-url', 'sqlite://x.db'])).toEqual(['database-url']); }); }); diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index aec7eb13319..50f5ce4f4a9 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -428,8 +428,7 @@ function handshakeSlice(manifest: Record): ProtocolHandshakeMan * `--to`, capped at the protocol this runtime implements. `--to` defaults to * {@link CHAIN_TERMINUS_MAJOR}, which runs AHEAD of {@link PROTOCOL_MAJOR} while * this build carries the next major's conversions — measured on a protocol-17 - * build, `--from 16` replayed 16 → 18 (the floor has been 17 since #25767, - * so that range now refuses). Those conversions map shapes the + * build, `--from 16` replays 16 → 18. Those conversions map shapes the * installed schemas refuse onto ones they accept, so the migrated source is * what THIS runtime loads, and `'^18'` would be refused by the same handshake * (measured: "targets protocol ^18 … Run: objectstack migrate meta --from 18"). diff --git a/packages/cli/test/migrate-meta-composed-load-conversions.test.ts b/packages/cli/test/migrate-meta-composed-load-conversions.test.ts index 238e6ea2895..459761e2707 100644 --- a/packages/cli/test/migrate-meta-composed-load-conversions.test.ts +++ b/packages/cli/test/migrate-meta-composed-load-conversions.test.ts @@ -9,13 +9,10 @@ * converted: `datasources[].driver: 'mongo'` until #23543 F1, then a flow-level * retry policy's `retryDelayMs` until F2 retired `retry-policy-converged` from * the load path too. After F2 no load-path conversion is a step the chain - * replays from its floor (`--from 17` replays step 18, and every entry of it - * is retired), so the defect's own reading — the chain missing what the load - * converted — has no subject on this tree. What the fix changed is read - * directly instead (see "What is pinned"). [#25767] The protocol-17 step - * itself is below the support floor now, so the refused spelling the chain - * converts here is a protocol-18 one (a dashboard's `refreshInterval`); the - * flow policy stays canonical throughout. + * replays from its floor (`--from 16` replays steps 17 and 18, and every entry + * of either is retired), so the defect's own reading — the chain missing what + * the load converted — has no subject on this tree. What the fix changed is + * read directly instead (see "What is pinned"). * * ## The defect * @@ -186,14 +183,14 @@ afterAll(() => { }); /** - * The service package, which the schema accepts as written: its flow-level - * retry policy is in the canonical spelling. It carried `retryDelayMs` - * (`retry-policy-converged`, the protocol-17 step) until [#25767] raised the - * support floor to 17: that spelling is a strict unknown key now, which the - * chain no longer converts. {@link REFRESH_SERVICE} is the same package with - * one spelling the protocol-18 step converts. + * The service package, whose one non-canonical spelling is the flow-level retry + * policy's `retryDelayMs`. The current schema REFUSES it at the `retryDelayMs` + * tombstone (`retry-policy-converged` is retired from the load path, #23543 + * F2), and the chain's step 17 renames it `backoffMs`. + * {@link CANONICAL_SERVICE} is the same package as the author would write it + * today, which the schema accepts. */ -const CANONICAL_SERVICE = `import { defineStack } from '@objectstack/spec'; +const SERVICE_STACK = `import { defineStack } from '@objectstack/spec'; export const ServiceStack = defineStack({ manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' }, @@ -211,7 +208,7 @@ export const ServiceStack = defineStack({ name: 'probe_sync', label: 'Sync', type: 'autolaunched', - errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }, + errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], edges: [{ id: 'e1', source: 'start', target: 'end' }], }, @@ -240,24 +237,16 @@ const composed = (service: string): Record => ({ 'src/app.stack.ts': APP_STACK, }); -/** - * The same package with a dashboard whose refresh cadence is spelled the old - * way: the current schema REFUSES it at the `refreshInterval` tombstone, and - * the protocol-18 step renames it `refreshIntervalSeconds`. - */ -const AUTHORED_CADENCE = 'refreshInterval: 300'; -const CANONICAL_CADENCE = 'refreshIntervalSeconds: 300'; -const REFRESH_SERVICE = edit( - CANONICAL_SERVICE, - ' flows: [\n', - ` dashboards: [{ name: 'probe_ops', label: 'Ops', widgets: [], ${AUTHORED_CADENCE} }],\n flows: [\n`, -); -const REFRESH_BODY_SITE = { - conversionId: 'dashboard-refresh-interval-to-refresh-interval-seconds', - path: 'packages[0].manifest.dashboards[0].refreshIntervalSeconds', - from: 'refreshInterval', - to: 'refreshIntervalSeconds', +const RETRY_CONVERSION = 'retry-policy-converged'; +const RETRY_BODY_SITE = { + conversionId: RETRY_CONVERSION, + path: 'packages[0].manifest.flows[0].errorHandling.backoffMs', + from: 'retryDelayMs', + to: 'backoffMs', }; +const AUTHORED_DELAY = 'retryDelayMs: 2000'; +const CANONICAL_DELAY = 'backoffMs: 2000'; +const CANONICAL_SERVICE = edit(SERVICE_STACK, AUTHORED_DELAY, CANONICAL_DELAY); /** The flow's policy exactly as the author wrote it … */ const AUTHORED_POLICY = { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }; /** … and as the strict parse returns it: the declared defaults filled in. */ @@ -292,21 +281,20 @@ describe('an accepted input: the authored-source load assembles the body the aut const authored = await quietLoad(dir, true); expect(bodyErrorHandling(authored.config)).toEqual(AUTHORED_POLICY); - const out = json(await runMeta(dir, ['--from', '17', '--json'])); + const out = json(await runMeta(dir, ['--from', '16', '--json'])); expect(out.applied).toEqual([]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); }); describe('a refused input the shim produces again, carrying two spellings the chain converts', () => { - // The `time` default with the UTC suffix and the dashboard's - // `refreshInterval` (both protocol-18 step) are refused by the current - // schema, so the shim hands the call's argument through and produces it - // again in `strict: false` mode for the composer. It carried a load-path - // spelling until #23543 F2; with none left that the chain replays, both are - // refusals. + // The `time` default with the UTC suffix (step 18) and the flow's + // `retryDelayMs` (step 17) are both refused by the current schema, so the + // shim hands the call's argument through and produces it again in + // `strict: false` mode for the composer. It carried a load-path spelling + // until #23543 F2; with none left that the chain replays, both are refusals. const MIXED = edit( - REFRESH_SERVICE, + SERVICE_STACK, " name: { type: 'text', label: 'Name' },\n", " name: { type: 'text', label: 'Name' },\n starts_at: { type: 'time', label: 'Starts', defaultValue: '10:00Z' },\n", ); @@ -315,25 +303,25 @@ describe('a refused input the shim produces again, carrying two spellings the ch const dir = writeProject(composed(MIXED)); const before = snapshot(dir); - const dry = json(await runMeta(dir, ['--from', '17', '--json'])); - expect(sites(dry.applied)).toEqual([REFRESH_BODY_SITE, TIME_BODY_SITE]); + const dry = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(dry.applied)).toEqual([RETRY_BODY_SITE, TIME_BODY_SITE]); - const run = await runMeta(dir, ['--from', '17', '--write', '--json']); + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); - expect(sites(out.applied)).toEqual([REFRESH_BODY_SITE, TIME_BODY_SITE]); + expect(sites(out.applied)).toEqual([RETRY_BODY_SITE, TIME_BODY_SITE]); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'src/service.stack.ts', sites: 2 }]); expect(snapshot(dir)).toEqual({ ...before, 'src/service.stack.ts': edit( edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), - AUTHORED_CADENCE, - CANONICAL_CADENCE, + AUTHORED_DELAY, + CANONICAL_DELAY, ), }); - const again = json(await runMeta(dir, ['--from', '17', '--json'])); + const again = json(await runMeta(dir, ['--from', '16', '--json'])); expect(again.applied).toEqual([]); const strict = await quietLoad(dir); @@ -343,12 +331,12 @@ describe('a refused input the shim produces again, carrying two spellings the ch it('an input defined twice starts from the innermost argument, and is listed once', async () => { const dir = writeProject(composed(edit( - edit(REFRESH_SERVICE, 'export const ServiceStack = defineStack({', 'export const ServiceStack = defineStack(defineStack({'), + edit(SERVICE_STACK, 'export const ServiceStack = defineStack({', 'export const ServiceStack = defineStack(defineStack({'), '\n});\n', '\n}));\n', ))); - const out = json(await runMeta(dir, ['--from', '17', '--json'])); - expect(sites(out.applied)).toEqual([REFRESH_BODY_SITE]); + const out = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(out.applied)).toEqual([RETRY_BODY_SITE]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); }); @@ -360,7 +348,7 @@ describe('control: an already-canonical composed project', () => { const dir = writeProject(composed(CANONICAL)); const before = snapshot(dir); - const run = await runMeta(dir, ['--from', '17', '--write', '--json']); + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); expect(out.applied).toEqual([]); diff --git a/packages/cli/test/migrate-meta-composed.test.ts b/packages/cli/test/migrate-meta-composed.test.ts index ec772f07f23..7688ee373fb 100644 --- a/packages/cli/test/migrate-meta-composed.test.ts +++ b/packages/cli/test/migrate-meta-composed.test.ts @@ -268,19 +268,21 @@ export default defineStack({ ], }); `; - // The same stack with a dashboard whose refresh cadence is spelled the old - // way: a second refused spelling, which the protocol-18 step renames. The - // raw hand-back keeps it visible to the chain. The second spelling was a - // conversion the load still applied — a datasource `driver: 'mongo'` until - // #23543 F1, then a flow's `retryDelayMs` until F2 retired - // `retry-policy-converged` from the load path — and [#25767] the protocol-17 - // step those rode is below the support floor now, so the row reads a - // protocol-18 spelling. + // The same stack with a flow whose retry policy spells its delay the old + // way: a second refused spelling, which the chain's step 17 renames. The raw + // hand-back keeps it visible to the chain. It was a conversion the load + // still applied — a datasource `driver: 'mongo'` until #23543 F1, then this + // `retryDelayMs` until F2 retired `retry-policy-converged` from the load + // path — and no load-path conversion is a step the chain replays from its + // floor any more, so the row reads the two-spelling refused arm. const SINGLE_WITH_SECOND_REFUSED_SPELLING = edit( SINGLE, ' ],\n});\n', ' ],\n' - + " dashboards: [{ name: 'probe_ops', label: 'Ops', widgets: [], refreshInterval: 300 }],\n" + + " flows: [{ name: 'probe_sync', label: 'Sync', type: 'autolaunched', " + + "errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, " + + "nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], " + + "edges: [{ id: 'e1', source: 'start', target: 'end' }] }],\n" + '});\n', ); const TIME_SITE = { @@ -289,28 +291,28 @@ export default defineStack({ from: '"10:00Z"', to: '"10:00"', }; - const REFRESH_SITE = { - conversionId: 'dashboard-refresh-interval-to-refresh-interval-seconds', - path: 'dashboards[0].refreshIntervalSeconds', - from: 'refreshInterval', - to: 'refreshIntervalSeconds', + const RETRY_SITE = { + conversionId: 'retry-policy-converged', + path: 'flows[0].errorHandling.backoffMs', + from: 'retryDelayMs', + to: 'backoffMs', }; it.each([ { name: 'a refused stack', source: SINGLE, applied: [TIME_SITE], writtenSites: 1 }, - { name: 'a refused stack carrying a second refused spelling', source: SINGLE_WITH_SECOND_REFUSED_SPELLING, applied: [REFRESH_SITE, TIME_SITE], writtenSites: 2 }, + { name: 'a refused stack carrying a second refused spelling', source: SINGLE_WITH_SECOND_REFUSED_SPELLING, applied: [RETRY_SITE, TIME_SITE], writtenSites: 2 }, ])('$name: `applied` and `write.files` as measured before the change', async ({ source, applied, writtenSites }) => { const dir = writeProject({ 'objectstack.config.ts': source }); - const dry = json(await runMeta(dir, ['--from', '17', '--json'])); + const dry = json(await runMeta(dir, ['--from', '16', '--json'])); expect(sites(dry.applied)).toEqual(applied); - const run = await runMeta(dir, ['--from', '17', '--write', '--json']); + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); const out = json(run); expect(sites(out.applied)).toEqual(applied); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: writtenSites }]); let expected = edit(source, "defaultValue: '10:00Z'", "defaultValue: '10:00'"); - if (writtenSites === 2) expected = edit(expected, 'refreshInterval: 300', 'refreshIntervalSeconds: 300'); + if (writtenSites === 2) expected = edit(expected, 'retryDelayMs: 2000', 'backoffMs: 2000'); expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe(expected); }, RUN_TIMEOUT); }); diff --git a/packages/cli/test/migrate-meta-default-range.test.ts b/packages/cli/test/migrate-meta-default-range.test.ts index 34a1c2410a4..a2ce5936980 100644 --- a/packages/cli/test/migrate-meta-default-range.test.ts +++ b/packages/cli/test/migrate-meta-default-range.test.ts @@ -266,11 +266,10 @@ describe('os migrate meta — an empty range answers as an empty range (#17134)' * todos on every hop it replays, whatever the stack holds, so an empty * `todos` needs a hop whose own `semantic` list is empty. `13 → 14` was that * hop: step 14 carried none. Raising `MIGRATION_SUPPORT_FLOOR` to 16 retired - * steps 11–16, and raising it to 17 (#25767) retired step 17; the one hop - * the chain still reaches carries semantic entries — so on this build no - * `--from` / `--to` reaches the sentence at all. Measured before the second - * move: `--from 16 --to 17` on the canonical stack printed 77 manual changes - * and `Migrated stack is schema-valid` at exit 0. + * steps 11–16, and both hops the chain still reaches carry semantic entries — + * so on this build no `--from` / `--to` reaches the sentence at all. Measured: + * `--from 16 --to 17` on the canonical stack prints 77 manual changes and + * `Migrated stack is schema-valid` at exit 0. * * ⛔ Re-pointing `13` to `16` would NOT have pinned this behaviour — it would * have pinned the OTHER branch of the same `if` under this branch's name, diff --git a/packages/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index f14df030969..a34e992ac1e 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -33,8 +33,7 @@ * * - The covered set is every semantic entry in the registry, so an entry added * later — in any family, a new family included — is held to the same line on - * arrival; and the set must still contain every entry the rewrites covered - * that the chain still carries (an entry retired one major on leaves both), + * arrival; and the set must still contain every entry the rewrites covered, * so it cannot turn every assertion below into a loop over nothing. * - Each block is asserted PRESENT in stdout before it is asserted clean, so a * renderer change that stopped printing the prose fails here instead of @@ -76,31 +75,41 @@ const TRACKER_ID = /#\d{4,5}\b/; /** * The entries rewritten when each family was brought to this line — the * anti-vacuity floor. An entry that carried no tracker id to begin with needs no - * row here: the whole directory is held. An entry that has since AGED OUT of - * the chain leaves this floor with its guidance (ADR-0087, one major on: - * objectstack#25767 removed the major-17 step at 18.2, and the 75 rows it - * carried left here), so the floor is the rewritten entries the registry still - * holds — 169 at that cut — never a row the chain can no longer print. + * row here: the whole directory is held. */ const REWRITTEN = [ 'action-bulk-dispatch-contract-undeclared', + 'action-descriptor-is-async-retired', + 'action-descriptor-resume-authority-default-flip', 'action-engine-facade-find-query-envelope', + 'action-session-roles-to-positions', + 'actor-user-roles-to-positions', 'address-location-value-unknown-keys-refused', 'admin-export-wildcard-removed', 'admin-scope-business-unit-blank-refused', 'advanced-plugin-lifecycle-config-retired', + 'aggregation-node-distinct-retired', 'ai-conversation-analytics-duration-unit-in-key', 'analytics-authorable-unknown-keys-refused', 'analytics-date-range-array-two-bounds-required', + 'analytics-query-request-envelope-retired', 'analytics-time-dimension-date-range-vocabulary-closed', 'api-assembled-entry-split', 'api-error-retry-after-unit-in-key', 'api-runtime-config-durations-unit-in-key', + 'api-runtime-create-withdrawn', + 'apimethod-enum-shrink', + 'approval-escalation-enabled-default-flip', 'assembled-package-body-plugins-envelope', 'audience-posture-default-invite-only', + 'audit-log-action-enum-retired', + 'audit-log-action-restore-retired', + 'auth-config-unadvertised-reserved-features', + 'authoring-schemas-strict-unknown-keys', 'automation-flow-list-route-retired', 'automation-runs-cursor-retired', 'autonumber-default-unique-organization', + 'batch-row-result-schema-shape', 'branded-identifier-schemas-retired', 'cache-warmup-scheduled-strategy-retired', 'cbp-master-detail-required-forced', @@ -108,40 +117,65 @@ const REWRITTEN = [ 'change-management-duration-keys-retired', 'change-management-family-retired', 'cli-command-contribution-retired', + 'client-delete-result-success', 'client-meta-reset-result-reset', 'cloud-subpath-retired', 'cluster-driver-dangling-values-removed', + 'connector-inline-authentication-publish-refused', 'dashboard-header-modal-target-page-only', 'dashboard-widget-chart-config-structure-refused', + 'dashboard-widget-compareto-offset', 'dashboard-widget-metric-family-multi-measure-refused', 'dashboard-widget-stage-order-non-funnel-refused', + 'data-driver-find-stream-retired', + 'data-driver-query-omit-object', + 'data-engine-batch-retired', + 'data-field-changed-event-retired', 'data-file-value-duration-unit-in-key', 'data-nosql-query-options-timeout-unit-in-key', 'dataset-filter-nested-relation-equality-array-refused-at-save', 'dataset-measure-aggregate-field-type-refused', 'dataset-measure-selecting-aggregate-field-type-refused', + 'datasource-config-inline-credential-refused', 'datasource-config-mongo-options-credential-refused', + 'datasource-config-placeholder-refused', 'datasource-config-postgres-url-unparseable-refused', 'datasource-config-url-query-credential-refused', + 'datasource-config-url-userinfo-refused', 'datasource-credentialsref-mongo-composed-no-username-refused', 'datasource-credentialsref-mongo-url-no-user-refused', + 'declarative-apis-endpoints-live', + 'delete-by-id-before-hook-repoint-retired', 'device-request-response-interval-unit-in-key', + 'driver-aggregate-undeclared-key-aliases-removed', + 'driver-capabilities-inert-bits-removed', 'driver-options-timeout-to-timeout-ms', + 'driver-sql-distinct-bare-filter-typed', 'driver-sql-unresolvable-where-column-refused', 'driver-sql-upsert-cross-row-identity-merge-refused', 'driver-turso-config-local-path-wasm-retired', 'element-data-source-and-object-block-filter-rule-array', 'engine-dotted-filter-refused', + 'engine-dotted-projection-refused', + 'engine-find-formula-filter-refused', + 'engine-find-formula-order-by-refused', + 'engine-update-upsert-retired', + 'enhanced-api-error-field-errors-renamed', 'epoch-instant-keys-renamed', 'esignature-config-deadline-keys-retired', + 'etl-pipeline-layer-retired', 'evaluated-expression-slots-source-required', 'event-name-schema-retired', + 'export-axis-opt-in', + 'export-field-meta-constraints-retired', 'export-job-family-retired', + 'external-lookup-message-queue-families-retired', 'field-currency-scale-refused', 'field-max-length-malformed-or-misplaced-refused', 'field-min-length-malformed-or-misplaced-refused', 'field-multiple-non-capable-type-refused', 'field-predicate-reference-traversal-refused', + 'field-runtime-create-withdrawn', 'field-scale-precision-integer-refused', 'filter-between-blank-endpoint-refused', 'filter-between-field-reference-endpoint-refused', @@ -152,18 +186,26 @@ const REWRITTEN = [ 'filter-ne-array-comparand-refused', 'filter-preset-ordering-comparand-refused', 'filter-query-face-comparands-refused-at-save', + 'filter-regex-options-retired', 'filter-text-operator-declared-type-refused', 'flow-decision-branch-expression-absent-refused', 'flow-decision-edge-branching-first-match', 'flow-edge-condition-evaluated-slot-source-required', 'flow-predicate-slot-blank-string-refused', + 'flow-retry-max-retries-required', + 'hook-context-session-roles-retired', + 'hook-register-empty-object-target-refused', 'hook-register-undispatched-lifecycle-event-refused', 'hot-reload-inert-state-strategies-retired', 'hot-reload-watch-placeholder-retired', + 'http-request-errors-total-retired', + 'http-server-runtime-vocabulary-retired', 'identity-api-key-schema-retired', + 'import-run-automations-declared-default-corrected', 'incident-response-deadline-keys-retired', 'incident-response-family-retired', 'inline-grid-column-currency-scale-refused', + 'job-retry-policy-constraints-tightened', 'kernel-compatibility-matrix-estimated-migration-time-unit-in-key', 'kernel-context-preview-mode-retired', 'kernel-event-bus-retention-unit-in-key', @@ -180,6 +222,7 @@ const REWRITTEN = [ 'metadata-manager-config-cache-ttl-unit-in-key', 'metadata-manager-config-inert-cache-keys-retired', 'metadata-plugin-additional-types-retired', + 'notification-list-cursor-retired', 'object-block-sort-item-array', 'object-grid-data-view-data-converged', 'object-index-unknown-keys-refused', @@ -187,33 +230,52 @@ const REWRITTEN = [ 'package-api-contracts-unmounted-entries-retired', 'package-install-request-unknown-keys-refused', 'package-rollback-response-retired', + 'package-uninstall-explicit-all-tenants', 'packages-list-pagination-retired', 'platform-timezone-columns-iana-domain-refused', + 'plugin-activation-events-retired', 'plugin-auto-restart-never-reinitialised', 'plugin-manifest-contributes-dead-members-retired', 'plugin-manifest-contributes-routes-retired', 'plugin-manifest-dead-containers-retired', 'plugin-manifest-kind-globs-retired', + 'plugin-manifest-loading-retired', + 'plugin-runtime-family-retired', 'plugin-security-scan-result-surface-retired', 'plugin-security-scanner-retired', + 'position-permissions-column-retired', + 'query-array-string-agg-retired', + 'query-cursor-retired', + 'query-distinct-retired', + 'query-field-node-object-form-retired', + 'query-joins-retired', + 'query-window-functions-retired', 'record-chatter-position-vocabulary-converged', + 'record-details-sections-object-form', 'rest-api-endpoint-handler-status-retired', 'rest-api-plugin-durations-unit-in-key', 'rest-server-config-dead-keys-retired', + 'rest-server-openapi31-block-removed', 'rls-predicate-array-comparand-refused', 'rls-predicate-cross-class-field-comparison-refused', 'rls-predicate-stored-list-ordering-refused', + 'runtime-httpserver-wrapper-retired', 'schedule-flow-acting-organization-required', 'scim-provider-object-retired', 'screen-field-lookup-reference-required', 'send-template-input-org-retired', 'session-payload-positions-security-axis', 'session-user-language-retired', + 'sharing-execution-context-retired', + 'sharing-rule-recipient-reconcile', + 'sort-node-direction-rejected', + 'spec-type-alias-input-suffix-retired', 'stack-themes-carrier-retired', 'stack-top-level-unknown-keys-refused', 'standard-error-code-batch-members-retired', 'standard-error-code-concurrent-limit-exceeded-retired', 'startup-orchestrator-retired', + 'storage-service-list-retired', 'strategy-context-aggregation-method-narrowed', 'structured-region-body-pause-and-end-refused', 'sys-account-issuer-retired', @@ -230,6 +292,7 @@ const REWRITTEN = [ 'tenant-schema-cache-ttl-unit-in-key', 'tenant-timeouts-unit-in-key', 'time-update-interval-sub-day-retired', + 'tool-requires-confirmation-retired', 'training-deadline-keys-retired', 'training-family-retired', 'translation-per-app-settings-platform-only', @@ -239,19 +302,26 @@ const REWRITTEN = [ 'ui-form-field-length-malformed-refused', 'ui-form-field-precision-scale-integer-refused', 'ui-form-view-predicate-features-root-refused', + 'ui-interaction-config-family-retired', 'ui-list-view-groupbyfield-padded-refused', 'ui-list-view-grouping-field-padded-refused', 'ui-mcp-connect-agent-unknown-keys-refused', + 'ui-notification-action-embed-config-retired', 'ui-object-grid-page-size-positive-integer-refused', 'ui-react-list-view-binding-aliases-retired', 'ui-record-blocks-unknown-keys-refused', 'ui-reference-rail-unknown-keys-refused', + 'ui-widget-i18n-family-retired', + 'ups-delegated-from-column-retired', 'view-filter-rule-absent-value-refused', 'view-filter-rule-scalar-operator-array-refused', + 'view-filter-rule-value-shaped-by-operator', + 'view-management-protocol-retired', 'view-overlay-options-bag-judged', 'view-pagination-page-size-default-50', 'wait-node-event-config-required', 'websocket-durations-unit-in-key', + 'workflow-service-slot-retired', ]; interface FamilyEntry { diff --git a/packages/cli/test/migrate-meta-load-conversions.test.ts b/packages/cli/test/migrate-meta-load-conversions.test.ts index dd5ae51e264..b9d2fd9d790 100644 --- a/packages/cli/test/migrate-meta-load-conversions.test.ts +++ b/packages/cli/test/migrate-meta-load-conversions.test.ts @@ -7,13 +7,10 @@ * It was measured on a spelling the load still converted: `driver: 'mongo'` * until #23543 F1, then a flow-level retry policy's `retryDelayMs` until F2 * retired `retry-policy-converged` from the load path too. After F2 no load-path - * conversion is a step the chain replays from its floor (`--from 17` replays - * step 18, and every entry of it is retired), so the defect's own reading — - * the chain missing what the load converted — has no subject on this tree. - * What the fix changed is read directly instead (see "What is pinned"). - * [#25767] The protocol-17 step itself is below the support floor now, so the - * refused spelling the chain converts here is a protocol-18 one (a dashboard's - * `refreshInterval`); the flow policy stays canonical throughout. + * conversion is a step the chain replays from its floor (`--from 16` replays + * steps 17 and 18, and every entry of either is retired), so the defect's own + * reading — the chain missing what the load converted — has no subject on this + * tree. What the fix changed is read directly instead (see "What is pinned"). * * ## The defect * @@ -37,9 +34,9 @@ * 2. A stack defined twice starts from the innermost argument. * 3. The control: an already-canonical source writes nothing, and its * `--json` summary equals the one the built stack gives. - * 4. A stack the schema refuses for two spellings the protocol-18 step - * converts: each conversion is applied once, both are written, and a - * stack defined twice is listed once. + * 4. A stack the schema refuses for two spellings the chain converts (one + * step-17, one step-18): each conversion is applied once, both are + * written, and a stack defined twice is listed once. * * Retired at F2: "lists a conversion the load already applied; --write writes * it, and the load stops converting". Its first half has no subject (above); @@ -180,14 +177,14 @@ afterAll(() => { }); /** - * A stack the schema accepts as written, its flow-level retry policy in the - * canonical spelling. It carried `retryDelayMs` (`retry-policy-converged`, the - * protocol-17 step) until [#25767] raised the support floor to 17: that - * spelling is a strict unknown key now, which the chain no longer converts. - * {@link REFRESH_SOURCE} is the same stack with one spelling the protocol-18 - * step converts. + * A stack whose one non-canonical spelling is the flow-level retry policy's + * `retryDelayMs`. The current schema REFUSES it at the `retryDelayMs` + * tombstone (`retry-policy-converged` is retired from the load path, #23543 + * F2), and the chain's step 17 renames it `backoffMs`. {@link CANONICAL_SOURCE} + * is the same stack as the author would write it today, which the schema + * accepts. */ -const CANONICAL_SOURCE = `import { defineStack } from '@objectstack/spec'; +const RETRY_SOURCE = `import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' }, @@ -205,7 +202,7 @@ export default defineStack({ name: 'probe_sync', label: 'Sync', type: 'autolaunched', - errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }, + errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], edges: [{ id: 'e1', source: 'start', target: 'end' }], }, @@ -213,24 +210,11 @@ export default defineStack({ }); `; -/** - * The same stack with a dashboard whose refresh cadence is spelled the old - * way: the current schema REFUSES it at the `refreshInterval` tombstone, and - * the protocol-18 step renames it `refreshIntervalSeconds`. - */ -const AUTHORED_CADENCE = 'refreshInterval: 300'; -const CANONICAL_CADENCE = 'refreshIntervalSeconds: 300'; -const REFRESH_SOURCE = edit( - CANONICAL_SOURCE, - ' flows: [\n', - ` dashboards: [{ name: 'probe_ops', label: 'Ops', widgets: [], ${AUTHORED_CADENCE} }],\n flows: [\n`, -); -const REFRESH_SITE = { - conversionId: 'dashboard-refresh-interval-to-refresh-interval-seconds', - path: 'dashboards[0].refreshIntervalSeconds', - from: 'refreshInterval', - to: 'refreshIntervalSeconds', -}; +const RETRY_CONVERSION = 'retry-policy-converged'; +const RETRY_SITE = { conversionId: RETRY_CONVERSION, path: 'flows[0].errorHandling.backoffMs', from: 'retryDelayMs', to: 'backoffMs' }; +const AUTHORED_DELAY = 'retryDelayMs: 2000'; +const CANONICAL_DELAY = 'backoffMs: 2000'; +const CANONICAL_SOURCE = edit(RETRY_SOURCE, AUTHORED_DELAY, CANONICAL_DELAY); /** The flow's policy exactly as the author wrote it … */ const AUTHORED_POLICY = { strategy: 'retry', maxRetries: 3, backoffMs: 2000 }; /** … and as the strict parse returns it: the declared defaults filled in. */ @@ -273,7 +257,7 @@ describe('an accepted stack: the authored-source load hands back what the author const authored = await quietLoad(dir, true); expect(authored.config.flows[0].errorHandling).toEqual(AUTHORED_POLICY); - const out = json(await runMeta(dir, ['--from', '17', '--json'])); + const out = json(await runMeta(dir, ['--from', '16', '--json'])); expect(out.applied).toEqual([]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); @@ -286,7 +270,7 @@ describe('control: an already-canonical source', () => { const dir = writeProject({ 'objectstack.config.ts': CANONICAL }); const before = snapshot(dir); - const run = await runMeta(dir, ['--from', '17', '--write', '--json']); + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); expect(out.applied).toEqual([]); @@ -306,13 +290,12 @@ describe('control: an already-canonical source', () => { }); describe('a stack with two refused spellings the chain converts', () => { - // The `time` default with the UTC suffix and the dashboard's - // `refreshInterval` (both protocol-18 step) are refused by the current - // schema, so this call hands its argument through. It carried a load-path - // spelling until #23543 F2; with none left that the chain replays, both - // arms are refusals. + // The `time` default with the UTC suffix (step 18) and the flow's + // `retryDelayMs` (step 17) are both refused by the current schema, so this + // call hands its argument through. It carried a load-path spelling until + // #23543 F2; with none left that the chain replays, both arms are refusals. const MIXED = edit( - REFRESH_SOURCE, + RETRY_SOURCE, " name: { type: 'text', label: 'Name' },\n", " name: { type: 'text', label: 'Name' },\n starts_at: { type: 'time', label: 'Starts', defaultValue: '10:00Z' },\n", ); @@ -320,20 +303,20 @@ describe('a stack with two refused spellings the chain converts', () => { it('applies each conversion once, and --write writes both', async () => { const dir = writeProject({ 'objectstack.config.ts': MIXED }); - const dry = json(await runMeta(dir, ['--from', '17', '--json'])); - expect(sites(dry.applied)).toEqual([REFRESH_SITE, TIME_SITE]); + const dry = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(dry.applied)).toEqual([RETRY_SITE, TIME_SITE]); - const run = await runMeta(dir, ['--from', '17', '--write', '--json']); + const run = await runMeta(dir, ['--from', '16', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); - expect(sites(out.applied)).toEqual([REFRESH_SITE, TIME_SITE]); + expect(sites(out.applied)).toEqual([RETRY_SITE, TIME_SITE]); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: 2 }]); expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe( - edit(edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), AUTHORED_CADENCE, CANONICAL_CADENCE), + edit(edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), AUTHORED_DELAY, CANONICAL_DELAY), ); - const again = json(await runMeta(dir, ['--from', '17', '--json'])); + const again = json(await runMeta(dir, ['--from', '16', '--json'])); expect(again.applied).toEqual([]); const strict = await quietLoad(dir); @@ -344,13 +327,13 @@ describe('a stack with two refused spellings the chain converts', () => { it('a stack defined twice starts from the innermost argument, and is listed once', async () => { const dir = writeProject({ 'objectstack.config.ts': edit( - edit(REFRESH_SOURCE, 'export default defineStack({', 'export default defineStack(defineStack({'), + edit(RETRY_SOURCE, 'export default defineStack({', 'export default defineStack(defineStack({'), '\n});\n', '\n}));\n', ), }); - const out = json(await runMeta(dir, ['--from', '17', '--json'])); - expect(sites(out.applied)).toEqual([REFRESH_SITE]); + const out = json(await runMeta(dir, ['--from', '16', '--json'])); + expect(sites(out.applied)).toEqual([RETRY_SITE]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); }); diff --git a/packages/cli/test/migrate-meta-protocol-range.test.ts b/packages/cli/test/migrate-meta-protocol-range.test.ts index f0fc586b73e..8635680412a 100644 --- a/packages/cli/test/migrate-meta-protocol-range.test.ts +++ b/packages/cli/test/migrate-meta-protocol-range.test.ts @@ -59,7 +59,6 @@ import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { stripVTControlCharacters } from 'node:util'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; -import { MIGRATION_SUPPORT_FLOOR } from '@objectstack/spec/migrations'; import { AppPlugin } from '@objectstack/runtime'; import MigrateMeta, { planProtocolRange } from '../src/commands/migrate/meta.js'; import { loadConfig } from '../src/utils/config.js'; @@ -88,14 +87,19 @@ const RUN_TIMEOUT = 120_000; /** * The shapes the load refuses on this runtime, one row per major migrated - * FROM, from the support floor up: the major, and one authored member the - * chain converts out of it — a key the current schema tombstones, so the - * authored-source load hands it to the chain as written. The `--from 17` - * row's member is converted by the protocol-18 step. [#25767] The `--from 16` - * row (`striped`, the protocol-17 step) left with the ≤16.x kit: 16 is below - * the floor now, and the refusal it meets is pinned in §3 instead. + * FROM: the major, and one authored member the chain converts out of it — a + * key the current schema tombstones, so the authored-source load hands it to + * the chain as written. The `--from 16` row's member is converted by the + * protocol-17 step, and the `--from 17` row's by the protocol-18 step. */ const SHAPES = [ + { + from: 16, + member: "views: [{ object: 'rg_item', list: { type: 'grid', columns: ['title'], striped: true } }]", + converted: ['view-list-passthrough-keys-removed'], + before: ', striped: true', + after: '', + }, { from: 17, member: "dashboards: [{ name: 'ops', label: 'Ops', widgets: [], refreshInterval: 300 }]", converted: ['dashboard-refresh-interval-to-refresh-interval-seconds'], before: 'refreshInterval: 300', after: 'refreshIntervalSeconds: 300' }, ]; @@ -396,15 +400,15 @@ describe('a range the load already admits is left alone', () => { describe('a range in a legacy home is rewritten where the handshake read it', () => { it.each([ - ['engines.platform', "engines: { platform: '^17' }", '^17', `engines: { platform: '${TARGET}' }`], + ['engines.platform', "engines: { platform: '^16' }", '^16', `engines: { platform: '${TARGET}' }`], // Its schema refuses a range short of a full version, so the full version is written. - ['engine.objectstack', "engine: { objectstack: '^17.0.0' }", '^17.0.0', `engine: { objectstack: '^${PROTOCOL_MAJOR}.0.0' }`], + ['engine.objectstack', "engine: { objectstack: '^16.0.0' }", '^16.0.0', `engine: { objectstack: '^${PROTOCOL_MAJOR}.0.0' }`], ])('%s', async (source, engines, range, rewritten) => { const text = config(engines); const dir = writeProject({ 'objectstack.config.ts': text }); - expect(await load(dir)).toEqual({ refused: expect.objectContaining(refusal(17, range, source)) }); + expect(await load(dir)).toEqual({ refused: expect.objectContaining(refusal(16, range, source)) }); - const run = await runMeta(dir, 17, ['--write', '--json']); + const run = await runMeta(dir, 16, ['--write', '--json']); expect(run.exitCode, run.stderr).toBeUndefined(); expect(JSON.parse(run.stdout).write.range).toMatchObject({ status: 'written', path: `manifest.${source}`, from: range }); expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': edit(text, engines, rewritten) }); @@ -428,12 +432,10 @@ describe('which range is written', () => { const dir = writeProject({ 'objectstack.config.ts': source }); const run = await runMeta(dir, PROTOCOL_MAJOR, ['--write']); expect(run.exitCode, run.stderr).toBeUndefined(); - // [#25767] The lowest `--from` that moves the range is never under the - // chain's floor: the range declares 16, and the run it names starts at 17. expect(run.stdout).toContain( `manifest.engines.protocol '^16' declares protocol 16, below --from ${PROTOCOL_MAJOR}: this run did not ` - + `replay protocol ${MIGRATION_SUPPORT_FLOOR} → ${PROTOCOL_MAJOR}, so the range is left as written and the load still refuses it. ` - + `Run with --from ${MIGRATION_SUPPORT_FLOOR}.`, + + `replay protocol 16 → ${PROTOCOL_MAJOR}, so the range is left as written and the load still refuses it. ` + + 'Run with --from 16.', ); expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': source }); }, RUN_TIMEOUT); @@ -529,20 +531,3 @@ describe('the range rides the plan, the write and the re-check', () => { }); }); }); - -// ── 3: below the floor (#25767) ──────────────────────────────────────────── - -describe('--from 16: below the support floor, the command refuses before it reads the range', () => { - it('is refused with the floor error naming the floor and the upgrade guide, and writes nothing', async () => { - const source = config(`engines: { protocol: '^16' }`, SHAPES[0]!.member); - const dir = writeProject({ 'objectstack.config.ts': source }); - const run = await runMeta(dir, 16, ['--write', '--json']); - expect(run.exitCode).toBe(1); - const payload = JSON.parse(run.stdout); - expect(payload.error).toBe('unsupported_from_major'); - expect(payload.message).toContain('support floor is 17'); - expect(payload.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); - // Nothing written: not the chain's edit, not the range. - expect(snapshot(dir)).toEqual({ 'objectstack.config.ts': source }); - }, RUN_TIMEOUT); -}); diff --git a/packages/cli/test/migrate-meta-write.test.ts b/packages/cli/test/migrate-meta-write.test.ts index a0aec2bc47e..14bbf710e9f 100644 --- a/packages/cli/test/migrate-meta-write.test.ts +++ b/packages/cli/test/migrate-meta-write.test.ts @@ -130,7 +130,7 @@ async function runMeta(dir: string, flags: string[]): Promise { const error = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { err.push(a.join(' ')); }); let exitCode: number | undefined; try { - await MigrateMeta.run([join(dir, 'objectstack.config.ts'), '--from', '17', ...flags], { root: CLI_ROOT }); + await MigrateMeta.run([join(dir, 'objectstack.config.ts'), '--from', '16', ...flags], { root: CLI_ROOT }); } catch (e: any) { if (typeof e?.oclif?.exit !== 'number') throw e; exitCode = e.oclif.exit; @@ -168,7 +168,7 @@ beforeAll(() => { writeFileSync(join(root, 'node_modules', 'fake-kit', 'package.json'), JSON.stringify({ name: 'fake-kit', main: 'index.js' })); writeFileSync( join(root, 'node_modules', 'fake-kit', 'index.js'), - "exports.KitView = { object: 'rf_ticket', list: { type: 'grid', columns: ['title'], filterableFields: ['title'] } };\n", + "exports.KitView = { object: 'rf_ticket', list: { type: 'grid', columns: ['title'], striped: true } };\n", ); }); @@ -192,6 +192,8 @@ import { allFlows } from './src/flows/index.js'; export default defineStack({ manifest: { id: 'com.example.write', name: 'Write fixture', version: '1.0.0', type: 'app' }, objects: [TicketObject], + // The document store, spelled the old way. + datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: {} }], dashboards: Object.values(dashboards), views: Object.values(views), agents: [SupportAgent], @@ -210,11 +212,6 @@ export const OpsDashboard = Dashboard.create({ refreshInterval: 300, // seconds }); `, - // [#25767] The three `compareTo` spellings below were the protocol-17 step's - // (`dashboard-widget-compareto-converged`), a ≤16.x kit the floor retired: - // `--write` leaves them as written now, and the schema still refuses them - // with the floor named — the control that an aged-out spelling is not - // silently rewritten by a step that no longer exists. 'src/dashboards/kpi.dashboard.ts': `import { Dashboard } from '@objectstack/spec/ui'; export const KpiDashboard = Dashboard.create({ @@ -236,8 +233,9 @@ export const TicketView = defineView({ list: { type: 'grid', columns: ['title'], - // Filter-panel whitelist. - filterableFields: ['title'], + // Zebra rows. + striped: true, + bordered: false, // no borders }, }); `, @@ -248,10 +246,9 @@ export const SupportAgent = defineAgent({ label: 'Support', role: 'Support assistant', instructions: 'Help customers.', - lifecycle: { - id: 'support', - initial: 'open', - states: { open: {} }, + knowledge: { + sources: ['faq'], + indexes: ['docs'], }, }); `, @@ -265,6 +262,7 @@ export const EscalateFlow = defineFlow({ name: 'escalate', label: 'Escalate', type: 'autolaunched', + active: false, nodes: [ { id: 'start', type: 'start', label: 'Start' }, { id: 'done', type: 'end', label: 'Done' }, @@ -278,7 +276,7 @@ export const TicketObject = ObjectSchema.create({ name: 'wr_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } }, - externalSharingModel: 'private', + tenancy: { enabled: true, organizationField: 'organization_id' }, }); `, }; @@ -288,22 +286,42 @@ function expectedAfterWrite(): Record { const p = PROJECT; return { ...p, + 'objectstack.config.ts': edit(p['objectstack.config.ts']!, "driver: 'mongo'", "driver: 'mongodb'"), 'src/dashboards/ops.dashboard.ts': edit(p['src/dashboards/ops.dashboard.ts']!, 'refreshInterval: 300,', 'refreshIntervalSeconds: 300,'), - 'src/views/ticket.view.ts': edit(p['src/views/ticket.view.ts']!, " filterableFields: ['title'],\n", ''), + 'src/dashboards/kpi.dashboard.ts': edit( + edit(p['src/dashboards/kpi.dashboard.ts']!, "compareTo: 'previousPeriod'", "compareTo: { kind: 'previousPeriod' }"), + "compareTo: { offset: '1y' }", + "compareTo: { kind: 'previousYear' }", + ), + 'src/views/ticket.view.ts': edit( + edit(p['src/views/ticket.view.ts']!, ' striped: true,\n', ''), + ' bordered: false, // no borders\n', + '', + ), 'src/agents/support.agent.ts': edit( p['src/agents/support.agent.ts']!, - " lifecycle: {\n id: 'support',\n initial: 'open',\n states: { open: {} },\n },\n", + " knowledge: {\n sources: ['faq'],\n indexes: ['docs'],\n },\n", '', ), - 'src/objects/ticket.object.ts': edit(p['src/objects/ticket.object.ts']!, " externalSharingModel: 'private',\n", ''), + 'src/flows/escalate.flow.ts': edit(p['src/flows/escalate.flow.ts']!, ' active: false,\n', ''), + 'src/objects/ticket.object.ts': edit( + p['src/objects/ticket.object.ts']!, + "tenancy: { enabled: true, organizationField: 'organization_id' }", + 'tenancy: { enabled: true }', + ), }; } const EXPECTED_SITES = [ - 'agents[0].lifecycle (agent-lifecycle-removed)', + 'agents[0].knowledge (agent-knowledge-removed)', + 'dashboards[0].widgets[0].compareTo (dashboard-widget-compareto-converged)', + 'dashboards[0].widgets[1].compareTo (dashboard-widget-compareto-converged)', 'dashboards[1].refreshIntervalSeconds (dashboard-refresh-interval-to-refresh-interval-seconds)', - 'objects[0].externalSharingModel (object-external-sharing-model-removed)', - 'views[0].list.filterableFields (view-list-filterable-fields-removed)', + 'datasources[0].driver (datasource-driver-mongo-to-mongodb)', + 'flows[0].active (flow-inert-keys-removed)', + 'objects[0].tenancy.organizationField (object-tenancy-organization-field-removed)', + 'views[0].list.bordered (view-list-passthrough-keys-removed)', + 'views[0].list.striped (view-list-passthrough-keys-removed)', ]; describe('os migrate meta --write over per-artifact modules', () => { @@ -334,8 +352,11 @@ describe('os migrate meta --write over per-artifact modules', () => { // Byte-for-byte, every file: the barrels and `index.ts` files included. expect(snapshot(dir)).toEqual(expectedAfterWrite()); expect(firstJson.write.files.map((f: any) => f.file).sort()).toEqual([ + 'objectstack.config.ts', 'src/agents/support.agent.ts', + 'src/dashboards/kpi.dashboard.ts', 'src/dashboards/ops.dashboard.ts', + 'src/flows/escalate.flow.ts', 'src/objects/ticket.object.ts', 'src/views/ticket.view.ts', ]); @@ -343,10 +364,10 @@ describe('os migrate meta --write over per-artifact modules', () => { it('reports each written site at its file and line as read', () => { const at = Object.fromEntries(firstJson.write.written.map((w: any) => [w.path, `${w.file}:${w.line}`])); - expect(at['agents[0].lifecycle']).toBe('src/agents/support.agent.ts:8'); + expect(at['datasources[0].driver']).toBe('objectstack.config.ts:12'); expect(at['dashboards[1].refreshIntervalSeconds']).toBe('src/dashboards/ops.dashboard.ts:7'); - expect(at['objects[0].externalSharingModel']).toBe('src/objects/ticket.object.ts:7'); - expect(at['views[0].list.filterableFields']).toBe('src/views/ticket.view.ts:9'); + expect(at['views[0].list.striped']).toBe('src/views/ticket.view.ts:9'); + expect(at['views[0].list.bordered']).toBe('src/views/ticket.view.ts:10'); }); it('writes the applied set and nothing else: every site it reports is an applied entry', () => { @@ -359,17 +380,9 @@ describe('os migrate meta --write over per-artifact modules', () => { // Relative to the dry run of the same build, not a pinned listing: which // notices the default list carries is the chain's business, not --write's. expect(firstJson.todos.map((t: any) => t.id)).toEqual(dry.todos.map((t: any) => t.id)); - }); - - it('leaves an aged-out spelling as written (#25767): no step rewrites it, and the schema still refuses it', () => { - // The `compareTo` spellings rode the protocol-17 step, which the support - // floor retired: nothing is attributed to them, their bytes stay, and the - // stack is still schema-invalid after the write — the refusal, not a - // silent rewrite, is the upgrade channel for a kit one major past its own. - expect(firstJson.applied.map((a: any) => a.conversionId)).not.toContain('dashboard-widget-compareto-converged'); - const kpi = readFileSync(join(dir, 'src/dashboards/kpi.dashboard.ts'), 'utf8'); - expect(kpi).toContain("compareTo: 'previousPeriod'"); - expect(kpi).toContain("compareTo: { offset: '7d' }"); + // The `compareTo` arm the conversion declines has no mechanical change, so + // its bytes stay — and the schema still refuses it, as before the write. + expect(readFileSync(join(dir, 'src/dashboards/kpi.dashboard.ts'), 'utf8')).toContain("compareTo: { offset: '7d' }"); expect(firstJson.schemaValid).toBe(false); }); @@ -392,7 +405,7 @@ describe('os migrate meta without --write (controls)', () => { const run = await runMeta(dir, ['--json', '--out', out]); expect(run.exitCode, run.stderr).toBeUndefined(); expect(snapshot(dir)).toEqual(before); - expect(JSON.parse(readFileSync(out, 'utf8')).dashboards[1].refreshIntervalSeconds).toBe(300); + expect(JSON.parse(readFileSync(out, 'utf8')).datasources[0].driver).toBe('mongodb'); }, RUN_TIMEOUT); it('prints the human report with --write, naming no capability it lacks', async () => { @@ -400,7 +413,7 @@ describe('os migrate meta without --write (controls)', () => { const run = await runMeta(dir, ['--write']); expect(run.exitCode, run.stderr).toBeUndefined(); const group = run.stdout.slice(run.stdout.indexOf('Wrote ')); - expect(group).toMatch(/^Wrote 4 of 4 mechanical change\(s\) into 4 file\(s\):/); + expect(group).toMatch(/^Wrote 9 of 9 mechanical change\(s\) into 7 file\(s\):/); expect(group).toContain('Re-ran the chain over the written sources: no mechanical change remains.'); // The group's own words; the spec's notices above it are the spec's. expect(group).not.toMatch(/automatic/i); @@ -523,20 +536,17 @@ const REFUSALS: Record = { 'objectstack.config.ts': `import { defineStack } from '@objectstack/spec'; import { KitView } from 'fake-kit'; -const START = '10:00Z'; -const sharedList = { type: 'grid', columns: ['title'], filterableFields: ['title'] }; -const listBase = { filterableFields: ['title'] }; +const MONGO = 'mongo'; +const sharedList = { type: 'grid', columns: ['title'], striped: true }; +const listBase = { bordered: true }; function makeAgent(name: string) { - return { name, label: name, role: 'r', instructions: 'i', lifecycle: 'draft' }; + return { name, label: name, role: 'r', instructions: 'i', knowledge: { sources: ['faq'] } }; } export default defineStack({ manifest: { id: 'com.example.refusals', name: 'Refusals', version: '1.0.0', type: 'app' }, - objects: [{ - name: 'rf_ticket', label: 'Ticket', - externalSharingModel: 'private', - fields: { title: { type: 'text', label: 'Title' }, starts_at: { type: 'time', label: 'Starts', defaultValue: START } }, - }], + objects: [{ name: 'rf_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }], + datasources: [{ name: 'docs', label: 'Docs', driver: MONGO, config: {} }], views: [ { object: 'rf_ticket', list: sharedList }, { object: 'rf_ticket', list: sharedList }, @@ -544,13 +554,14 @@ export default defineStack({ { object: 'rf_ticket', list: { - type: 'grid', filterableFields: ['title'], + type: 'grid', virtualScroll: true, columns: ['title'], }, }, KitView, ], agents: [makeAgent('helper_agent')], + flows: [{ name: 'plain', label: 'Plain', type: 'autolaunched', active: true, nodes: [], edges: [] }], }); `, }; @@ -570,21 +581,21 @@ describe('os migrate meta --write refuses what it cannot prove, and says why', ( payload.write.manual.find((m: any) => m.path === path)?.kind; it('names the refusal kind of every site it leaves', () => { - expect(kindOf('objects[0].fields.starts_at.defaultValue')).toBe('computed'); - expect(kindOf('views[0].list.filterableFields')).toBe('shared'); - expect(kindOf('views[1].list.filterableFields')).toBe('shared'); - expect(kindOf('views[2].list.filterableFields')).toBe('spread'); - expect(kindOf('views[3].list.filterableFields')).toBe('layout'); - expect(kindOf('views[4].list.filterableFields')).toBe('outside-project'); - expect(kindOf('agents[0].lifecycle')).toBe('helper'); + expect(kindOf('datasources[0].driver')).toBe('computed'); + expect(kindOf('views[0].list.striped')).toBe('shared'); + expect(kindOf('views[1].list.striped')).toBe('shared'); + expect(kindOf('views[2].list.bordered')).toBe('spread'); + expect(kindOf('views[3].list.virtualScroll')).toBe('layout'); + expect(kindOf('views[4].list.striped')).toBe('outside-project'); + expect(kindOf('agents[0].knowledge')).toBe('helper'); expect(payload.write.manual).toHaveLength(7); for (const m of payload.write.manual) expect(m.reason.length).toBeGreaterThan(0); }); it('still writes the site it can prove, beside them — and nothing else', () => { - expect(sites(payload.write.written)).toEqual(['objects[0].externalSharingModel (object-external-sharing-model-removed)']); + expect(sites(payload.write.written)).toEqual(['flows[0].active (flow-inert-keys-removed)']); expect(snapshot(dir)).toEqual({ - 'objectstack.config.ts': edit(REFUSALS['objectstack.config.ts']!, " externalSharingModel: 'private',\n", ''), + 'objectstack.config.ts': edit(REFUSALS['objectstack.config.ts']!, ' active: true,', ''), }); expect(payload.write.verification.ok).toBe(true); }); diff --git a/packages/cli/test/migrate-meta.e2e.test.ts b/packages/cli/test/migrate-meta.e2e.test.ts index e1a465cdb09..9805a8771ec 100644 --- a/packages/cli/test/migrate-meta.e2e.test.ts +++ b/packages/cli/test/migrate-meta.e2e.test.ts @@ -9,11 +9,9 @@ * chain attributable, the schema-validity verdict, the machine JSON shape, * `--out` snapshots, and the support-floor refusal — had no test at all. * This spawns `bin/run-dev.js` against a temp project authored in the - * protocol-17 dialect, exercising one key from each family of the protocol-18 - * step in a single pass, exactly the cross-major "consumer upgrades" - * scenario the command exists for. [#25767] It was authored in the - * protocol-16 dialect until the support floor rose to 17: that dialect's - * keys are strict unknown keys now, and `--from 16` is refused (pinned below). + * PRE-protocol-17 dialect, exercising one key from every v17 conversion + * family in a single pass, exactly the cross-major "consumer upgrades" + * scenario the command exists for. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -34,14 +32,13 @@ const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); /** - * A stack authored against protocol 17: every line marked `// 17:` is a shape - * the protocol-18 step must rewrite, spanning the step's families — renames - * (dashboard refreshInterval → refreshIntervalSeconds, job/hook timeout → - * timeoutMs, api cacheTtl → cacheTtlSeconds) and the ADR-0049 close-out - * removals (object externalSharingModel, action aria, list filterableFields, - * agent lifecycle, permission managedBy and allowRestore). + * A stack authored against protocol 16: every line marked `// 16:` is a shape + * the v17 chain must rewrite, spanning each conversion family — renames + * (action execute→target, sharing full→edit) and the #3896 close-out removals (rls.priority, the four + * tool keys, flow active/template/outputSchema/fallbackNodeId, view/dashboard + * inert keys, agent.knowledge, skill.triggerPhrases). */ -const PRE18_CONFIG = ` +const PRE17_CONFIG = ` export default { // #8687: top-level name/label were never stack keys (silently stripped // before the strict close, refused now) — the identity lives in manifest. @@ -50,77 +47,106 @@ export default { name: 'e2e_ticket', label: 'Ticket', fields: { - title: { type: 'text', label: 'Title', required: true }, // required once implied NOT NULL — the chain does NOT write that down (#16693) + title: { type: 'text', label: 'Title', required: true }, // 16: required implied NOT NULL — v17 does NOT write that down (#16693) notes: { type: 'textarea', label: 'Notes' }, }, - externalSharingModel: 'private', // 17: removed (external access is the portal's) }], actions: [{ name: 'close_ticket', label: 'Close', type: 'script', - target: 'closeHandler', - aria: { ariaLabel: 'Close this ticket' }, // 17: removed (the placing node's aria block carries it) + execute: 'closeHandler', // 16: renamed to target + shortcut: 'Ctrl+K', // 16: removed (never dispatched) + bulkEnabled: true, // 16: removed (toolbar reads view bulkActions) + }], + flows: [{ + name: 'e2e_flow', + label: 'E2E Flow', + type: 'autolaunched', + active: false, // 16: removed (status is the lifecycle) + template: true, // 16: removed (no reader) + // 16: fallbackNodeId removed (fault edges own this). maxRetries is stated + // because #4247 refuses a zero-attempt 'retry' — the count is the one v17 + // flow change the chain surfaces as a semantic TODO instead of rewriting. + errorHandling: { strategy: 'retry', maxRetries: 2, fallbackNodeId: 'n9' }, + nodes: [ + { id: 'n1', type: 'start', label: 'Start', outputSchema: { ok: { type: 'boolean' } } }, // 16: removed + { id: 'n2', type: 'delete_record', label: 'Purge', config: { objectName: 'e2e_ticket', filter: { done: true } } }, // canonical since protocol 11 — must pass through untouched + ], + edges: [], }], views: [{ object: 'e2e_ticket', - list: { type: 'grid', columns: ['title'], filterableFields: ['title'] }, // 17: removed (no Filter-panel whitelist) + list: { type: 'grid', columns: ['title'], responsive: { hiddenOn: ['xs'] }, performance: { lazyLoad: true } }, // 16: removed + form: { type: 'simple', sections: [{ fields: ['title'] }], defaultSort: [{ field: 'title' }], aria: { label: 'Ticket' } }, // 16: removed }], dashboards: [{ name: 'e2e_kpis', label: 'E2E KPIs', + aria: { label: 'KPIs' }, // 16: removed + performance: { prefetch: true },// 16: removed widgets: [], - refreshInterval: 300, // 17: renamed to refreshIntervalSeconds }], agents: [{ name: 'e2e_agent', label: 'Agent', role: 'Helper', instructions: 'help', - lifecycle: 'draft', // 17: removed (no runtime read it) - }], - jobs: [{ - name: 'e2e_sweep', - schedule: { type: 'cron', expression: '0 1 * * *' }, - handler: 'sweepTickets', - timeout: 300000, // 17: renamed to timeoutMs + knowledge: { sources: ['faq'] }, // 16: removed (never scoped retrieval) }], - hooks: [{ - name: 'e2e_audit', - object: 'e2e_ticket', - events: ['afterInsert'], - handler: 'auditTicket', - timeout: 5000, // 17: renamed to timeoutMs + skills: [{ + name: 'e2e_skill', + label: 'Skill', + tools: ['query_records'], + triggerPhrases: ['do the thing'], // 16: removed (never matched) }], - apis: [{ - name: 'list_tickets', - path: '/api/v1/apps/e2e/tickets', - method: 'GET', - type: 'object_operation', - objectParams: { object: 'e2e_ticket', operation: 'find' }, - cacheTtl: 30, // 17: renamed to cacheTtlSeconds + tools: [{ + name: 'e2e_tool', + label: 'Tool', + description: 'A tool', + parameters: { type: 'object' }, + category: 'action', // 16: removed + permissions: ['x.y'], // 16: removed (gated nothing) + active: true, // 16: removed (withdrew nothing) + builtIn: false, // 16: removed }], permissions: [{ name: 'e2e_set', label: 'Set', - objects: { e2e_ticket: { allowRead: true, allowRestore: true } }, // 17: allowRestore removed - managedBy: 'user', // 17: removed + objects: { e2e_ticket: { allowRead: true } }, + rowLevelSecurity: [{ + name: 'own_rows', + object: 'e2e_ticket', + operation: 'select', + using: 'owner = current_user.id', + enabled: true, + priority: 10, // 16: removed (no conflict to order) + }], + }], + sharingRules: [{ + name: 'share_hot', + type: 'criteria', + object: 'e2e_ticket', + label: 'Hot tickets', + condition: "record.hot == true", + sharedWith: { type: 'team', value: 'support' }, + accessLevel: 'full', // 16: full→edit (walked at the TOP-LEVEL collection) }], }; `; /** Conversion ids the run MUST attribute at least one rewrite to. */ const EXPECTED_CONVERSIONS = [ - 'object-external-sharing-model-removed', - 'action-aria-removed', - 'view-list-filterable-fields-removed', - 'dashboard-refresh-interval-to-refresh-interval-seconds', - 'agent-lifecycle-removed', - 'job-timeout-to-timeout-ms', - 'hook-timeout-to-timeout-ms', - 'api-endpoint-cache-ttl-to-cache-ttl-seconds', - 'permission-set-managed-by-removed', - 'permission-allow-restore-purge-removed', + 'action-execute-to-target', + 'action-inert-keys-removed', + 'flow-inert-keys-removed', + 'view-inert-keys-removed', + 'dashboard-inert-keys-removed', + 'agent-knowledge-removed', + 'skill-trigger-phrases-removed', + 'tool-inert-authoring-keys-removed', + 'permission-rls-priority-removed', + 'sharing-rule-access-level-full-to-edit', ]; let dir: string; @@ -137,8 +163,8 @@ async function runMeta(args: string[], cwd: string) { beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'os-migrate-meta-e2e-')); - writeFileSync(join(dir, 'objectstack.config.ts'), PRE18_CONFIG); - const stdout = await runMeta(['--from', '17', '--json', '--out', join(dir, 'migrated.json')], dir); + writeFileSync(join(dir, 'objectstack.config.ts'), PRE17_CONFIG); + const stdout = await runMeta(['--from', '16', '--json', '--out', join(dir, 'migrated.json')], dir); out = { stdout, parsed: JSON.parse(stdout) }; }, 120_000); @@ -146,13 +172,13 @@ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); -describe('os migrate meta --from 17 (e2e over the real CLI)', () => { - it('replays the protocol-18 step and reports schema-valid output', () => { - expect(out.parsed.from).toBe(17); +describe('os migrate meta --from 16 (e2e over the real CLI)', () => { + it('replays the v17 chain and reports schema-valid output', () => { + expect(out.parsed.from).toBe(16); expect(out.parsed.schemaValid).toBe(true); }); - it('attributes at least one rewrite to every expected protocol-18 conversion family', () => { + it('attributes at least one rewrite to every expected v17 conversion family', () => { const ids = new Set(out.parsed.applied.map((a: any) => a.conversionId)); for (const id of EXPECTED_CONVERSIONS) { expect(ids.has(id), `expected a rewrite from ${id}; got ${[...ids].join(', ')}`).toBe(true); @@ -189,25 +215,23 @@ describe('os migrate meta --from 17 (e2e over the real CLI)', () => { expect(parsed.success, JSON.stringify(parsed.success ? '' : parsed.error.issues.slice(0, 3))).toBe(true); // Spot-check one rewrite per family on the snapshot itself. - expect(snap.objects[0].externalSharingModel).toBeUndefined(); - expect(snap.actions[0].target, 'canonical key passes through untouched').toBe('closeHandler'); - expect(snap.actions[0].aria).toBeUndefined(); - expect(snap.views[0].list.filterableFields).toBeUndefined(); - expect(snap.views[0].list.columns, 'canonical key passes through untouched').toEqual(['title']); - expect(snap.dashboards[0].refreshIntervalSeconds).toBe(300); - expect(snap.dashboards[0].refreshInterval).toBeUndefined(); - expect(snap.agents[0].lifecycle).toBeUndefined(); - expect(snap.jobs[0].timeoutMs).toBe(300000); - expect(snap.jobs[0].timeout).toBeUndefined(); - expect(snap.hooks[0].timeoutMs).toBe(5000); - expect(snap.hooks[0].timeout).toBeUndefined(); - expect(snap.apis[0].cacheTtlSeconds).toBe(30); - expect(snap.apis[0].cacheTtl).toBeUndefined(); - expect(snap.permissions[0].managedBy).toBeUndefined(); - expect(snap.permissions[0].objects.e2e_ticket.allowRestore).toBeUndefined(); - expect(snap.permissions[0].objects.e2e_ticket.allowRead).toBe(true); + expect(snap.actions[0].target).toBe('closeHandler'); + expect(snap.actions[0].execute).toBeUndefined(); + expect(snap.actions[0].shortcut).toBeUndefined(); + expect(snap.flows[0].active).toBeUndefined(); + expect(snap.flows[0].errorHandling.fallbackNodeId).toBeUndefined(); + expect(snap.flows[0].nodes[0].outputSchema).toBeUndefined(); + expect(snap.flows[0].nodes[1].config.filter, 'canonical key passes through untouched').toEqual({ done: true }); + expect(snap.views[0].list.responsive).toBeUndefined(); + expect(snap.views[0].form.defaultSort).toBeUndefined(); + expect(snap.dashboards[0].aria).toBeUndefined(); + expect(snap.agents[0].knowledge).toBeUndefined(); + expect(snap.skills[0].triggerPhrases).toBeUndefined(); + expect(snap.tools[0].category).toBeUndefined(); + expect(snap.permissions[0].rowLevelSecurity[0].priority).toBeUndefined(); + expect(snap.sharingRules[0].accessLevel).toBe('edit'); // ⛔ ADR-0113, #16693: the chain does NOT write a column constraint for the - // author. `required: true` crosses a major as the write-time contract and + // author. `required: true` crosses 16→17 as the write-time contract and // nothing else, so `title` comes out with NO `storage` block — exactly like // the optional field beside it. The withdrawn `field-required-notnull- // explicit` conversion used to make this line read `{ notNull: true }`, and @@ -224,7 +248,7 @@ describe('os migrate meta --from 17 (e2e over the real CLI)', () => { try { const snap = readFileSync(join(dir, 'migrated.json'), 'utf-8'); writeFileSync(join(dir2, 'objectstack.config.ts'), `export default ${snap};`); - const stdout = await runMeta(['--from', '17', '--json'], dir2); + const stdout = await runMeta(['--from', '16', '--json'], dir2); const again = JSON.parse(stdout); expect(again.applied).toEqual([]); expect(again.schemaValid).toBe(true); @@ -234,13 +258,10 @@ describe('os migrate meta --from 17 (e2e over the real CLI)', () => { }, 120_000); /** - * ADR-0104 / #3438. The metadata half of a 16→17 upgrade was only half of - * it: two DATA migrations gate enforcement per deployment, and the command - * named them for the field classes the author declares. [#25767] The support - * floor is 17, so no run of this command crosses into 17 any more: the - * advice is unreachable from the command (its positive cases left with the - * ≤16.x kit, and `pendingDataMigrations` is reported as a residual on the - * card) and only the silence is pinned. + * ADR-0104 / #3438. The metadata half of a 16→17 upgrade is only half of it: + * two DATA migrations gate enforcement per deployment, and a gate nobody is + * told about is served by nobody. The advice is scoped to the field classes + * the author actually declares, so it is never noise. */ describe('per-deployment data migrations (#3438)', () => { const withFields = (fields: string) => ` @@ -250,31 +271,61 @@ describe('os migrate meta --from 17 (e2e over the real CLI)', () => { }; `; - it('says nothing on a chain that starts at the floor — no run crosses into 17 any more', async () => { + async function runJson(fields: string, args: string[] = ['--from', '16', '--json']) { const d = mkdtempSync(join(tmpdir(), 'os-migrate-meta-dm-')); try { - writeFileSync(join(d, 'objectstack.config.ts'), withFields(`cover: { type: 'image', label: 'Cover' }, spot: { type: 'location', label: 'Spot' }`)); - const res = JSON.parse(await runMeta(['--from', '17', '--json'], d)); - expect(res.dataMigrations).toEqual([]); + writeFileSync(join(d, 'objectstack.config.ts'), withFields(fields)); + return JSON.parse(await runMeta(args, d)); } finally { try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } } + } + + it('names the file migration when the metadata declares a media field', async () => { + const res = await runJson(`cover: { type: 'image', label: 'Cover' }`); + expect(res.dataMigrations.map((m: any) => m.id)).toEqual(['adr-0104-file-references']); }, 120_000); - }); - it('refuses --from 16 — the boundary the ≤16.x kit left (#25767) — naming the floor and the upgrade guide', async () => { - let failed = false; - try { - await runMeta(['--from', '16', '--json'], dir); - } catch (e: any) { - failed = true; - const body = JSON.parse(String(e.stdout || '{}')); - expect(body.error).toBe('unsupported_from_major'); - expect(body.message).toContain('support floor is 17'); - expect(body.message).toContain('https://objectstack.ai/docs/protocol-upgrade'); - } - expect(failed, 'expected a non-zero exit at the floor boundary').toBe(true); - }, 120_000); + it('names the value-shape scan when the metadata declares a covered field', async () => { + const res = await runJson(`spot: { type: 'location', label: 'Spot' }`); + expect(res.dataMigrations.map((m: any) => m.id)).toEqual(['adr-0104-value-shapes']); + }, 120_000); + + it('names both when both classes are declared', async () => { + const res = await runJson( + `cover: { type: 'image', label: 'Cover' }, spot: { type: 'location', label: 'Spot' }`, + ); + expect(res.dataMigrations.map((m: any) => m.id).sort()).toEqual([ + 'adr-0104-file-references', + 'adr-0104-value-shapes', + ]); + }, 120_000); + + it('stays silent for metadata that declares neither class', async () => { + const res = await runJson(`title: { type: 'text', label: 'Title' }`); + expect(res.dataMigrations).toEqual([]); + }, 120_000); + + it('says nothing on a chain that does not cross into 17', async () => { + const res = await runJson(`cover: { type: 'image', label: 'Cover' }`, ['--from', '17', '--json']); + expect(res.dataMigrations).toEqual([]); + }, 120_000); + + it('ends the human-readable upgrade with the advice, not just the JSON', async () => { + // Where an operator actually reads it. The advice names the command and + // says the run is dry by default, so nothing here reads as "do this and + // something irreversible happens". + const d = mkdtempSync(join(tmpdir(), 'os-migrate-meta-dm-h-')); + try { + writeFileSync(join(d, 'objectstack.config.ts'), withFields(`cover: { type: 'image', label: 'Cover' }`)); + const stdout = await runMeta(['--from', '16'], d); + expect(stdout).toMatch(/os migrate files-to-references/); + expect(stdout).toMatch(/dry-run by default/); + } finally { + try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } + }, 120_000); + }); it('refuses a --from below the support floor with the structured error', async () => { let failed = false; @@ -342,12 +393,13 @@ export default defineStack({ label: 'Agent', role: 'Helper', instructions: 'help', - lifecycle: 'draft', // RETIRED in protocol 17.7 — the whole point + knowledge: { sources: ['faq'] }, // RETIRED in protocol 17 — the whole point })], skills: [defineSkill({ name: 'rk_skill', label: 'Skill', - tools: ['query_records'], // nothing retired: the control that the helper's output survives + tools: ['query_records'], + triggerPhrases: ['do the thing'], // RETIRED in protocol 17 })], }); `; @@ -372,20 +424,23 @@ export default defineStack({ }); it('loads it, converts it, and reports the migrated stack schema-valid', async () => { - const stdout = await runMeta(['--from', '17', '--json', '--out', join(rkDir, 'migrated.json')], rkDir); + const stdout = await runMeta(['--from', '16', '--json', '--out', join(rkDir, 'migrated.json')], rkDir); const parsed = JSON.parse(stdout); const appliedAt = new Map( parsed.applied.map((a: any) => [a.conversionId, a.path] as [string, string]), ); - expect(parsed.from).toBe(17); - // Authored through a helper imported from a spec subpath, which is where a + // Authored through `defineStack`'s own inline literal… + expect(parsed.from).toBe(16); + // …and through helpers imported from a spec subpath, which is where a // tolerance scoped to `defineStack` alone would still have refused. - expect(appliedAt.get('agent-lifecycle-removed')).toBe('agents[0].lifecycle'); + expect(appliedAt.get('agent-knowledge-removed')).toBe('agents[0].knowledge'); + expect(appliedAt.get('skill-trigger-phrases-removed')).toBe('skills[0].triggerPhrases'); expect(parsed.schemaValid).toBe(true); const snap = JSON.parse(readFileSync(join(rkDir, 'migrated.json'), 'utf-8')); - expect(snap.agents[0].lifecycle).toBeUndefined(); + expect(snap.agents[0].knowledge).toBeUndefined(); + expect(snap.skills[0].triggerPhrases).toBeUndefined(); // The rest of the artifact survives the tolerant load intact — the codemod // removes what retired, not what it could not parse. expect(snap.agents[0].name).toBe('rk_agent'); @@ -403,8 +458,8 @@ export default defineStack({ } catch (e: any) { refused = true; const output = `${String(e.stdout ?? '')}${String(e.stderr ?? '')}`; - expect(output).toMatch(/`agent\.lifecycle` was removed/); - expect(output).toMatch(/os migrate meta --from 17/); + expect(output).toMatch(/`agent\.knowledge` was removed/); + expect(output).toMatch(/os migrate meta --from 16/); } expect(refused, '`os validate` must still reject a retired key').toBe(true); }, 180_000); diff --git a/packages/cli/test/package-owned-command-readers.test.ts b/packages/cli/test/package-owned-command-readers.test.ts index f9b91f85f77..6ca3f1e3df9 100644 --- a/packages/cli/test/package-owned-command-readers.test.ts +++ b/packages/cli/test/package-owned-command-readers.test.ts @@ -17,11 +17,6 @@ * os migrate meta dataMigrations [] adr-0104-file-references * --from 16 --json * - * [#25767] The data-migration listing that row measured is unreachable from the - * command since the support floor rose to 17 (ADR-0104 keys on a run crossing - * into 17), so the `migrate meta` pin reads `applied` instead: a retired key on - * the service package's object, listed at its path inside that package's body. - * * Each now folds its stack with `authoringRuleUnionStack` where it loads it. The * pairs make these pins: a fold that broke every stack would fail the control, * and the control holds the one-package answer unchanged. The enumeration of @@ -38,6 +33,8 @@ import { execFile } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, join } from 'node:path'; +import { FILE_REFERENCES_MIGRATION_ID } from '@objectstack/spec/system'; + import { CLI, TSX, childEnv } from './helpers/serve-process.js'; import { linkSpec } from './helpers/define-stack-fixture.js'; @@ -130,18 +127,11 @@ const one = (svcObjects: string) => `${PIECES} export default defineStack({ manifest: appManifest, objects: [${svcObjects}, ticket] } as any); `; -/** The service object `os migrate meta` reads: a protocol-17 spelling the chain converts at 18. */ -const scanWithRetiredKey = "{ ...scan, externalSharingModel: 'private' }"; - const CONFIGS: Record = { two: two('note'), twoAfter: two('note, scan'), one: one('note'), oneAfter: one('note, scan'), - // `diff` loads its after-side config, which the retired key would refuse, so - // `migrate meta` reads a pair of its own. - twoMeta: two(`note, ${scanWithRetiredKey}`), - oneMeta: one(`note, ${scanWithRetiredKey}`), }; const dirs: Record = {}; @@ -165,7 +155,7 @@ beforeAll(async () => { jobs.push([`diff:${shape}`, shape, [ 'diff', join(dirs[shape], 'objectstack.config.ts'), join(dirs[`${shape}After`], 'objectstack.config.ts'), '--json', ]]); - jobs.push([`meta:${shape}`, `${shape}Meta`, ['migrate', 'meta', '--from', '17', '--json']]); + jobs.push([`meta:${shape}`, `${shape}After`, ['migrate', 'meta', '--from', '16', '--json']]); } const queue = [...jobs]; const worker = async () => { @@ -212,18 +202,13 @@ describe('#22288 — os diff compares every package', () => { } }); -describe('#22288 — os migrate meta lists the mechanical edit a package\'s object needs', () => { +describe('#22288 — os migrate meta lists the data migration a package\'s fields need', () => { for (const shape of ['two', 'one'] as const) { - it(`${shape === 'two' ? 'two packages' : 'control, one package'}: a retired key on the service package's object is listed at its path`, () => { + it(`${shape === 'two' ? 'two packages' : 'control, one package'}: a file field in the service package lists it`, () => { const run = r[`meta:${shape}`]; expect(run.code, run.stderr).toBe(0); - const payload = payloadOf(run); - expect(payload.applied).toContainEqual(expect.objectContaining({ - conversionId: 'object-external-sharing-model-removed', - path: shape === 'two' ? 'packages[0].manifest.objects[1].externalSharingModel' : 'objects[1].externalSharingModel', - })); - // [#25767] No run crosses into 17 any more, so ADR-0104 lists nothing. - expect(payload.dataMigrations).toEqual([]); + const ids = (payloadOf(run).dataMigrations as Array<{ id: string }>).map((m) => m.id); + expect(ids).toContain(FILE_REFERENCES_MIGRATION_ID); }); } }); diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 9d4dfc18613..2a852ba58dd 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -190,7 +190,7 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => { // only the edits it can prove — never the unqualified claim. Pinned here AND class-wide in // `retired-key-migrate-sentence.test.ts` (widened to `packages/lint/src`). expect(rest[0].message).toMatch( - /This edit left `os migrate meta` with the protocol-16\.x step \(support floor 17\): apply it by hand, or run the tool on a release that still carries the step — https:\/\/objectstack\.ai\/docs\/protocol-upgrade\.$/, + /This edit left `os migrate meta` with the protocol-16\.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https:\/\/objectstack\.ai\/docs\/protocol-upgrade\.$/, ); expect(rest[0].message).not.toMatch(/rewrite it automatically/); expect(rest[0].message).not.toMatch(/rewrite existing sources automatically/); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index ad45e6c6d18..6f0685ad4c8 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -2846,7 +2846,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression // so this site is a member of `retired-key-migrate-sentence.test.ts`'s // widened scan (#7030) on the same textual shape as the spec corpus — no // interpolation lives in this clause, so nothing is lost switching quote style. - 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', source: JSON.stringify({ id: node.id, type: node.type, config: cfg }), }); } else if (!fn) { diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 94bda02670a..6abda63e0bd 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -783,9 +783,9 @@ const CURRENT_MAJOR = Number.parseInt(SPEC_VERSION, 10); * a line that was ALREADY `[RETIRED]` on the authorable surface the day that * major was cut predates the cut, so its kit has lived its one major. The * tag is immutable and lives on origin, so neither the registry row a PR can - * write nor the one it can delete moves this clock — which is also why a - * region whose rows have left the registry (every ≤16.x row did on #25767) - * stays datable without a backfill. + * write nor the one it can delete moves this clock — which is also why the + * tombstones that predate the table (46 of the 61 lines #25767 deleted) are + * datable without a backfill. */ const TOMBSTONE_AGE_MAJORS = 1; diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 1d4ac46f925..e908fb55b87 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -196,7 +196,7 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ + 'To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a ' + "share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of " + 'opening the whole data plane. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', ), /** diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index 22f7c0a63d6..4f9b45b9978 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -159,7 +159,7 @@ const CRUD_ALIAS_GUIDANCE = { + 'release. Use `objectName`. Rename the key; the value (the object name) is unchanged. If `objectName` is ' + 'also present, this node names two different objects — decide which object it acts on, put it on ' + '`objectName`, and delete `object`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', filters: 'The CRUD node config key `filters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a ' + 'consumer-side executor fallback, which the loader rewrote to `filter` (singular) until this release. Use ' @@ -1447,7 +1447,7 @@ export const MapConfigSchema = lazySchema(() => strictObject({ + 'Use `flowName`. Rename the key; the value (the per-item subflow\'s name) is unchanged. If `flowName` is ' + 'also present, the two name DIFFERENT subflows — decide which flow runs per item, put it on `flowName`, and ' + 'delete `flow`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', }, }, { /** diff --git a/packages/spec/src/automation/flow-sibling-block-loose-keys.ts b/packages/spec/src/automation/flow-sibling-block-loose-keys.ts index cb6334c7d8c..b358a8aa7cd 100644 --- a/packages/spec/src/automation/flow-sibling-block-loose-keys.ts +++ b/packages/spec/src/automation/flow-sibling-block-loose-keys.ts @@ -46,9 +46,9 @@ export const WAIT_EVENT_CONFIG_LOOSE_KEYS: ReadonlyArray { diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index bd02b525660..65a51335550 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -108,26 +108,26 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { + 'spelling the loader rewrote to `recipients` until this release. Use `recipients`. Rename the key; the value ' + '(who gets notified) is unchanged. If `recipients` is also present, the two name DIFFERENT recipients — ' + 'decide who gets notified, put them on `recipients`, and delete `to`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', subject: 'The `notify` node config key `subject` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the ' + 'email-idiom spelling the loader rewrote to `title` until this release. Use `title`. Rename the key; the ' + 'value (the heading text) is unchanged. If `title` is also present with a DIFFERENT value, reconcile them ' + 'onto `title`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', body: 'The `notify` node config key `body` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the ' + 'email-idiom spelling the loader rewrote to `message` until this release. Use `message`. Rename the key; ' + 'the value (the body text) is unchanged. If `message` is also present with a DIFFERENT value, reconcile ' + 'them onto `message`. (`body` IS canonical on an `http` node — the key is wrong only here.) ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', url: 'The `notify` node config key `url` was removed in @objectstack/spec 18 (ADR-0087 D2) — `url` elsewhere on ' + 'the platform means "HTTP endpoint to call" (`http` node, webhooks), a different concept from an in-app ' + 'click target, and the loader rewrote it to `actionUrl` until this release. Use `actionUrl`. Rename the ' + 'key; the value (the click-through link) is unchanged. If `actionUrl` is also present with a DIFFERENT ' + 'link, reconcile them onto `actionUrl`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', source: 'The nested `notify` node config `source: { object, id }` was removed in @objectstack/spec 18 (ADR-0087 D2) — ' + 'the click-through target is the flat PAIR `sourceObject` + `sourceId`, which the loader lifted the nested ' @@ -136,7 +136,7 @@ const NOTIFY_KEY_GUIDANCE: Readonly> = { + 'value, reconcile onto the flat pair and delete `source`. ' + 'Note the pair only takes effect together: a half-specified target is dropped so the inbox never renders a ' + 'dead link. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', }; /** diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index 4132ca4b9e6..f8b0e357488 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -162,14 +162,14 @@ const SCRIPT_KEY_GUIDANCE: Readonly> = { + 'AI/template-emitted alias the loader rewrote to `function` until this release. Use `function`. Rename the ' + 'key; the value (the callable reference) is unchanged. If `function` is also present, the two name DIFFERENT ' + 'callables — decide which one runs, put it on `function`, and delete `functionName`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', input: 'The `script` node config key `input` was removed in @objectstack/spec 18 (ADR-0087 D2) — the singular ' + "spelling leaked in from `connector_action`, where `connectorConfig.input` is a DIFFERENT and canonical " + 'surface (do not "fix" that one), and the loader rewrote it to `inputs` until this release. Use `inputs`. ' + 'Rename the key; the value (the input map) is unchanged. If `inputs` is also present with DIFFERENT values, ' + 'reconcile them onto `inputs`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', }; /** `subflow` prescriptions — one retired spelling, one wrong layer. */ @@ -180,7 +180,7 @@ const SUBFLOW_KEY_GUIDANCE: Readonly> = { + 'release. Use `flowName`. Rename the key; the value (the invoked flow\'s name) is unchanged. If `flowName` ' + 'is also present, the two name DIFFERENT flows — decide which one this step invokes, put it on `flowName`, ' + 'and delete `flow`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', timeoutMs: "A subflow step's timeout is the engine's per-node guard, so it belongs on the NODE, not in its config: " + '`{ id, type: "subflow", timeoutMs: 30000, config: { … } }`. `FlowNodeSchema.timeoutMs` is the declared key.', diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index afa3bdc5347..17499ed9dc1 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -513,10 +513,9 @@ const pageComponentVisibilityToVisibleWhen: MetadataConversion = { * carries them — and the generated upgrade guide and the `spec_changes` MCP * tool are projections of that record, composed across however many majors * the consumer is jumping; - * - the step-17 chain entry referenced them by id, so - * `os migrate meta --from 16` REWROTE the consumer's source mechanically - * instead of asking them to hand-edit — until the support floor rose to 17 - * and retired the step (#25767). + * - the step-17 chain entry references them by id, so + * `os migrate meta --from 16` REWRITES the consumer's source mechanically + * instead of asking them to hand-edit. * * The tombstone error is the backstop for someone who did neither, and it says * so by pointing at `migrate meta`. @@ -594,9 +593,8 @@ function mapObjectFieldsKey(stack: Dict, collection: string, from: string, to: s * release, so that refusal is what an author meets at every door that judges * a sharing rule (`os validate` / `os build` / `os lint`). Stored rows keep * healing: stored-row rehydration (`applyConversionsToStoredItem`) replays the - * full chain, this entry included, and so does the artifact-ingestion door - * (inside its window); `os migrate meta --from 16` did too, until the support - * floor retired the step (#25767). + * full chain, this entry included, and so do the artifact-ingestion door + * (inside its window) and `os migrate meta --from 16`. */ const sharingRuleAccessLevelFullToEdit: MetadataConversion = { id: 'sharing-rule-access-level-full-to-edit', @@ -679,9 +677,8 @@ function renameFlowConfigAliases( * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so does the artifact-ingestion door (inside its window); - * `os migrate meta --from 16` did too, until the support floor retired the - * step (#25767). + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. */ const flowNodeCrudObjectAlias: MetadataConversion = { id: 'flow-node-crud-object-alias', @@ -813,9 +810,8 @@ function liftNotifySourceShape(stack: Dict, emit: Emit): Dict { * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so does the artifact-ingestion door (inside its window); - * `os migrate meta --from 16` did too, until the support floor retired the - * step (#25767). + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. */ const flowNodeNotifyConfigAliases: MetadataConversion = { id: 'flow-node-notify-config-aliases', @@ -1051,8 +1047,7 @@ function liftWaitEventConfig(stack: Dict, emit: Emit): Dict { * Stored flows are the `includeRetired` seams: the automation engine's flow * rehydration, stored-row rehydration (`applyConversionsToStoredItem`), the * metadata save door's flow fallback, the artifact-ingestion door inside its - * window; `os migrate meta --from 16` replayed it too (a step-17 member), - * until the support floor retired the step (#25767). + * window, and `os migrate meta --from 16` (a step-17 member). */ const flowNodeWaitEventConfigLift: MetadataConversion = { id: 'flow-node-wait-event-config-lift', @@ -1140,9 +1135,8 @@ const flowNodeWaitEventConfigLift: MetadataConversion = { * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so does the artifact-ingestion door (inside its window); - * `os migrate meta --from 16` did too, until the support floor retired the - * step (#25767). + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. */ const flowNodeMapFlowAlias: MetadataConversion = { id: 'flow-node-map-flow-alias', @@ -1210,9 +1204,8 @@ const flowNodeMapFlowAlias: MetadataConversion = { * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so does the artifact-ingestion door (inside its window); - * `os migrate meta --from 16` did too, until the support floor retired the - * step (#25767). + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. */ const flowNodeSubflowFlowAlias: MetadataConversion = { id: 'flow-node-subflow-flow-alias', @@ -1476,9 +1469,8 @@ const flowNodeConnectorConfigLift: MetadataConversion = { * `os build` / `os lint`. Stored flows keep healing: the automation * engine's flow rehydration and stored-row rehydration * (`applyConversionsToStoredItem`) replay the full chain, this entry - * included, and so does the artifact-ingestion door (inside its window); - * `os migrate meta --from 16` did too, until the support floor retired the - * step (#25767). + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. * * The fixture below carried `actionType: 'invoke_function'` through both sides * until #4343 retired that key — an end state protocol 17 no longer reaches, so @@ -1668,10 +1660,8 @@ const stripKeys = ( * replays this entry, and an author is taught the canonical id instead of * being rewritten. Stored rows are the `includeRetired` seams: stored-row * rehydration (`applyConversionsToStoredItem`) and the artifact door still - * rewrite them; `os migrate meta --from 16` listed the edit for existing - * sources, as step 17's mechanical member, until the support floor retired the - * step (#25767) — the D3 entry `datasource-driver-mongo-alias-retired` names - * the hand edit since. The spelling keeps resolving the + * rewrite them, and `os migrate meta --from 16` lists the edit for existing + * sources, as step 17's mechanical member. The spelling keeps resolving the * mongodb CONTRACT, which is what let the retired * `datasource-config-driver-key-aliases` — ordered ahead of this entry while * it existed (#25767) — reach a stored `driver: 'mongo'` row's legacy config @@ -1812,9 +1802,8 @@ const datasourceDriverMongoToMongodb: MetadataConversion = { * stored-row rehydration (`applyConversionsToStoredItem`), the automation * engine's flow rehydration, the metadata save door's flow fallback, the * artifact-ingestion door inside its window — so a job stored before 17 keeps - * the three retries it ran with. `os migrate meta --from 16` replayed it too, - * until the support floor rose to 17 and retired the step (#25767); since then - * the D3 entry `job-retry-policy-without-attempts-refused` names the hand edit. + * the three retries it ran with. `os migrate meta --from 16` replays it too (a + * step-17 member, carried by the D3 entry `job-retry-policy-without-attempts-refused`). */ const retryPolicyConverged: MetadataConversion = { id: 'retry-policy-converged', diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index 831ab6654c2..1fff233d61a 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -54,7 +54,7 @@ const RETIRED_CAPABILITIES: Record = { + 'metadata, so declaring a capability here never changed which engine path ran. Delete the ' + 'block. If you wrote `readOnly: true`, read its note below — it did NOT make anything ' + 'read-only. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', readOnly: CAPABILITIES_REMOVED_PREFIX + '`readOnly` in particular NEVER made a datasource read-only: no write path consulted it, ' @@ -87,7 +87,7 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + 'enforced, but they are a DIFFERENT key on a different type and spell the delay `backoffMs`, ' + 'not `baseDelayMs`. Moving these values onto a hook or a job only makes sense if you ' + 'actually want that hook or job retried. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', healthCheck: '`datasource.healthCheck` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'health-check loop ever read it, so `enabled: true` scheduled nothing and the two timeouts ' @@ -95,12 +95,12 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + '(`ping()` / `checkHealth()`), which the datasource admin service calls for "Test ' + 'connection". The only recurring datasource timer is `external.validation.checkIntervalMs`, ' + 'which checks SCHEMA DRIFT — a different concern, not a liveness probe. Delete the block. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', externalLabel: '`external.label` was removed in @objectstack/spec 17.0.0 (ADR-0049) — nothing read ' + "the federation block's own label. Use the datasource's TOP-LEVEL `label`, which is what " + 'Setup → Datasources actually renders. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', externalRequirePermission: '`external.requirePermission` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'authorization check ever consulted it, so a permission named here gated nothing. Access to ' @@ -108,7 +108,7 @@ const RETIRED_DATASOURCE_BLOCKS: Record = { + 'exactly as for a managed datasource. Naming a permission that is never required is the ' + 'false-compliance shape ADR-0049 exists to remove — grant or withhold the object ' + 'permissions instead. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', }; /** @@ -166,7 +166,7 @@ const RETIRED_READ_REPLICAS = + 'Delete the key. There is no read-replica routing to migrate to — if your database fronts ' + 'its replicas behind one endpoint (pgpool, ProxySQL, an RDS reader endpoint), point ' + '`config` at that endpoint, which is the only read-scaling path that works today. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * The refusal of one retired driver spelling as a regular expression: diff --git a/packages/spec/src/data/driver/config-registry.zod.ts b/packages/spec/src/data/driver/config-registry.zod.ts index ae6a0212370..50487c22574 100644 --- a/packages/spec/src/data/driver/config-registry.zod.ts +++ b/packages/spec/src/data/driver/config-registry.zod.ts @@ -215,16 +215,16 @@ export interface DriverVocabularyEntry { * The prescription for an authored `datasource.driver: 'mongo'` — the * `mongodb` row's one {@link DriverVocabularyEntry.retiredAliases} entry. * Written to `enumWithRetiredValues`' conventions: the value first, the release - * and ADR that removed it, why, the one-line fix, and the post-floor `os migrate + * and ADR that removed it, why, the one-line fix, and the house `os migrate * meta` sentence (`datasource-driver-mongo-to-mongodb` is a `toMajor: 17` - * entry whose step left the chain when the support floor rose to 17, #25767). + * entry, so its step is reached by `--from 16`). */ const MONGO_DRIVER_ID_RETIRED = "Datasource `driver: 'mongo'` was removed in @objectstack/spec 18 (ADR-0087 D2) — the MongoDB driver's " + 'id has been `mongodb` since 17, the spelling both boot hosts, the `@objectstack/driver-mongodb` package ' + "and every connection URL scheme use, and the loader rewrote `mongo` to it until this release. Use `driver: 'mongodb'`. " + 'Change the value; the driver it builds and the `config` contract it is judged against are unchanged. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; /** * THE table. Everything else in this module is a projection of it. diff --git a/packages/spec/src/data/hook-body.zod.ts b/packages/spec/src/data/hook-body.zod.ts index fdef2828b7b..49692ce92e2 100644 --- a/packages/spec/src/data/hook-body.zod.ts +++ b/packages/spec/src/data/hook-body.zod.ts @@ -18,7 +18,7 @@ const CRYPTO_HASH_RETIRED = + 'in the host (a Connector recipe, or an engine-side hook) instead. If you need hashing in ' + 'a body, reopen it through the capability admission process — implementation first, the ' + 'declaration lands with the implementation. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * Capability tokens a script body may request. diff --git a/packages/spec/src/data/mapping.zod.ts b/packages/spec/src/data/mapping.zod.ts index 2f161ced2af..6e2ff1171da 100644 --- a/packages/spec/src/data/mapping.zod.ts +++ b/packages/spec/src/data/mapping.zod.ts @@ -44,14 +44,14 @@ const RETIRED_EXTRACT_QUERY = + 'export path that does not exist. Delete the key. Exports run through the ordinary ' + 'query API (`POST /api/v1/data/:object/query`); if a mapping-driven export is ever ' + 'designed, this is where it plugs back in. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; const RETIRED_ERROR_POLICY = '`mapping.errorPolicy` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' + 'import code ever read it, so `skip` / `abort` / `retry` selected between three ' + 'behaviours that were all the same behaviour. Delete the key. Error handling on the ' + 'import path belongs to the import REQUEST\'s own options, not to the stored mapping. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; const RETIRED_BATCH_SIZE = '`mapping.batchSize` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no ' @@ -61,7 +61,7 @@ const RETIRED_BATCH_SIZE = + 'the seed loader\'s and the NoSQL driver cursor\'s are all LIVE and enforced — but each is ' + 'a DIFFERENT key on a different type sizing its own path, and none of them sizes a ' + 'mapping import. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; const MAPPING_RETIRED_KEY_GUIDANCE: Readonly> = { extractQuery: RETIRED_EXTRACT_QUERY, diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 8564c193abc..cc34dd89cc7 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -78,9 +78,9 @@ describe('ObjectCapabilities', () => { expect(message).toContain(`\`${key}\``); expect(message, 'the migration command is the durable reference').not.toMatch(/#\d{3,5}/); // The prescription names the source rewrite the #3207 conversion used to - // perform; the step aged out with the 16.x kits (#25767), so the house + // perform; the conversion aged out with the 16.x kits (#25767), so the house // sentence says so and links the guide instead of naming `--from 16`. - expect(message).toContain('This edit left `os migrate meta` with the protocol-16.x step (support floor 17)'); + expect(message).toContain('This edit left `os migrate meta` with the protocol-16.x conversions'); expect(message).not.toContain('os migrate meta --from 16'); } // `trash` additionally points at the parked soft-delete issue — the @@ -470,7 +470,7 @@ describe('IndexSchema retired keys', () => { .toThrow(/Unrecognized key\(s\) on this index: `partial`[\s\S]*https:\/\/objectstack\.ai\/docs\/protocol-upgrade/); }); - it('no longer names a migrate step — the floor rose past the one that carried these', () => { + it('no longer names a migrate step — the conversion that carried these aged out with its kit', () => { for (const bad of [{ fields: ['a'], type: 'btree' }, { fields: ['a'], partial: 'x' }]) { expect(() => IndexSchema.parse(bad)).not.toThrow(/os migrate meta --from/); expect(() => IndexSchema.parse(bad)).toThrow(); @@ -2712,9 +2712,9 @@ describe('managedBy: retiring the overloaded `system` bucket', () => { expect(msg).toMatch(/managedBy/s); expect(msg).toMatch(/removed in @objectstack\/spec 17/s); expect(msg).toMatch(/system-data/s); - // The automated fix rode the protocol-17 step, which aged out with the 16.x - // kits (#25767): the house sentence says so and links the guide. - expect(msg).toMatch(/support floor 17/s); + // The automated fix rode a 16.x conversion, which aged out with its kit + // (#25767): the house sentence says so and links the guide. + expect(msg).toMatch(/protocol-16\.x conversions/s); expect(msg).not.toMatch(/os migrate meta --from 16/s); }); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index e628a8507f1..bdca6584855 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -163,13 +163,13 @@ const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record = { 'soft-delete that never ran). Delete the key. For recoverability use per-field ' + '`trackHistory` (audit trail) or a `lifecycle` policy; soft delete is parked, ' + 'and if built returns as a live enforced flag (ADR-0049 prune-or-build). ' + - 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', mru: '`enable.mru` was removed from @objectstack/spec in the 16.x line (' + 'ADR-0049) — Most-Recently-Used tracking was never implemented; no reader ' + 'existed, so the flag changed nothing. Delete the key. If MRU tracking is ' + 'built it returns as a live enforced flag (ADR-0049 prune-or-build). ' + - 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', }; /** @@ -1493,7 +1493,7 @@ const MANAGED_BY_SYSTEM_RETIRED = + 'and can be deleted; keep it only to NARROW. CSV `import` is deliberately NOT in that ' + 'default: it stays opt-in per object via `userActions: { import: true }`, which ' + 'is what a v16 `system` object already resolved to. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * Known-confusable schema keys → precise authoring guidance. diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 660f321bb22..d3f37aa2d2c 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -91,7 +91,7 @@ const AGG_RETIRED_TAIL = 'There is no replacement in the query vocabulary: read the rows with an ordinary `fields` ' + 'query and shape them in the caller, or model the roll-up as a stored field. It returns ' + 'only WITH a portable lowering — ADR-0049\'s enforce leg, implementation first. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; const ARRAY_AGG_RETIRED = '`array_agg`' + AGG_RETIRED_MIDDLE diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index e6f3cc1d10e..43663c9e629 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -967,7 +967,7 @@ const ConnectorBaseSchema = lazySchema(() => z.object({ 'here was inert while reading like a configured cap. Delete the key. Do NOT substitute ' + '`shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; ' + 'until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. ' + - 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', ), /** diff --git a/packages/spec/src/migrations/chain.ts b/packages/spec/src/migrations/chain.ts index 882abeb95c6..4776c104d69 100644 --- a/packages/spec/src/migrations/chain.ts +++ b/packages/spec/src/migrations/chain.ts @@ -13,7 +13,6 @@ */ import { PROTOCOL_MAJOR } from '../kernel/protocol-version.js'; -import { PROTOCOL_UPGRADE_GUIDE_URL } from '../shared/protocol-upgrade-guide.js'; import { ALL_CONVERSIONS } from '../conversions/registry.js'; import type { MetadataConversion } from '../conversions/types.js'; import { MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR } from './registry.js'; @@ -176,12 +175,7 @@ export class MigrationFloorError extends Error { ) { super( `Cannot migrate from protocol ${fromMajor}: the chain's support floor is ${floor} ` + - `(ADR-0087 D3). Upgrade to protocol ${floor} by another path first, then re-run. ` + - // A kit retired at or below the floor is gone with its step (one major - // past its retirement, ADR-0087 amendment #25767): the keys are refused - // as unknown keys and their prescriptions live only in the upgrade guide. - `Keys retired at or below protocol ${floor - 1} are refused as unknown keys and ` + - `prescribed in the protocol upgrade guide: ${PROTOCOL_UPGRADE_GUIDE_URL}`, + `(ADR-0087 D3). Upgrade to protocol ${floor} by another path first, then re-run.`, ); this.name = 'MigrationFloorError'; } diff --git a/packages/spec/src/migrations/entries/README.md b/packages/spec/src/migrations/entries/README.md index 2f02eb42765..5f04e3b1179 100644 --- a/packages/spec/src/migrations/entries/README.md +++ b/packages/spec/src/migrations/entries/README.md @@ -35,16 +35,9 @@ Write **one new file**, named `..ts` with `/` and `:` replac `__`, then run `pnpm --filter @objectstack/spec gen:migration-registry`: ``` -18 + data/AggregationNode:distinct → retired-keys/18.data__AggregationNode__distinct.ts +17 + data/AggregationNode:distinct → retired-keys/17.data__AggregationNode__distinct.ts ``` -The major in the name is the one the retirement **happens in** (the build's -`CURRENT_MAJOR`), and it is also the entry's lifetime: a kit lives one protocol major -after the one it retired in and leaves when the next major is cut (ADR-0087 amendment -2026-10-26, #25767 — at 18 every `17.*` file left with step 17). A row must never leave -AHEAD of its tombstone; the deletion gate dates a tombstone whose row is gone by the -previous major's release tag instead. - ```ts // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts new file mode 100644 index 00000000000..05cf82bc576 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewRequest.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// Commit f549a0d4a — api/protocol.zod.ts view-management operations +export const entry = 'api/CreateViewRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts new file mode 100644 index 00000000000..b2d39ed8b09 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__CreateViewResponse.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/CreateViewResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts new file mode 100644 index 00000000000..160ccd3fedf --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewRequest.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/DeleteViewRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts new file mode 100644 index 00000000000..056e1e53f13 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__DeleteViewResponse.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/DeleteViewResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts new file mode 100644 index 00000000000..aadf5c45e56 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewRequest.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/GetViewRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts new file mode 100644 index 00000000000..3de2069d8a7 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__GetViewResponse.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/GetViewResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts new file mode 100644 index 00000000000..1c7f230fb2c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsRequest.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/ListViewsRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts new file mode 100644 index 00000000000..cba1d055c72 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__ListViewsResponse.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/ListViewsResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts new file mode 100644 index 00000000000..75662754aa2 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewRequest.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/UpdateViewRequest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts new file mode 100644 index 00000000000..84d425c2022 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.api__UpdateViewResponse.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/UpdateViewResponse'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts new file mode 100644 index 00000000000..fc694c27871 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLDestination.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #6414 — automation/etl.zod.ts, the whole L2 layer +export const entry = 'automation/ETLDestination'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts new file mode 100644 index 00000000000..248634862ca --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLEndpointType.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'automation/ETLEndpointType'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts new file mode 100644 index 00000000000..62ebcdbef69 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipeline.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'automation/ETLPipeline'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts new file mode 100644 index 00000000000..4b955208371 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLPipelineRun.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'automation/ETLPipelineRun'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts new file mode 100644 index 00000000000..1c926428254 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLRunStatus.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'automation/ETLRunStatus'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts new file mode 100644 index 00000000000..6a96761dea4 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSource.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'automation/ETLSource'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts new file mode 100644 index 00000000000..c3155fc5703 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLSyncMode.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'automation/ETLSyncMode'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts new file mode 100644 index 00000000000..f4a1ce14fd8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformation.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'automation/ETLTransformation'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts new file mode 100644 index 00000000000..eff52c5ef56 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.automation__ETLTransformationType.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'automation/ETLTransformationType'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalDataSource.ts b/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalDataSource.ts new file mode 100644 index 00000000000..22031d64f5e --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalDataSource.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8075 — data/external-lookup.zod.ts, retired whole (ADR-0049 +// enforce-or-remove; fork (b) of the #8075 census, accepted 2026-08-12). The +// external-data-source shape whose `authentication.config` record accepted +// inline OAuth client secrets and API keys — its own docblock example wrote +// `"clientSecret": "..."`. Zero consumers: no metadata-type binding, no stack +// collection, no object/field embedding (`object.external` binds +// `ObjectExternalBindingSchema`, which routes credentials through datasource +// config per ADR-0015/0062), and its only in-module consumer +// (`ExternalLookupSchema.dataSource`) was itself consumed by nothing. Route 3: +// no tombstone, no D2 conversion — this table plus the D3 +// `external-lookup-message-queue-families-retired` are the declaration. +export const entry = 'data/ExternalDataSource'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalFieldMapping.ts b/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalFieldMapping.ts new file mode 100644 index 00000000000..6db5a640891 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalFieldMapping.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8075 — data/external-lookup.zod.ts, retired whole (ADR-0049). Extended +// `shared/FieldMapping` and was embedded only by `ExternalLookupSchema`, which +// nothing consumed. Its `transform` key's #5552 `retiredKey()` tombstone — and +// the `data/ExternalFieldMapping:transform` RETIRED_KEYS entry that registered +// it — are SUBSUMED here, the WidgetManifest.performance way: they go with the +// shape that carried them, which is strictly stronger, because there is no +// longer a mapping shape to author the key INTO. The base tombstone on +// `shared/FieldMapping` and the `integration/ConnectorFieldMapping` spelling +// are untouched and still reject the key with the #5552 prescription. +export const entry = 'data/ExternalFieldMapping'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalLookup.ts b/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalLookup.ts new file mode 100644 index 00000000000..6b2c74e8e13 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.data__ExternalLookup.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8075 — data/external-lookup.zod.ts, retired whole (ADR-0049). The root of +// the module's dependency shape: it embedded `ExternalDataSource` (the inline +// credential sink) and `ExternalFieldMapping`, and was itself referenced by no +// stack collection, metadata type or import outside packages/spec — the +// #5552 conversion's docblock had already recorded that no external-lookup +// document exists for the conversion walker to visit. Real-time external +// lookup returns via the enforce route of ADR-0049: the executor first, the +// vocabulary second. +export const entry = 'data/ExternalLookup'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts new file mode 100644 index 00000000000..331beecad79 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCaching.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #4914 — the plugin manifest's `loading` block (ADR-0049 enforce-or-remove, +// maintainer ruling 2026-08-04). `PluginLoadingConfig` was reachable from +// authored metadata ONLY through `Manifest.loading`, and the ten members +// registered with it were embedded only by it, so retiring the carrier key +// unpublishes the whole closure. The carrier itself is a `retiredKey()` tombstone +// registered one level up in `RETIRED_KEYS_BY_MAJOR`. +// +// ⚠️ `kernel/PluginLoadingEvent` and `kernel/PluginLoadingState` are +// deliberately NOT here. They live in the same module and share its prefix, +// but neither was ever embedded in `PluginLoadingConfig` — they are the +// observational half (a lifecycle event and a per-plugin state), they are +// not authorable, and they still emit. Module adjacency is not evidence, +// the `system/ServerRateLimitConfig` note above applies verbatim. +export const entry = 'kernel/PluginCaching'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts new file mode 100644 index 00000000000..d3e3ee13ba4 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginCodeSplitting.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginCodeSplitting'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts new file mode 100644 index 00000000000..17d90748618 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDependencyResolution.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginDependencyResolution'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts new file mode 100644 index 00000000000..04686103f40 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginDynamicImport.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginDynamicImport'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts new file mode 100644 index 00000000000..94495ef9818 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginHotReload.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginHotReload'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts new file mode 100644 index 00000000000..b055f785a6c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginInitialization.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginInitialization'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts new file mode 100644 index 00000000000..b9110663954 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingConfig.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginLoadingConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts new file mode 100644 index 00000000000..3e735c9438d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginLoadingStrategy.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginLoadingStrategy'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts new file mode 100644 index 00000000000..ab25566970a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPerformanceMonitoring.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginPerformanceMonitoring'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts new file mode 100644 index 00000000000..432e74661cc --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginPreloadConfig.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginPreloadConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts new file mode 100644 index 00000000000..cd0ac01d6da --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.kernel__PluginSandboxing.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'kernel/PluginSandboxing'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts b/packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts new file mode 100644 index 00000000000..9875fcf6fcc --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.shared__FieldMappingTransform.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'shared/FieldMappingTransform'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ConsumerConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ConsumerConfig.ts new file mode 100644 index 00000000000..f0ca17f7992 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.system__ConsumerConfig.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). Consumer +// group config (groupId / offset reset / auto-commit / poll size), embedded +// only by `system/MessageQueueConfig` (retired in the same change) and +// consumed by nothing. +export const entry = 'system/ConsumerConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__DeadLetterQueue.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__DeadLetterQueue.ts new file mode 100644 index 00000000000..1c4c4b94735 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.system__DeadLetterQueue.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). DLQ config, +// embedded only by `system/MessageQueueConfig` (retired in the same change) +// and consumed by nothing. ⚠️ `kernel/DeadLetterQueueEntry` is a different +// declaration (the event bus's per-event DLQ record) and is untouched — +// name adjacency is not evidence, the `system/ServerRateLimitConfig` note +// applies verbatim. +export const entry = 'system/DeadLetterQueue'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueConfig.ts new file mode 100644 index 00000000000..6e428a2ace6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueConfig.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049 +// enforce-or-remove; fork (b) of the #8075 census, accepted 2026-08-12). The +// top-level broker-administration config, carrying a required inline +// `sasl.password` credential when `sasl` was present. Zero consumers outside +// packages/spec repo-wide; no `message_queue` metadata type; the connector +// 'message_queue' ConnectorType enum value never referenced this schema. The +// consumed near-namesake `kernel/EventMessageQueueConfig` +// (`EventBusConfigSchema.messageQueue`) deliberately carries NO credential +// field and is untouched. Route 3: no tombstone, no D2 conversion — this +// table plus the D3 `external-lookup-message-queue-families-retired` are the +// declaration. +export const entry = 'system/MessageQueueConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueProvider.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueProvider.ts new file mode 100644 index 00000000000..36736ba1598 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.system__MessageQueueProvider.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). The +// six-value broker provider enum, embedded only by +// `system/MessageQueueConfig` (retired in the same change). Not the same +// declaration as `kernel/EventMessageQueueConfig.provider` — that is its own +// INLINE `z.enum` (same six values, never a reference to this def) and is +// untouched. +export const entry = 'system/MessageQueueProvider'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts new file mode 100644 index 00000000000..bfe2c4af80d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerCapabilities.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #5295 — system/http-server.zod.ts runtime vocabulary +export const entry = 'system/ServerCapabilities'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts new file mode 100644 index 00000000000..560189a7f52 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEvent.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'system/ServerEvent'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts new file mode 100644 index 00000000000..ab99cedf27a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerEventType.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'system/ServerEventType'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts new file mode 100644 index 00000000000..ff3b131953b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.system__ServerStatus.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'system/ServerStatus'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.system__TopicConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.system__TopicConfig.ts new file mode 100644 index 00000000000..42e88b4fe08 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.system__TopicConfig.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8075 — system/message-queue.zod.ts, retired whole (ADR-0049). Topic +// partitions/replication/retention/compression config, embedded only by +// `system/MessageQueueConfig` (retired in the same change) and consumed by +// nothing — leaving it behind would strand an exported schema with no +// consumer (#3950). +export const entry = 'system/TopicConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts new file mode 100644 index 00000000000..62d51f175a1 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__DateFormat.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/DateFormat'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts new file mode 100644 index 00000000000..fb1d94fd923 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__I18nObject.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/I18nObject'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts new file mode 100644 index 00000000000..65409a4e602 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__LocaleConfig.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/LocaleConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts new file mode 100644 index 00000000000..e8cc4bbc308 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__NumberFormat.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/NumberFormat'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts new file mode 100644 index 00000000000..bf8390d4dc5 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__PluralRule.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/PluralRule'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts new file mode 100644 index 00000000000..cbfd60752bd --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetEvent.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/WidgetEvent'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts new file mode 100644 index 00000000000..9b9bfc23d29 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetLifecycle.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/WidgetLifecycle'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts new file mode 100644 index 00000000000..9cd6475bf8e --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetManifest.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/WidgetManifest'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts new file mode 100644 index 00000000000..b6bffe48e02 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetProperty.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/WidgetProperty'; diff --git a/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts new file mode 100644 index 00000000000..2ced2c07e12 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/17.ui__WidgetSource.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/WidgetSource'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts new file mode 100644 index 00000000000..a2e10323ed8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__magicLink.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7481 — the sibling of `api/AuthFeaturesConfig:passkeys`, retired in the same +// maintainer ruling (2026-08-11) and for the same reason, but with one +// difference a reader of this table must not lose: what was inert here is the +// ADVERTISEMENT, not the capability. `AuthPluginConfig.plugins.magicLink` still +// wires better-auth's magic-link plugin and `/api/v1/auth/magic-link/{send,verify}` +// still answer — no client just renders anything off the served flag, so the +// flag alone was the false promise. The prescription says so explicitly rather +// than reusing its sibling's string. +// +// Same response-surface disposition as its sibling: no D2 conversion (nothing +// authors an `AuthFeaturesConfig`), prescription carried by this tombstone plus +// the D3 semantic entry `auth-config-unadvertised-reserved-features`. +export const entry = 'api/AuthFeaturesConfig:magicLink'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts new file mode 100644 index 00000000000..21d69da3246 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.api__AuthFeaturesConfig__passkeys.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7481 — the `/api/v1/auth/config` `features` payload stops advertising the +// two capabilities no client can act on (maintainer ruling 2026-08-11, +// declared = enforced: a deployer must not be able to flip a flag that does +// nothing anywhere). `passkeys` is the emptier of the pair — no login UI reads +// it AND no better-auth passkey plugin is wired behind it. +// +// Registered here but NOT in `src/conversions/registry.ts`, for the same reason +// as the `api/ListNotifications{Request,Response}:cursor` pair: this is a +// RESPONSE surface — the server mints it on every `GET /auth/config` and +// nobody authors or persists an `AuthFeaturesConfig` — so there is no source +// for `os migrate meta` to rewrite. The prescription reaches consumers as the +// D3 semantic entry `auth-config-unadvertised-reserved-features` plus this +// tombstone, the `EnhancedApiError.fieldErrors` disposition. +// +// The withdrawal is conditional, not permanent: the flags return in the change +// that ships the login UI (objectui#4179). Until then the standing record is +// `PUBLIC_AUTH_FEATURES_NOT_ADVERTISED` in `kernel/public-auth-features.ts`. +export const entry = 'api/AuthFeaturesConfig:passkeys'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts new file mode 100644 index 00000000000..e2b5105845c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsRequest__cursor.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// Commit 90bbf2510 — the notification-inbox pagination key, tombstoned on BOTH halves +// of `GET /api/v1/notifications` because one capability is never half- +// deleted (maintainer ruling 2026-08-07, ruled jointly with the `unreadCount` fix, commit 17d095413). Two +// keys, one prescription: `NOTIFICATIONS_CURSOR_REMOVED` in +// `api/protocol.zod.ts` is the single string both rejection sites raise. +// +// Registered here but NOT in `src/conversions/registry.ts`, and that +// asymmetry is the point rather than an omission: a D2 conversion rewrites +// an authored source or a stored `sys_metadata` row, and these two shapes +// are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing +// persists one. The prescription reaches consumers as the D3 semantic entry +// `notification-list-cursor-retired` plus this tombstone, which is the +// disposition `BatchOptions.validateOnly` and the `AnalyticsQueryRequest` +// envelope keys already take in this major ("a semantic TODO for API +// callers rather than a stack conversion"). +export const entry = 'api/ListNotificationsRequest:cursor'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts new file mode 100644 index 00000000000..12ffedb7a27 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.api__ListNotificationsResponse__cursor.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'api/ListNotificationsResponse:cursor'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts b/packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts new file mode 100644 index 00000000000..e0afe1c7fb6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.automation__ActionDescriptor__isAsync.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #6748 — ADR-0049 enforce-or-remove on the action-descriptor capability +// block. `isAsync` was a second spelling of `supportsPause` with ZERO +// readers on a fresh three-repo measurement; its sibling took the enforce +// leg in #6667 and this one takes the remove leg. Descriptors are published +// from executor TypeScript, not from stack metadata, so the D2 side is a D3 +// `SemanticMigration` (`action-descriptor-is-async-retired`) rather than a +// MetadataConversion — there is no stored source for `os migrate meta` to +// rewrite. The `EnhancedApiError.fieldErrors` precedent. +export const entry = 'automation/ActionDescriptor:isAsync'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts new file mode 100644 index 00000000000..edec2c08bd3 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__AggregationNode__distinct.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #6815 — the per-aggregation DISTINCT flag, retired under ADR-0049 by +// maintainer ruling 2026-08-09. ONE key, and one entry, because +// `AggregationNodeSchema` is reused BY REFERENCE rather than `.extend()`ed: +// `QuerySchema.aggregations` and `EngineAggregateOptionsSchema. +// aggregations` are both `z.array(AggregationNodeSchema)`, so the walked +// shape has a single `data/AggregationNode` def and the baseline marks one +// line `[RETIRED]`. Contrast the `shared/FieldMapping:transform` trio in +// this same table, where two `.extend()`s copied the property into three +// walked shapes and each needed its own registration. +// +// Registered here but NOT in `src/conversions/registry.ts`, for the same +// reason as the `api/ListNotifications{Request,Response}:cursor` pair: +// `QueryAST` is a REQUEST surface — +// the client SDK builder's output and the `POST /data/:object/query` body +// — never stored in stack metadata, so there is no authored source or +// `sys_metadata` row for a D2 conversion to rewrite. The prescription +// reaches consumers as the D3 semantic entry +// `aggregation-node-distinct-retired` plus this tombstone, which is the +// disposition every other `data.query.*` retirement in this major already +// takes (`query-joins-retired` / `query-cursor-retired` / +// `query-distinct-retired` / `query-window-functions-retired`, #4286). +export const entry = 'data/AggregationNode:distinct'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__DataEngineUpdateOptions__upsert.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__DataEngineUpdateOptions__upsert.ts new file mode 100644 index 00000000000..9ceab8374d4 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__DataEngineUpdateOptions__upsert.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8057 — the deprecated legacy update-options schema re-declared the +// never-implemented `upsert` flag, so it is tombstoned with the SAME +// prescription as `data/EngineUpdateOptions:upsert` (one string, both +// schemas plus the engine's unknown-option gate): a capability is never +// half-deleted. See that entry's comment for the full disposition; no D2 +// conversion, since an engine option bag is call-time only. +export const entry = 'data/DataEngineUpdateOptions:upsert'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__EngineUpdateOptions__upsert.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__EngineUpdateOptions__upsert.ts new file mode 100644 index 00000000000..915d524e6b5 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__EngineUpdateOptions__upsert.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #8057 — the never-implemented upsert flag on `engine.update()`'s option +// surface, tombstoned on BOTH update-options schemas (this one and the +// deprecated `DataEngineUpdateOptions` sibling) with one prescription: +// `ENGINE_UPDATE_UPSERT_REMOVED` in `data/data-engine.zod.ts`, which the +// objectql engine's unknown-option gate quotes too. Declared-but-unenforced +// (ADR-0049): the key sat on the engine's update allowlist while no code path +// read it, so `{ upsert: true }` was accepted and silently dropped. +// +// Registered here but NOT in `src/conversions/registry.ts`, and that asymmetry +// is the point rather than an omission: a D2 conversion rewrites an authored +// source or a stored `sys_metadata` row, and an engine option bag is call-time +// only — nobody authors one and nothing persists one. The prescription reaches +// consumers as the D3 semantic entry `engine-update-upsert-retired` plus this +// tombstone (the `BatchOptions.validateOnly` / `ListNotificationsRequest:cursor` +// disposition). +export const entry = 'data/EngineUpdateOptions:upsert'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts new file mode 100644 index 00000000000..e6cf3b2e679 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7990 — sibling of `data/PostgresConfig:password`, same ruling, same +// disposition: tombstoned inline credential; the secret binder / +// `external.credentialsRef` is the mechanism. See that entry for the reasoning +// and the D3 semantic entry `datasource-config-inline-credential-refused` for +// the hand-migration prescription. +export const entry = 'data/MongoConfig:password'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts new file mode 100644 index 00000000000..bc872b8bf56 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7990 — sibling of `data/PostgresConfig:password`, same ruling, same +// disposition: tombstoned inline credential; the secret binder / +// `external.credentialsRef` is the mechanism. See that entry for the reasoning +// and the D3 semantic entry `datasource-config-inline-credential-refused` for +// the hand-migration prescription. +export const entry = 'data/MysqlConfig:password'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts new file mode 100644 index 00000000000..1d6888a4f7c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7990 (maintainer-ruled Option A, 2026-08-12) — inline datasource credentials +// refused at publish. The key is tombstoned (`z.never()` with the refusal +// prescription), not deleted: `sys_metadata` serves rows through the ordinary +// data API, so an inline `config.password` was cleartext at rest. The secret +// belongs to the datasource secret binder (`sys_secret` + +// `external.credentialsRef`), which already wins over an inline value at +// connect time. No D2 conversion: a stored cleartext credential cannot be +// mechanically rewritten into an encrypted `sys_secret` row at load — see the +// D3 semantic entry `datasource-config-inline-credential-refused`. +export const entry = 'data/PostgresConfig:password'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts new file mode 100644 index 00000000000..bde6b4d12d4 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7990 — the turso face of `data/PostgresConfig:password` (the credential key +// is `authToken`, a JWT, rather than a password — same inline-cleartext class, +// same ruling, same disposition). The standalone boot path is untouched: +// `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` are resolved by the host and +// handed to the driver factory directly, never through the authoring schema. +// See the D3 semantic entry `datasource-config-inline-credential-refused`. +export const entry = 'data/TursoConfig:authToken'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts b/packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts new file mode 100644 index 00000000000..19556cac0b3 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.integration__ConnectorFieldMapping__transform.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'integration/ConnectorFieldMapping:transform'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts b/packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts new file mode 100644 index 00000000000..193b153a54c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.kernel__Manifest__loading.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #4914 — ADR-0049 enforce-or-remove on the plugin manifest's whole +// `loading` block (maintainer ruling 2026-08-04). ONE tombstoned key here, +// because `loading` was the single carrier: every schema underneath it +// (`PluginLoadingConfig` and the ten members it combined) leaves the +// published set as a whole-def removal and is registered in +// `RETIRED_DEFS_BY_MAJOR` below, not as ~27 individual key entries. +// +// Registered here but NOT in `src/conversions/registry.ts`, for the reason +// `automation/ActionDescriptor:isAsync` gives: the conversion chain +// walks a normalized STACK (`mapCollection(stack, 'objects' | 'views' | …)`) +// and `applyConversionsToStoredItem` maps a metadata type onto one of those +// collections. A package manifest is neither — there is no `packages` / +// `plugins` entry in `PLURAL_TO_SINGULAR`, so a manifest is not a stack +// collection member and a stored manifest row passes that seam through +// unchanged. A MetadataConversion here would be a transform with no seam +// that ever runs. The prescription reaches authors instead through the +// tombstone at the one place a manifest is parsed with an author present +// (`os plugin build` → `ManifestSchema.safeParse`, which exits non-zero), +// and through the D3 semantic entry `plugin-manifest-loading-retired`. +export const entry = 'kernel/Manifest:loading'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts b/packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts new file mode 100644 index 00000000000..2163775b2bd --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.shared__FieldMapping__transform.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'shared/FieldMapping:transform'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts new file mode 100644 index 00000000000..4a1a70e60da --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__displayField.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #5775 — the SDUI component-props reconciliation. Three keys on the record +// picker (`displayField` was the REQUIRED one, and the synonym of the key +// the renderer actually reads) and the card's second spelling of the +// composition slot every other container calls `children`. +export const entry = 'ui/ElementRecordPickerProps:displayField'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts new file mode 100644 index 00000000000..bd1b691ba05 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__multiple.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/ElementRecordPickerProps:multiple'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts new file mode 100644 index 00000000000..8a29d1eeff2 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ElementRecordPickerProps__searchFields.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/ElementRecordPickerProps:searchFields'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__bordered.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__bordered.ts new file mode 100644 index 00000000000..b9ed373fb19 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__bordered.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7176 — pass-through-only (ADR-0049 enforce-or-remove, maintainer ruling +// 2026-08-10): every measured reader copied the key forward and the chain ends +// at ObjectGrid, which never spells it; the grid frame is the renderer's own +// constant (`borderless`), never this key. Distinct from the LIVE +// `ui/PageCardProps:bordered`, a different surface on the page-card container. +// Conversion `view-list-passthrough-keys-removed` strips it from sources. +export const entry = 'ui/ListView:bordered'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts new file mode 100644 index 00000000000..e15969b6ac7 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7176 — pass-through-only (ADR-0049 enforce-or-remove, maintainer ruling +// 2026-08-10): every measured reader (react spec-bridge, plugin-list, +// plugin-view/app-shell) copied the key forward and the chain ends at +// ObjectGrid, which never spells it — copy-without-apply is dead in effect. +// Conversion `view-list-passthrough-keys-removed` strips it from sources. +export const entry = 'ui/ListView:striped'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__virtualScroll.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__virtualScroll.ts new file mode 100644 index 00000000000..6a9d18d3da6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__virtualScroll.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7176 — pass-through-only (ADR-0049 enforce-or-remove, maintainer ruling +// 2026-08-10): every measured reader copied the key forward and the chain ends +// at ObjectGrid, which never spells it. objectui's VirtualGrid genuinely +// virtualizes but is only exported, never instantiated by ObjectGrid, and its +// props carry no `virtualScroll` member — so no grid ever virtualized off this +// key. Conversion `view-list-passthrough-keys-removed` strips it from sources. +export const entry = 'ui/ListView:virtualScroll'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__bordered.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__bordered.ts new file mode 100644 index 00000000000..9088e5a8156 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__bordered.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7176 — the `ObjectListViewSchema` copy of `ui/ListView:bordered`: the def is +// `ListViewSchema.omit({userFilters}).extend(…)`, so the tombstone lands in +// this walked shape too and `authorable-surface/` marks it `[RETIRED]` +// separately. Registered per key, as gate (b) reads them — nothing radiates +// from the base (the `shared/FieldMapping:transform` precedent). +export const entry = 'ui/ObjectListView:bordered'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__striped.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__striped.ts new file mode 100644 index 00000000000..17180cbc8aa --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__striped.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7176 — the `ObjectListViewSchema` copy of `ui/ListView:striped`: the def is +// `ListViewSchema.omit({userFilters}).extend(…)`, so the tombstone lands in +// this walked shape too and `authorable-surface/` marks it `[RETIRED]` +// separately. Registered per key, as gate (b) reads them — nothing radiates +// from the base (the `shared/FieldMapping:transform` precedent). +export const entry = 'ui/ObjectListView:striped'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__virtualScroll.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__virtualScroll.ts new file mode 100644 index 00000000000..d6d046294ef --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__ObjectListView__virtualScroll.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7176 — the `ObjectListViewSchema` copy of `ui/ListView:virtualScroll`: the +// def is `ListViewSchema.omit({userFilters}).extend(…)`, so the tombstone lands +// in this walked shape too and `authorable-surface/` marks it `[RETIRED]` +// separately. Registered per key, as gate (b) reads them — nothing radiates +// from the base (the `shared/FieldMapping:transform` precedent). +export const entry = 'ui/ObjectListView:virtualScroll'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts new file mode 100644 index 00000000000..7f2e4136b87 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__actions.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #6946 — three SDUI page-component props, retired by maintainer ruling +// 2026-08-09 (decision-inbox round, 「全部接受」): objectui#3829 route (c) +// for the first two, objectui#3818 for the third. Registered per key, as +// gate (b) reads them — nothing radiates from a neighbouring key, and this +// family needs that literally: `ui/PageHeaderProps:actions` and +// `ui/RecordHighlightsProps:layout` are LIVE keys sharing these leaf names. +// +// The first two are the B class — declared here, read NOWHERE in objectui +// (the header resolves icons per action; the card renders +// title/bordered/children/footer and has no actions area), and carried in +// that repo's own `UNPUBLISHED_EXEMPTIONS` map as exactly that. +export const entry = 'ui/PageCardProps:actions'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts new file mode 100644 index 00000000000..05f0bceeaad --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageCardProps__body.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/PageCardProps:body'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts new file mode 100644 index 00000000000..d89b8880116 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageHeaderProps__icon.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +export const entry = 'ui/PageHeaderProps:icon'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts new file mode 100644 index 00000000000..0f68b5c6afc --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__PageTabsProps__type.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #6776 — #5775's count was incomplete. The tab strip's visual style is the +// one prop whose declared spelling collides with the page component's own +// dispatch key, so `type` could never be authored in a flat or JSX carrier +// and was skipped unvalidated by `sdui-parser`'s `BASE_PROPS`. Renamed to +// the `tabStyle` every carrier can express and the renderer already reads. +export const entry = 'ui/PageTabsProps:type'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts b/packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts new file mode 100644 index 00000000000..aff9ae5215f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.ui__RecordDetailsProps__layout.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The third is a sharper shape: `layout` IS read, but only against +// `inline`/`compact` — values its `auto | custom` enum never permitted — so +// both legal values took the same branch. Declared on BOTH sides with the +// same enum, which is why the declaration-parity ratchet (two declarations, +// never a declaration vs an implementation) reported agreement over it. +export const entry = 'ui/RecordDetailsProps:layout'; diff --git a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts new file mode 100644 index 00000000000..ef68073a0d8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-is-async-retired.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'action-descriptor-is-async-retired', + surface: 'ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)', + replacement: + 'nothing to re-declare — delete the key. Suspension is `execute()` RETURNING ' + + '`suspend: true`, and permission to suspend is `supportsPause: true` on the same ' + + 'descriptor (with the `resumeAuthority` its pauses need)', + reason: + 'ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow ' + + 'awaiting an external reply" and NOTHING read it: a fresh three-repo measurement ' + + '(taken when the key was filed for retirement, and re-run at pickup) found zero ' + + 'property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of ' + + 'five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. ' + + 'So declaring it never made a node suspend and omitting it never stopped one, ' + + 'which is the silently-inert declaration ADR-0049 exists to end. It was always a ' + + 'second, weaker spelling of the capability `supportsPause` states, and the two ' + + 'diverged in exactly the way a duplicated declaration does: `screen` declared ' + + 'both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing ' + + 'anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — ' + + '`AutomationEngine` now refuses a suspension whose type does not declare ' + + '`supportsPause: true` — so the capability this key gestured at is now a real, ' + + 'enforced fact under one name. This one had no consumer to grow into and takes ' + + 'the remove leg. ' + + 'Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from ' + + "an executor's TypeScript, never stored in stack metadata — no stack, example or " + + 'template carries the key — so there is no source for the chain to rewrite and ' + + '`os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` ' + + 'and descriptor authors delete the key themselves; that rejection (a `tsc` error ' + + 'at the authoring site, and a parse error inside `defineActionDescriptor`) is the ' + + 'channel a third-party plugin author actually meets. The ' + + '`EnhancedApiError.fieldErrors` disposition, one layer down.', + acceptanceCriteria: + 'No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, ' + + '`wait`, `approval`, `approval_revise`), not a plugin\'s. Every node type that ' + + 'returns `suspend: true` from `execute()` declares `supportsPause: true` on its ' + + 'descriptor together with a `resumeAuthority`, and its runs still pause and resume ' + + 'as before: the behaviour never depended on `isAsync`, so deleting the key changes ' + + 'no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails ' + + '`defineActionDescriptor()` at runtime with the prescription, instead of parsing ' + + 'clean and being stripped.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts new file mode 100644 index 00000000000..221a3159fdf --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.action-descriptor-resume-authority-default-flip.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'action-descriptor-resume-authority-default-flip', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). + surface: + 'automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing ' + + 'node descriptor (supportsPause: true, or any executor whose execute() returns ' + + 'suspend: true)', + replacement: + "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose " + + 'pauses really are meant to be continued through the generic resume route ' + + '(POST /automation/:name/runs/:runId/resume) — a screen-style collected-input ' + + "pause, or a signal wait an external producer resumes. Declare 'service' instead " + + 'if continuing is the tail of a decision your own service must authorize and ' + + 'record first. Either value is a one-line addition; only the silence changed ' + + 'meaning', + reason: + 'A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as ' + + "protocol 12's `rest-requireauth-default-flip`, and it is registered here for the " + + 'same reason: whether a given pause is genuinely open to the generic route is a ' + + 'trust judgment no transform can make. The generic resume route\'s authorization gate ' + + 'keys on the SUSPENDED ' + + "NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a " + + 'pausing node type shipped raw-resumable unless its author remembered the field. ' + + "It now resolves to `'service'` when absent: an unclaimed pause is refused on the " + + 'generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may ' + + 'continue it. The revise-window incident decided the direction — ADR-0044 pointed ' + + "an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and " + + 'the pause standing in a service-owned position inherited a fail-open value nobody ' + + 'chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote ' + + 'run. The two possible mistakes are asymmetric, which is the whole argument: ' + + "guessing `'any'` walks past a decision nothing recorded and is silent, while " + + "guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface " + + 'is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no ' + + 'source for a D2 conversion to rewrite and deliberately no schema tombstone — the ' + + 'disposition `data-driver-find-stream-retired`, `storage-service-list-retired` ' + + 'and `actor-user-roles-to-positions` already carry. It differs from ' + + 'those in one way a reader should not have to infer: nothing is REMOVED, so tsc ' + + 'reports nothing at all — the field was already optional after step one and an ' + + 'omission still compiles. The enforced channels are all run-time: a registration ' + + 'warning naming the node type (once per type per engine), the refusal message on ' + + 'the resume itself, and `check:resume-authority-declared` for executors living in ' + + 'this repo. For a third-party plugin the generated upgrade guide is the only ' + + 'channel that arrives BEFORE a user hits a run that will not continue. In-tree the ' + + 'flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, ' + + 'approval, approval_revise) declare their authority explicitly. ADR-0044 amendment ' + + '(2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam ' + + 'addendum to ADR-0019 (approval as a flow node).', + acceptanceCriteria: + 'Every action descriptor your plugin registers for a node type that can suspend ' + + 'declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but ' + + 'never declares resumeAuthority` warning naming one of your types, and a run parked ' + + 'on each of your pausing nodes can still be continued the way you intend: a resume ' + + "through the generic route succeeds for the ones you declared `'any'`, and answers " + + "403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue " + + 'through your own service API instead. ⚠️ `supportsPause` is no longer the ' + + 'declaration nothing enforced: an executor whose ' + + '`execute()` returns `suspend: true` while leaving `supportsPause` false is still ' + + 'warned about by neither warning channel, but ' + + '`AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the ' + + 'one seam every suspension passes through — a guard-class failure no `fault` edge ' + + 'routes — so it needs no hand-check. The residue that does: an executor registering ' + + 'NO descriptor declares nothing for either warning or the refusal to read, so its ' + + 'pauses are still created and refused only later, on the resume route.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts new file mode 100644 index 00000000000..27b963603fc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.action-session-roles-to-positions.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'action-session-roles-to-positions', + surface: 'ui.actionSession.roles', + replacement: 'ui.actionSession.positions (an action body reads `ctx.session.positions`)', + reason: + 'The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this ' + + 'step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed ' + + 'outright), while the ACTION body\'s `ctx.session` carries it ' + + 'produced-and-really-populated. `buildActionSession()` ' + + '(`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` ' + + 'into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under ' + + 'the one spelling that ADR bans — so a body author met two different answers to one ' + + 'key name on one platform: rejected in a hook, live and full of values in an action. ' + + 'The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare ' + + 'the shape as it stands first, then rename on the typed face): phase 1 declared the ' + + 'previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. ' + + '`positions` is now the canonical key on that schema and `roles` a deprecated alias of ' + + 'it; the producer emits both for one deprecation window (the runtime half of the same ' + + 'ruling), after which `roles` is removed on the path the v16 session-alias removal ' + + 'already walked (the hook session\'s `tenantId` alias: deprecated first, removed in the ' + + 'next major). ' + + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: ' + + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per ' + + 'dispatch and never persisted, so no `sys_metadata` row, example or template can ' + + 'carry the key — the `openApi31` / `activationEvents` / ' + + '`hook-context-session-roles-retired` shape. SECOND, the only place the key ' + + 'is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed ' + + 'script whose `ScriptContext.session` is still `unknown`. A declarative transform ' + + 'cannot safely rewrite an identifier inside free-form code — exactly the reason the ' + + 'ADR-0090 wave delegated `current_user.roles` to the author at step 13 ' + + '(`cel-current-user-roles-to-positions`) instead of substituting text. ' + + 'Note what is deliberately NOT done here: the alias is not tombstoned. A ' + + '`retiredKey()` REJECTS the key, and a deprecation window exists precisely so the ' + + 'old spelling keeps working while its readers move — tombstoning during the window ' + + 'would be the removal it is meant to defer. The tombstone (or the plain deletion the ' + + 'authorable-surface ratchet adjudicates) belongs to the release that closes the ' + + 'window. Until then this entry IS the channel: `spec-changes.json` and the generated ' + + 'upgrade guide are how a reader learns the rename before the removal reaches them. ' + + 'ADR-0090 D3, ADR-0087.', + acceptanceCriteria: + 'No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` ' + + 'and observes the same array (the rename is a rename — the VALUE is ' + + '`ExecutionContext.positions` on both sides, which the runtime pin ' + + '`action-session-shape-contract.test.ts` asserts independently of the key name). ' + + 'Privilege is NOT re-derived from either spelling: a read that was ' + + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security ' + + 'service (capability grants / placements / derived posture, ADR-0095), never ' + + 'renamed to `positions.includes(\'admin\')` — renaming that read migrates the defect ' + + 'rather than the code. Verify against a real dispatch, not a fixture: invoke an ' + + 'action as a caller holding positions and assert the body observed them under the ' + + 'canonical key. During the window both keys are present and equal, so a reader can ' + + 'be migrated and verified before the alias is removed; after it, `roles` is absent ' + + 'and a body still reading it sees `undefined` — which is why the read must be moved ' + + 'inside the window rather than at its close.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts b/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts new file mode 100644 index 00000000000..9695095460b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.actor-user-roles-to-positions.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'actor-user-roles-to-positions', + surface: 'action body / AI route: ctx.user.roles (req.user.roles)', + replacement: + 'ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, ' + + 'under the one spelling ADR-0090 D3 sanctions', + reason: + 'The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose ' + + 'surface the spec never declared. `ActorUser` ' + + '(`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` ' + + 'envelope handed to an action body as `ctx.user` and to an AI route handler as ' + + '`req.user`; it declared `positions` and `roles` side by side and filled them from a ' + + 'SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical ' + + 'on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, ' + + 'published straight into author-written code. The maintainer ruled it closed IMMEDIATELY ' + + '(2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation ' + + 'window, no dual-emit, the alias simply gone in 17. ' + + '⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: ' + + '`action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached ' + + 'through the same `ctx`, and that one KEEPS its one-window dual-emit. Same word, ' + + 'same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while ' + + '`ctx.session.roles` still answers for the length of its window. ' + + 'What makes this entry different in KIND from both session-side siblings: `ctx.user` has ' + + 'no spec schema and never had one. It is a runtime TS interface, so unlike ' + + '`HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema` ' + + 'once it had no producer and no consumer left) and unlike `ActionSessionSchema` (declared ' + + 'contract-first, as it stood, as the first stage of the session rename, precisely so its ' + + 'key could be renamed), there is no schema key here to tombstone and no ' + + '`retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` ' + + 'through a `.parse()`, so a prescription there would have no one to reach. The enforced ' + + 'channel is tsc, and it reports at the READ site inside the author\'s own body; for an ' + + 'untyped or sandboxed body there is no enforced channel at all, which is exactly why ' + + 'this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide ' + + 'are the ONLY way such a reader learns of the rename. It is the `findStream` (no caller) / ' + + '`IStorageService.list` (no consumer) disposition — a TS/API contract, no stored source, no ' + + 'tombstone, tsc at the call site — applied to a surface that lives one layer further ' + + 'out than either: those two are at least DECLARED in `packages/spec/src/contracts`, ' + + 'this one only in `packages/runtime`. ' + + 'Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent ' + + 'grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` ' + + 'is constructed per dispatch and never persisted, so no `sys_metadata` row, example or ' + + 'template can carry the key (the `openApi31` / `activationEvents` / ' + + '`hook-context-session-roles-retired` shape). SECOND, the only place the key is ' + + 'ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or ' + + 'a sandboxed script. A declarative transform cannot safely rewrite an identifier inside ' + + 'free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to ' + + 'the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting ' + + 'text. ' + + 'The removal\'s hard precondition was met before it landed, and the result is recorded ' + + 'here because the ledger is where an upgrading consumer meets it: the declaration\'s own ' + + 'comment claimed the alias was "kept for the REST/AI shapes", and that claim was ' + + 'DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all ' + + 'of them in the pins the removal flipped; the four `ActorUser` construction sites build ' + + 'server-side envelopes that never enter a response body; objectui\'s `.roles` reads ' + + 'belong to two unrelated producers (the better-auth session, and the ' + + '`/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and ' + + 'is the one consumer face left unverified — this entry, and the changeset\'s FROM/TO ' + + 'prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; ' + + 'every such read is `.positions` and observes the SAME array — the value was ' + + '`ExecutionContext.positions` on both sides, so this is a pure key rename and no value ' + + 'has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was ' + + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security service ' + + '(capability grants / placements / derived posture, ADR-0095), never renamed to ' + + '`positions.includes(\'admin\')` — renaming that read migrates the defect rather than the ' + + 'code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is ' + + 'already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed ' + + 'one silently sees `undefined` — move the read AS you upgrade, not after it. Verify ' + + 'against a real dispatch rather than a fixture: invoke an action (and an AI route) as a ' + + 'caller holding positions, assert the body observed them under the canonical key, and ' + + 'assert the old key is ABSENT by key existence (`\'roles\' in ctx.user === false`) rather ' + + 'than by `undefined`, which cannot tell a removed key from one left behind holding ' + + 'nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts b/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts new file mode 100644 index 00000000000..9588f665237 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.aggregation-node-distinct-retired.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'aggregation-node-distinct-retired', + surface: 'data.query.aggregations[].distinct', + replacement: + 'the `count_distinct` aggregation FUNCTION for a deduplicated count — the one ' + + 'deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on ' + + 'both SQL faces since it took the enforce leg of the 2026-08-07 ruling that kept it ' + + 'declared and retired `array_agg` / `string_agg`. ' + + '`SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no ' + + 'replacement: no backend ever computed them here, and a per-row measure that needs ' + + 'deduplicating before summing is a modelling problem to fix in the data', + reason: + 'A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the ' + + '`QueryAST` members no executor runs, the one that dispositioned every other ' + + '`data.query.*` member. That sweep asked which keys ' + + 'no executor reads; this one HAD an executor, exactly one out of six. The engine\'s ' + + 'in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the ' + + 'values before applying the function, while `SqlDriver.aggregate`, the Turso ' + + '`RemoteTransport.aggregate`, `driver-mongodb`\'s `buildAggregationStage`, ' + + '`driver-memory`\'s `computeAggregate` and service-analytics\' `AGGREGATE_SQL` all ' + + 'ignored the key. So `{ function: \'sum\', field: \'amount\', distinct: true }` ' + + 'answered a deduplicated sum when the engine fell back in memory and an ordinary sum ' + + 'on every SQL datasource: one query, two numbers, chosen by which backend happened ' + + 'to serve it — and unlike the divergences closed earlier on the same axis (an ' + + 'aggregate function name the remote Turso face compiled and the local face refused, and ' + + 'an unsupported function thrown as a bare error with no code on both SQL faces), the ' + + 'wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced ' + + 'it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — ' + + '`count` returned from its own branch before reaching the dedupe, `count_distinct` ' + + 'fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move ' + + '`min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): ' + + '`count_distinct` already covers the only spelling anyone has measured demand for, ' + + 'and lowering `SUM(DISTINCT …)` across five faces — two of them, driver-memory and ' + + 'driver-mongodb, then under the maintainer\'s 2026-08-05 investment freeze, a freeze ' + + 'lifted 2026-08-11, after this ruling — ' + + 'buys a shape that is near-universally a modelling mistake. A REQUEST surface — ' + + '`QueryAST` is the client SDK builder\'s output and the `POST /data/:object/query` ' + + 'body, never stored in stack metadata — so there is no source for the chain to ' + + 'rewrite and callers move their own queries: the disposition that sweep gave ' + + '`joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ' + + 'ADR-0049.', + acceptanceCriteria: + 'No caller sends `distinct` inside an `aggregations[]` entry, on the wire or through ' + + 'the SDK; a deduplicated count is written as `{ function: \'count_distinct\', field }` ' + + 'and reads the same number on every backend. A query still carrying the key fails to ' + + 'parse with the removal prescription — including through ' + + '`EngineAggregateOptionsSchema`, which reuses `AggregationNodeSchema` by reference — ' + + 'and `POST /api/v1/data/:object/query` answers `400 VALIDATION_FAILED` with a ' + + '`fields[]` entry at `aggregations..distinct` instead of serving a number. ' + + 'Authoring it is a `tsc` error at the call site. ⚠️ The observable NUMBERS change on ' + + 'exactly one path and that is the point of the change: a `sum`/`avg` that used to be ' + + 'deduplicated by the in-memory fallback now answers what every SQL face has always ' + + 'answered for the same query. Verify against the SQL answer, not against the ' + + 'pre-upgrade fallback answer — the two disagreed, which is why the key is gone.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts new file mode 100644 index 00000000000..2b7cd6a578a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-envelope-retired.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'analytics-query-request-envelope-retired', + surface: 'api.analyticsQueryRequest.query', + replacement: 'bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)', + reason: + 'The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded ' + + 'analytics shim (the fallback that answered /analytics/query when no analytics service ' + + 'was installed, and dropped the caller\'s identity and its `where` filter at the door), ' + + 'never stored in stack metadata — there is no source for the ' + + 'chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the ' + + 'query.* fields to the body top level themselves.', + acceptanceCriteria: + 'Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and ' + + 'succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts new file mode 100644 index 00000000000..50ac24ea568 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.analytics-query-request-format-retired.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'analytics-query-request-format-retired', + surface: 'api.analyticsQueryRequest.format', + replacement: '(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)', + reason: + 'The `format` key was declared but never implemented (declared ≠ enforced): every ' + + 'response is the JSON envelope regardless of the requested value, so there is no ' + + 'behaviour to preserve and nothing stored to rewrite.', + acceptanceCriteria: + 'No /analytics/query or /analytics/sql call sends `format`; exports go through the ' + + 'export surface.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts b/packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts new file mode 100644 index 00000000000..a5e0b0c613c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.api-runtime-create-withdrawn.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'api-runtime-create-withdrawn', + surface: 'PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)', + replacement: + 'Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) ' + + 'and ship it through `publishPackage`', + reason: + 'The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never ' + + 'honoured it. Measured on a real showcase boot: `PUT /api/v1/meta/api/' + + 'e8_backdoor` answered 200 with `{"success":true,…,"message":"Saved …"}`, and the ' + + 'declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` ' + + 'line, because the endpoint was never in the index to be excluded from. The serving ' + + 'criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> ' + + "`MetadataManager.listForIndex('api')`, which reads the manager's registry plus its " + + 'registered loaders (`["filesystem","memory"]` on dev/serve); a runtime write lands ' + + 'in `sys_metadata`, which is in neither. A declared capability the runtime does not ' + + 'honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s ' + + 'forever is its most dangerous shape for the AI authors ADR-0033 targets. The ' + + 'maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because ' + + 'making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and ' + + "the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no " + + 'business pull for Studio-authored endpoints today (zero `.api.*` artifacts author ' + + 'them at runtime; showcase uses the artifact route, and its declared endpoints serve ' + + 'live). ' + + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored ' + + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an ' + + 'authorable one, and the artifact route it points authors toward is untouched — a ' + + '`**/*.api.ts` file valid before this change is valid after it, byte for byte. What ' + + 'changed is a runtime HTTP verdict, so it is one semantic TODO for operators and ' + + 'Studio callers rather than a stack conversion — the same disposition ' + + '`BatchOptions.validateOnly` takes. Consequently `gateApiDraftsForPublish` ' + + 'is retired with it: it gated a promotion into a state the matcher can ' + + 'never read, and with the inlet closed no `api` draft can exist for it to judge. ' + + 'Re-entry is recorded in the ruling: if the Studio metadata-coverage work promotes `apis` ' + + 'to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — ' + + 'implementation first, ' + + 'declaration second. The same refusal closes the direct-active write too, which had been a ' + + 'third path past the endpoint namespace and duplicate-path gates. ADR-0049 / ADR-0121.', + acceptanceCriteria: + 'No caller creates or updates an `api` item through the runtime metadata API. ' + + '`PUT /api/v1/meta/api/{name}` answers 403 with `code: "NOT_CREATABLE"` and a body ' + + 'naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the ' + + 'prescription `Declare it in source (**/*.api.ts) and redeploy` — in `?mode=draft` ' + + 'as well as direct-active, because the gate runs before the draft/publish branch and ' + + 'does not read `mode`. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole ' + + 'point of the change: a stack declaring `apis:` still compiles, still passes ' + + '`validateApiEndpointDeclarations` at publish (`publishPackage`) and at load ' + + '(`buildEndpointIndex`), and its endpoints still SERVE — that route was ' + + 'always the only one that served. An operator who genuinely needs the runtime door ' + + 'back on one deployment sets `OS_METADATA_WRITABLE=api`, the same single escape ' + + 'hatch `job` / `agent` / `capability` use; note that this unlocks the WRITE only, and ' + + 'the endpoint still will not be served, which is why it is a diagnostic and not a ' + + 'workaround. Any `api` rows already sitting in `sys_metadata` from before this change ' + + 'were never served either; they can be deleted (`deleteMetaItem` is deliberately not ' + + 'gated by this refusal, so repair stays possible).', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts b/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts new file mode 100644 index 00000000000..4d0b9f60433 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.apimethod-enum-shrink.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'apimethod-enum-shrink', + surface: 'data.object.enable.apiMethods (the eight legacy non-primitive values)', + replacement: + 'the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: ' + + 'replace each legacy value with the primitives it derives from, de-duplicate, and ' + + 'delete the key entirely if the result names all six', + reason: + 'The authored `enable.apiMethods` enum is now exactly the six primitives. The eight ' + + 'legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, ' + + '`import`, `export` — are no longer authorable, because they are DERIVED effective ' + + 'operations resolved by the server\'s single derivation table, and an enum that lets an ' + + 'author name both a primitive and something derived from it has two spellings for one ' + + 'fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; ' + + '`import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; ' + + '`history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, ' + + 'because `enable.trash` was retired with the other dead `enable.*` flags in the 11.0 ' + + 'ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That ' + + 'last row is why this is a semantic entry and not a mechanical conversion, and the ' + + 'reason is a security one: the mapping WIDENS. An allowlist naming `history` was ' + + 'granting read of one record\'s audit trail; rewritten to `get` it grants ordinary ' + + 'record reads, and an allowlist naming `search` becomes a grant of full `list`. A ' + + 'transform that applied the table silently would broaden real API permissions without ' + + 'anyone reading the diff, so the rewrite is delegated to the author with the widening ' + + 'flagged. The reporter codemod exists for exactly that shape: `node ' + + 'scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact ' + + 'replacement per site, and FLAGS the allowlists the mapping would widen so the edit ' + + 'stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing ' + + '(permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what ' + + 'an author may newly write. Registered late, by the stock reconciliation that compared ' + + 'the breaking changesets already on the v17 release train against this ledger: the enum ' + + 'shrink (phase 2 of the programme that made UI action buttons agree with the ' + + '`apiMethods` allowlist) predates the gate that makes a breaking changeset state its ' + + 'ledger disposition. ADR-0087.', + acceptanceCriteria: + 'No authored `enable.apiMethods` array names a legacy value; `objectstack validate` ' + + 'passes. Run the reporter codemod first and read its widening flags before applying ' + + 'anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For ' + + 'every object where `history` became `get` or `search` became `list`, confirm the ' + + 'broader operation is one the API should genuinely expose; where it is not, the answer ' + + 'is not a different value in this enum but a permission set that withholds the ' + + 'operation. Where the six primitives are all present, prefer deleting the key: that is ' + + 'equivalent to default-open and it tracks future primitives, whereas a hand-listed six ' + + 'silently stops granting anything added later. `restore` / `purge` are deleted with no ' + + 'replacement — if trash-like behaviour was being relied on, that capability left in the ' + + '11.0 dead-property removal and this entry is not where it returns.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts b/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts new file mode 100644 index 00000000000..80d278366a7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.approval-escalation-enabled-default-flip.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'approval-escalation-enabled-default-flip', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). + surface: + 'automation.ApprovalEscalation.enabled — an OMITTED value inside an approval ' + + "node's escalation block", + replacement: + 'nothing, for the common intent (escalate on timeout): an escalation block ' + + 'carrying timeoutHours is live by default. To declare an SLA OFF while keeping ' + + 'its configuration, write enabled: false explicitly — which is now the spelling ' + + 'the escalation sweep actually reads', + reason: + 'A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real ' + + "(maintainer ruling 2026-08-27, which moved the declared default to what the sweep had " + + "always done) — the same category as protocol 17's " + + '`import-run-automations-declared-default-corrected`: the schema promised ' + + '`enabled` defaults to `false` (SLA off) while the plugin-approvals sweep never ' + + 'read the key at all — any escalation block with a positive `timeoutHours` ' + + 'escalated, and with `action: \'auto_approve\'` that silently approved requests ' + + 'their author had declared off the clock. The flip moves the default to `true` ' + + 'and, in the same change, the sweep starts honouring an explicit ' + + '`enabled: false`. The feature-level switch is whether an `escalation` block ' + + 'exists at all; within a block carrying `timeoutHours`, escalation is on unless ' + + 'explicitly turned off. Deployed metadata that OMITS `enabled` does not change ' + + 'behaviour: it escalated before (the sweep ignored the key) and escalates after ' + + '(the parse materializes `true`). Stored request snapshots written before the ' + + 'flip carry a MATERIALIZED `enabled: false` (the approval-node executor parses ' + + 'config through the old schema before snapshotting), so the sweep keeps a ' + + 'read-side legacy window keyed on the snapshot\'s `created_at`: pre-flip ' + + 'snapshots keep escalating exactly as they do today, and the window retires ' + + 'itself as those pending requests drain. What DOES change is that an explicit ' + + '`enabled: false` finally binds — a flow that authored it (e.g. the console ' + + 'toggle switched off after a timeout was set) stops escalating on requests ' + + 'opened after the upgrade, which is the declared intent being honoured.', + acceptanceCriteria: + 'A flow whose approval node omits `enabled` inside `escalation` still escalates ' + + 'on timeout (no metadata edit needed). A flow that writes `enabled: false` ' + + 'stops escalating for newly opened requests — verify one such request stays ' + + 'pending past its `timeoutHours` with no `escalate` audit row and no ' + + 'auto-decision. Requests opened BEFORE the upgrade keep their pre-upgrade ' + + 'behaviour (they escalate) regardless of the stored `enabled` bit. Clients ' + + 'that parse metadata through the published JSON Schema now materialize ' + + '`enabled: true` where they materialized `false`; a client that needs the SLA ' + + 'off must write it explicitly.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts new file mode 100644 index 00000000000..2444f9fe212 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-enum-retired.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'audit-log-action-enum-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + "sys_audit_log.action — the values 'export' and 'permission_change' left the select " + + 'enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/' + + 'sys-audit-log.object.ts). The same two values also left the shipped list-view ' + + "filters on that object: 'permission_change' from the auth_events view and 'export' " + + 'from the config_changes view', + replacement: + 'nothing, for either value — both are removed rather than renamed, because neither ' + + 'named an event this platform records. For permission changes, read the ordinary ' + + '`create` / `update` rows on the permission objects themselves: a grant or binding ' + + 'write is an ordinary record write and the generic audit writer already ledgers it, ' + + 'so a second semantically-duplicate row was never minted. For `export` there is no ' + + 'replacement and nothing is lost: no export feature ever wrote an audit row. A ' + + 'consumer filtering `sys_audit_log` on either value was reading an empty result set ' + + 'on every deployment, and still is — what changed is that the contract no longer ' + + 'promises otherwise', + reason: + 'Maintainer ruling 2026-08-12 on the audit log\'s writerless actions, the retirement half ' + + 'of a two-half verdict: the cheap writers get built (`login` / `logout` on the auth ' + + 'session hooks, `config_change` from the settings service) and the enum ' + + 'values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的' + + '过滤器是可见产品缺陷;审计面宁窄勿谎. ' + + 'The defect was false compliance on a COMPLIANCE surface, which is the sharpest form ' + + 'of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the ' + + 'platform captured permission changes and data exports, and the shipped list views ' + + 'and dashboard widgets showed them a filter and a tile for exactly those events. ' + + 'Both were permanently empty. Measured by enumerating every `sys_audit_log` writer ' + + 'in the repo — there are exactly two: plugin-audit`s generic hook writer, whose ' + + '`actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing ' + + 'else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or ' + + '`permission_change`. ' + + 'This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement ' + + 'in the two ways `hook-body-crypto-hash-removed`, ' + + '`dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` ' + + 'already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) ' + + 'and the four surface ratchets are expected to be byte-identical (no def changed). ' + + 'It differs from all three in being a SEMANTIC entry rather than a D2 conversion, ' + + 'and the reason is that there is no source to rewrite: `sys_audit_log` is a ' + + 'platform-owned, append-only object whose every field is `readonly: true`. Nobody ' + + 'authors an audit row and nobody authors this enum — the values appear only in rows ' + + 'the runtime writes and in queries consumers send. A conversion rewrites authored ' + + 'metadata or a stored `sys_metadata` row; this surface is neither, so the ' + + 'disposition is the one `BatchOptions.validateOnly` and the notification cursor ' + + 'already take in this major. ' + + '⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a ' + + 'row with either value keeps it, and keeps reading it back: the enum is not enforced ' + + 'on this object at all (`validateRecord` skips `readonly` fields, and every field ' + + 'here is readonly), so nothing rejects stored history and no backfill is required or ' + + 'wanted. Deleting audit history to satisfy a schema narrowing would be the one ' + + 'genuinely destructive reading of this change. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No consumer filters `sys_audit_log` on `action = "export"` or ' + + '`action = "permission_change"` expecting rows: both were empty everywhere before ' + + 'this change, so a query that returned data has not been identified and a query that ' + + 'returned nothing behaves identically. Concretely, check three places. (1) Saved ' + + 'queries, dashboards and reports over `sys_audit_log`: a filter naming either value ' + + 'should be deleted, not re-pointed — for permission auditing, filter the permission ' + + 'objects` own `create`/`update` rows by `object_name` instead. (2) Any code branching ' + + 'on the action string (a badge map, a label switch, an `if (row.action === ...)`): ' + + 'the arms for these two values are now unreachable and should go, and a `switch` with ' + + 'an exhaustiveness check over the enum type will now fail to compile if they stay — ' + + 'that compile error is the enforced channel for TypeScript consumers. (3) Custom ' + + 'objects or plugins inserting `sys_audit_log` rows with either value: this is the ' + + 'only case that needs a real decision, because the write will NOT be refused ' + + '(readonly fields are not validated) — it will simply be a row whose action the ' + + 'object no longer declares. Pick a declared value or open an issue for the action ' + + 'you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is ' + + 'append-only and stays exactly as written.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts new file mode 100644 index 00000000000..b4e5902acee --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.audit-log-action-restore-retired.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The third and last value retired from `sys_audit_log.action` in this major, +// and a separate entry from `audit-log-action-enum-retired` on purpose: two +// cards registering DIFFERENT entries merge clean, two cards editing the SAME +// entry collide in git — which is the layout `entries/README.md` chose on a +// registry where a dropped entry produces no error anywhere. +export const entry: SemanticMigration = { + id: 'audit-log-action-restore-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + "sys_audit_log.action — the value 'restore' left the select enum declared by " + + 'plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). ' + + 'It also left the shipped writes_only list-view filter on that object, and the ' + + 'generated option label in all four plugin-audit translation bundles', + replacement: + 'nothing — the value is removed rather than renamed, because it never named an event ' + + 'this platform records. There is no undelete or restore capability to point at: ' + + 'deletes are hard deletes, and the record-level audit writer maps the ObjectQL ' + + 'lifecycle to `create` / `update` / `delete` only. A consumer filtering ' + + '`sys_audit_log` on this value was reading an empty result set on every deployment, ' + + 'and still is — what changed is that the contract no longer promises otherwise. If ' + + 'you were counting on a restore trail, the capability itself is the missing piece ' + + '(an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built ' + + 'yet), not this enum row', + reason: + 'The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one ' + + "value that ruling's own survey did not name (triage 2026-08-13). 原则记录:空 " + + 'widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. ' + + '`restore` is the least ambiguous member of the family: the record-level writer ' + + "could not have produced it even by accident, because `actionFor()` in " + + "audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller " + + 'early-returns on null. A tree-wide search finds no other producer. ' + + 'What made it a card rather than a tidy-up is that TWO shipped declarations ' + + 'asserted the opposite, so a declaration-reading audit scored the action as ' + + 'covered: the `writes_only` list view offered it as a filter value, and the module ' + + 'docblock of auth-event-audit.ts named it among the actions the writer emits. The ' + + 'comment is the ADR-0049 declared-≠-enforced shape in its purest form (the shape a ' + + 'credential-storage audit had to settle by re-measuring two "hashed at rest" comments) — a ' + + 'sentence next to a mechanism, contradicted by the type signature of that very ' + + 'mechanism, with nothing in CI able to tell. Both declarations are corrected in one ' + + 'change, and the invariant behind the comment (every declared action has a writer) ' + + 'now has a pin test under it rather than prose. ' + + 'Bookkeeping is identical to the sibling entry, for the same reasons: an ' + + 'enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY ' + + 'changed) and leaves the four surface ratchets byte-identical (no def changed), and ' + + 'it is a SEMANTIC entry rather than a D2 conversion because there is no source to ' + + 'rewrite — `sys_audit_log` is a platform-owned, append-only object whose every ' + + 'field is `readonly: true`, so nobody authors an audit row and nobody authors this ' + + 'enum. ' + + '⚠️ This is a statement about the WRITER, not a product stance against undelete. ' + + 'Soft delete/restore is parked, not rejected: the undelete / purge lifecycle and the ' + + 'recycle bin are both held open, not declined. If that capability lands, this value ' + + 'returns WITH its writer — the ' + + 'emission point, its tests, and the view that surfaces it — never as a bare enum ' + + 'row again. ' + + '⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: ' + + 'the enum is not enforced on this object at all (`validateRecord` skips `readonly` ' + + 'fields), so any stored row keeps parsing and reading back, and no backfill is ' + + 'required or wanted. Deleting audit history to satisfy a schema narrowing would be ' + + 'the one genuinely destructive reading of this change. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No consumer filters `sys_audit_log` on `action = "restore"` expecting rows: it was ' + + 'empty on every deployment before this change and behaves identically after it. ' + + 'Concretely, check three places. (1) Saved queries, dashboards and reports over ' + + '`sys_audit_log`: a filter naming `restore` should be deleted, not re-pointed — ' + + 'there is no action that carries the meaning, because the platform records no ' + + 'restore event. (2) Any code branching on the action string (a badge map, a label ' + + 'switch, an option list in an audit-log filter UI): the `restore` arm is ' + + 'unreachable and should go, and a `switch` with an exhaustiveness check over the ' + + 'enum type will now fail to compile if it stays — that compile error is the ' + + 'enforced channel for TypeScript consumers. An option in a FILTER dropdown is the ' + + 'user-visible half and matters most: it offers an operator a choice that returns ' + + 'nothing. (3) Custom objects or plugins inserting `sys_audit_log` rows with this ' + + 'value: the write will NOT be refused (readonly fields are not validated), so it ' + + 'silently becomes a row whose action the object no longer declares. Pick a declared ' + + 'value, or open an issue for the action you actually need. ⚠️ Do NOT migrate or ' + + 'delete existing rows: audit history is append-only and stays exactly as written.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts b/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts new file mode 100644 index 00000000000..4f22291b7ae --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.auth-config-unadvertised-reserved-features.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'auth-config-unadvertised-reserved-features', + surface: 'api.authConfig.features.passkeys / api.authConfig.features.magicLink', + replacement: '(removed — no replacement flag; the capabilities are not advertised)', + reason: + 'Both flags were served by `GET /api/v1/auth/config` from introduction and read by no ' + + 'client: no login UI anywhere renders a passkey or magic-link affordance off them, so ' + + 'the payload advertised two sign-in methods a user could never reach, and a deployer ' + + 'setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable ' + + 'effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove ' + + 'over keep-as-reserved, so that a deployer cannot flip a flag that does nothing ' + + 'anywhere). The two are not equally empty: nothing at all is wired behind ' + + '`passkeys`, whereas `magicLink`\'s better-auth endpoints are live and only their ' + + 'advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists ' + + 'an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema ' + + 'tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is ' + + 'conditional: both return to the payload in the change that ships the login UI ' + + '(flag-gated passkey and magic-link entry points, which objectui defers until the ' + + 'maintainer schedules them). ADR-0049.', + acceptanceCriteria: + 'No client reads `features.passkeys` or `features.magicLink` off `/api/v1/auth/config`; ' + + 'a client that gated UI on either now treats the capability as absent rather than ' + + 'reading `undefined` as false by accident, and constructing an `AuthFeaturesConfig` ' + + 'with either key fails to parse with its own prescription instead of being silently ' + + 'stripped. Magic-link deployments keep working: `plugins.magicLink` still mounts ' + + '`/api/v1/auth/magic-link/send` and `/magic-link/verify`, which a custom UI may call ' + + 'directly.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts b/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts new file mode 100644 index 00000000000..94b2cc96ff3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.authoring-schemas-strict-unknown-keys.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The protocol-17 leg of the #4001 unknown-key strictness wave, registered as ONE +// entry for the major rather than one per batch — the shape both existing +// precedents already take (`ui-schemas-strict-unknown-keys` at 15, +// `dashboard-widget-strict-unknown-keys` at 16), and the maintainer's ruling on +// #7630 (2026-08-12) against the two alternatives (eleven entries, or a +// per-surface grouping). An upgrader reads ONE prescription covering the whole +// wave; the per-batch trace lives here so an archaeologist can still walk it +// batch by batch. Batches folded into this entry, in landing order: +// +// 1. `unknown-key-strictness-tier-a` — #4001 Tier-A: `security/permission.zod.ts` +// (permission set / object / field / admin scope) and `automation/flow.zod.ts`'s four outer +// shapes (flow / node / edge / variable), plus the shared `strictUnknownKeyError` factory. +// 2. `unknown-key-strictness-step2` — #4001 step 2: `security/rls.zod.ts`, +// `security/sharing.zod.ts` (base + criteria + recipient), `identity/position.zod.ts`. +// 3. `strict-automation-control-flow-state-machine` — #4001 batch 10: five shapes in +// `automation/control-flow.zod.ts`, six in `automation/state-machine.zod.ts`. +// 4. `unknown-key-strictness-automation-batch11` — #4001 batch 11: flow's six NESTED blocks, +// `time-relative-trigger.zod.ts`, `flow-function.zod.ts`, `webhook.zod.ts`. +// 5. `unknown-key-strictness-ui-batch13` — #4001 batch 13: all four shapes in +// `ui/responsive.zod.ts` (strictness does not recurse, so `PageComponentSchema.strict()` +// had never reached them). +// 6. `unknown-key-strictness-ui-batch15` — #4001 batch 15: `ui/theme.zod.ts` 14/14, +// `ui/chart.zod.ts` 5/7 (two arms left open, measured `no gate`). +// 7. `unknown-key-strictness-ui-batch16` — #4001 batch 16: `AriaPropsSchema` +// (`ui/i18n.zod.ts`), carried as `aria:` on ~30 live shapes under six metadata roots. +// 8. `view-subblock-strictness-batch18` — #4001 batch 18: fifteen `ui/view.zod.ts` +// sub-blocks (view data arms, filter/gantt options, conditional formatting, empty state, +// subforms, submit behaviour). +// 9. `rare-jars-shave` — `ViewItemSchema` split into a strict +// authoring gate and a `.strip()` wire variant; `ViewFilterRuleSchema` and `ListView.sort[]` +// closed with it. +// 10. `user-filters-allow-add-tab-promote-and-close` — #5073: `userFilters.allowAddTab` promoted +// into the contract, `UserFiltersSchema` closed behind it, `ObjectUserFiltersSchema` +// rejecting the three page-only keys. +// 11. `view-union-identity-precondition` — #5599: the `view` write-path union stops +// matching every object — one level ABOVE the object schemas the ten batches closed. +// +// Two adjacent seams are deliberately NOT covered here and are recorded on #7630: the 45 `~` +// rows of the #6350 audit (a ledger file was touched by some commit, which does not prove the +// entry covers that face) and the 7 borderline candidates PR #7624 judged not-owed. +export const entry: SemanticMigration = { + id: 'authoring-schemas-strict-unknown-keys', + surface: + 'the protocol-17 authoring schemas closed against undeclared keys by the unknown-key ' + + 'strictness wave — `automation/` ' + + '(flow and its six nested blocks, control-flow, state-machine, webhook, time-relative ' + + 'trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and ' + + '`identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` ' + + 'sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition ' + + 'one level above them', + replacement: + 'declared keys only. Each rejection names the surface, echoes the offending key and — where ' + + 'the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a ' + + 'prescription where a rename would be wrong. A `view` body must additionally carry at ' + + 'least one key some union member declares, discounting the identity keys the write path ' + + 'stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)', + reason: + "zod's default `.strip` discarded any key these schemas did not declare and let the parse " + + 'SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata ' + + 'that quietly ignored what they wrote. Closing them turns that into a loud parse error ' + + '(ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly ' + + 'convertible for the same reason the two precedent entries at majors 15 and 16 are not: ' + + 'an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly ' + + 'the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix ' + + 'the typo, move it to the layer that owns it, or delete dead metadata. The named renames ' + + 'the errors carry are a help, not a transform: a large share of this wave is ' + + 'PRESCRIPTIONS rather than renames precisely because renaming would be wrong ' + + "(`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` " + + 'counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` ' + + 'bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint ' + + 'vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real ' + + 'on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on ' + + '`try_catch` and `context` on a state machine have no key at all). The eleventh member ' + + 'is not an unknown-key close but the same defect one level up — the `view` union had an ' + + 'arm that both stripped and required nothing, so it matched every object and `saveMetaItem` ' + + 'persisted garbage as an ACTIVE view overlay that read back badged valid. ' + + 'This is ONE entry for the whole major by the maintainer\'s 2026-08-12 ruling that the ' + + 'wave is registered one entry per major, not one per batch, mirroring the ' + + "registry's only two precedents of this shape; the eleven batches it folds are the " + + 'changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, ' + + '`-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, ' + + '`view-subblock-strictness-batch18`, `rare-jars-shave`, ' + + '`user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, ' + + 'each carrying its own FROM → TO table in `CHANGELOG.md`. One batch promoted a key before ' + + 'closing its block: `userFilters.allowAddTab` was already read by objectui, so the ' + + 'maintainer\'s 2026-08-04 ruling declared it in the spec rather than let a correct-looking ' + + 'refusal tell authors to delete a working capability. The entry was registered in the ' + + 'backfill of the v17 train\'s breaking changesets, which had never been compared against ' + + 'the ledger. ADR-0049 / ADR-0078 / ADR-0087.', + acceptanceCriteria: + '`objectstack validate` passes with no unknown-key parse errors on any authoring surface — ' + + 'the sweep is "fix until nothing raises", and every rejection carries its own fix. ' + + '⚠️ Parsing clean is the weaker half on three of these faces, because the key was being ' + + 'dropped rather than refused and a config that silently did nothing looked exactly like ' + + 'one that worked: re-check that responsive/theme/chart styling actually renders as ' + + 'authored, that every `aria` block still names the element it was written for, and that ' + + 'each state machine and loop config still carries the transitions and caps you declared. ' + + 'For stored `view` bodies, `GET /api/v1/meta/diagnostics?type=view` lists every overlay ' + + 'the identity precondition now rejects, one row per view with the reason; each is fixed by ' + + 'giving the body a real view shape or deleting an overlay that was never a view.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts b/packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts new file mode 100644 index 00000000000..134ac8eb039 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.batch-options-validate-only-retired.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'batch-options-validate-only-retired', + surface: 'api.batchOptions.validateOnly', + replacement: '(removed — no dry-run today; open an issue to design a no-commit batch preview)', + reason: + 'The `validateOnly` key promised a dry-run ("validate records without persisting") but no ' + + 'batch surface ever read it — updateManyData / deleteManyData / batchData persist ' + + 'regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ' + + 'ever appeared in an HTTP request body). Callers must stop sending it.', + acceptanceCriteria: + 'No /batch, /updateMany or /deleteMany call sends `options.validateOnly`; a request that ' + + 'includes it answers 400 VALIDATION_FAILED with the retirement prescription.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts b/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.ts new file mode 100644 index 00000000000..42b38f48cfc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.batch-row-result-schema-shape.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: 'batch-row-result-schema-shape', + surface: + 'api.batchOperationResult — the per-row `results` entries of BatchUpdateResponse ' + + '(`POST /data/:object/batch`, `/updateMany`, `/deleteMany`)', + replacement: + '`errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on ' + + '`row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row\'s position ' + + 'in the request array)', + reason: + 'The rows the three bulk-write endpoints emitted had drifted from the schema that ' + + 'declared them: `BatchOperationResultSchema`, the client SDK\'s exported ' + + '`BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / ' + + '`data` / `index`, while the wire carried `error: string` / `record` and never sent ' + + '`index` at all. A TypeScript consumer written against the published type compiled, ' + + 'validated and read `undefined` at runtime — the declared-but-not-delivered shape this ' + + 'registry exists to close, on the response envelope (ADR-0119 D4 deferred the ' + + 'reconciliation off a bug fix; this is that tracked change, shipped in the 17 major ' + + 'window). The ADR-0119 rollback marking, which the fix making `deleteManyData` and ' + + '`updateManyData` honour `atomic` carried to those two endpoints, is structured in the ' + + 'same move: the ' + + '`ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered ' + + '`ApiError.code` values (message keeps the human-readable cause and causal row index), ' + + 'so "attempted and undone" vs "never ran" is machine-readable instead of a regex ' + + 'convention. A RESPONSE surface — nothing stored in stack metadata carries a batch ' + + 'row, so there is no source for the chain to rewrite; consumers of the legacy keys ' + + 'move their reads themselves. Off-contract readers only: the legacy keys were never ' + + 'in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: ' + + 'the implementation moves to the schema\'s shape as a hard cut in the 17 major, with no ' + + 'dual-emit transition.', + acceptanceCriteria: + 'No consumer reads `row.error` or `row.record` on a batch result row; failures are read ' + + 'from `row.errors` (message via `errors[0].message`, rollback state via ' + + '`errors[0].code` — ROLLED_BACK / NOT_ATTEMPTED), records from `row.data`, and rows ' + + 'correlate to the request via `row.index`. Every row the three endpoints emit parses ' + + 'under `BatchOperationResultSchema` with those keys present.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts b/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts new file mode 100644 index 00000000000..a5306301aff --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.client-delete-result-success.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'client-delete-result-success', + surface: 'client.DeleteDataResult.deleted (the return of `client.data.delete()`)', + replacement: '`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name', + reason: + '`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a ' + + 'declaration that contradicted it: the interface declared `deleted: boolean` while ' + + '`DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never ' + + 'been declared by any schema and no server path has ever returned it on ' + + '`/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the ' + + 'project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / ' + + '`_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of ' + + 'it, and the claim was false in the one direction that matters: the compiler endorsed ' + + 'the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the ' + + 'branch was never taken; `if (r.success)` was rejected by the compiler and correct on ' + + 'the wire. So this rename REVEALS a defect rather than breaking working code — every ' + + 'reader of the old key was already reading `undefined`, on every deployment and not ' + + 'just some, because the protocol path has always answered `success`. It is registered ' + + 'as a semantic entry rather than a mechanical conversion for the reason the rewrite ' + + 'itself does not capture: the key is one token, but a call site that branched on ' + + '`r.deleted` has been taking the FALSE branch unconditionally since it was written, and ' + + 'whatever that branch did — or skipped — is what actually has to be re-read. There is ' + + 'no authored source for the chain to rewrite either; this is a published TypeScript ' + + 'surface whose enforced channel is tsc at the call site, and for an untyped JS caller ' + + 'there is no constrained channel at all, which is why the ledger entry is the only ' + + 'notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one ' + + 'producer shape, and a consumer accepting two spellings is what contract-first exists ' + + 'to prevent (the same rule already moved the runtime\'s ObjectQL fallback from answering ' + + '`deleted: true` to the declared `success`, on the producer side). No deprecated ' + + '`deleted?: boolean` transition key ships, for the same reason — a transition period is ' + + 'for keys that WORKED, and this one never did. Registered by the stock reconciliation of ' + + 'the v17 train\'s breaking changesets, which had never been compared against the ledger. ' + + 'ADR-0087.', + acceptanceCriteria: + 'No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.' + + 'delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller ' + + 'must be swept by hand because nothing will report it. Nothing about the request, the ' + + 'route, the status codes or the error shapes changes, and no server needs upgrading — ' + + 'the value you may now read is the one that was already arriving. ⚠️ The real work is ' + + 'behavioural: every `if (r.deleted)` has been false since it was written, so re-read ' + + 'what each of those branches was supposed to do. Post-delete cleanup, cache ' + + 'invalidation, audit writes and UI refreshes guarded that way have never run, and ' + + 'switching to `r.success` turns them ON for the first time — verify that is what you ' + + 'want rather than assuming it restores prior behaviour. Any test that passed while ' + + 'asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts b/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts new file mode 100644 index 00000000000..aad5980205b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'connector-inline-authentication-publish-refused', + surface: 'connector.authentication on AUTHORED entries (defineStack `connectors:`, ' + + '`PUT /meta/connector/:name`) — previously refused only on provider-bound instances ' + + '(ADR-0097 §3), now refused on catalog descriptors too', + replacement: 'a catalog descriptor drops `authentication` (or sets `{ type: "none" }`) ' + + 'and documents the auth scheme in `description`; a dispatchable instance declares ' + + '`provider` and references its credential with `auth: { type, credentialRef }` ' + + '(ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape ' + + 'still carries resolved secrets inline.', + reason: + 'A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` ' + + '/ `password` / `clientSecret` is cleartext at rest, readable through the data API (the ' + + 'class a credential-persistence survey measured: any authored artefact whose schema ' + + 'permits an inline credential lands it there). No mechanical rewrite exists: whether ' + + 'the entry should become a `none` descriptor or a provider-bound instance with a ' + + '`credentialRef` — and which secret ' + + 'store receives the credential — is a judgment about the connector, not a rename.', + acceptanceCriteria: + 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no ' + + 'authored entry carries a non-`none` `authentication`; formerly inline credentials are ' + + 'reachable through `credentialRef` resolution and the connector still materializes.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts b/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts new file mode 100644 index 00000000000..e9b0b20c30d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.dashboard-widget-compareto-offset.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'dashboard-widget-compareto-offset', + surface: "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", + replacement: "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`", + reason: + 'The widget declared three comparison arms; the analytics executor implements one shape, ' + + '`{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset ' + + 'path — the spec\'s single author-facing analytics shape — `{ offset }` was forwarded ' + + 'verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, ' + + 'taking the widget down; the arm ever only ran on the legacy inline chart path (measured ' + + 'when all three declared arms were found dead on the dataset path: two silently dropped, ' + + 'this one throwing). ' + + "The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every " + + 'other duration has NO faithful target: `previousPeriod` shifts by the length of whatever ' + + "window the widget's filter resolves to, which equals `7d` only when that window happens " + + 'to be seven days long. Rewriting mechanically would silently change which rows the ' + + 'comparison column counts — a wrong number rather than a missing one, which is strictly ' + + 'worse and exactly the class this convergence exists to end. Re-stating the intended ' + + 'window is a judgment about the presentation, not a transform.', + acceptanceCriteria: + 'No dashboard widget declares `compareTo.offset`. Each former offset comparison states its ' + + "window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` " + + "(or `'previousYear'`), and `dimension` is named wherever the selection dates more than one " + + 'time dimension. `objectstack validate` passes, and each affected widget renders a ' + + '`__compare` column over the window its author intended.', + relevantWhen: { kind: 'stack-declares', keys: ['dashboards'] }, +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts new file mode 100644 index 00000000000..7c0a6794ea5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.data-driver-find-stream-retired.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'data-driver-find-stream-retired', + surface: 'contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream', + replacement: + 'find() with limit/offset — the paged read whose determinism IS enforced ' + + '(IDataDriver.find, data/pagination-conformance.ts)', + reason: + '`findStream` was a REQUIRED contract method documented as "optimized for large ' + + 'datasets to avoid memory overflow", and in two of its three implementations it ' + + 'delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for ' + + 'the ENTIRE result set and then yielded it row by row, so the peak memory a caller ' + + 'was promised protection from was already reached before the first yield. The third ' + + '(`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in ' + + 'that driver never routed through `buildFindOptions`, so it hardcoded ' + + '`projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever ' + + 'observed, because the method had NO caller in either repository: the engine exposes ' + + 'no stream entry, and the REST export, import and bulk-read paths all go through ' + + '`find()`. The ~20 driver test doubles that existed only to satisfy a required ' + + 'method almost all threw `not implemented`, and nothing ever noticed — which is the ' + + 'proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every ' + + 'test double with an implementation of a capability the platform does not have. ' + + 'Rather than build a caller to justify three implementations, the method is retired; ' + + 'a real cursor-based read should return WITH the caller that needs it (ADR-0049 ' + + 'enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never ' + + 'stack metadata — so there is no source for the chain to rewrite, and deliberately ' + + 'no schema tombstone either: nothing ever ran a driver object through ' + + '`DriverInterfaceSchema.parse()`, so a prescription there would have no one to ' + + 'reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ' + + 'ADR-0078.', + acceptanceCriteria: + 'No code calls `driver.findStream(...)`; large reads page through `find()` with ' + + '`limit`/`offset` (which guarantees a total order across the whole walk) or go ' + + 'through the export surface. Drivers and test doubles no longer implement the ' + + 'method — one left behind still compiles and is simply never reached, so removing ' + + 'it is cleanup rather than a break, while a CALLER of it no longer type-checks.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts b/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts new file mode 100644 index 00000000000..dadfdbc974c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.data-driver-query-omit-object.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'data-driver-query-omit-object', + surface: + 'contracts.IDataDriver query parameter — find / findOne / count / updateMany / ' + + 'deleteMany / explain', + replacement: + '`DriverQuery` (`Omit`): delete the redundant `object:` key from the ' + + 'query literal at the call site — the object name is already the FIRST argument', + reason: + 'Every one of these methods takes the object name as its first argument, and then ' + + 'required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, ' + + 'with two places for it to disagree. The layers above had already paid for that ' + + 'ambiguity: the objectql engine deliberately writes its key order as `{ ...query, ' + + 'object }` so a smuggled `query.object` cannot override the resolved name, and the wire ' + + 'layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The ' + + 'driver side paid in blanket casts: a direct caller holding only a `where` could not ' + + 'name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / ' + + '`fields` along with it — 20 such sites were measured in the downstream cloud codebase, ' + + 'and a `$like` the type layer would have caught reached runtime there through exactly ' + + 'that hole. This is a TS contract surface with ' + + 'no authored source for the chain to rewrite, which is why it is a semantic entry and ' + + 'not a D2 conversion; for a typed caller the compiler names every site (TS2353 ' + + '`\'object\' does not exist in type \'DriverQuery\'`), and for an untyped JS caller ' + + 'there is no constrained channel at all, which is exactly why this ledger entry must ' + + 'exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it ' + + 'unchanged (excess properties are only rejected on fresh literals), and an ' + + 'implementation still declaring `query: QueryAST` keeps compiling under parameter ' + + 'bivariance. What an implementation may no longer do is READ `query.object` — callers ' + + 'are now entitled to omit it. Registered by the stock reconciliation that compared the ' + + 'breaking changesets already on the v17 release train against this ledger. This change ' + + 'was that audit\'s CONTROL sample, drawn to show that not every flagged candidate is an ' + + 'omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose ' + + 'inside other entries, and the only subject-level hit on this interface is ' + + '`data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver ' + + 'call-parameter changes both registered, and both cite this narrowing as ' + + 'background; the larger sibling they derive from never got its own entry. ADR-0087 ' + + '(backfilled by that reconciliation).', + acceptanceCriteria: + 'No `IDataDriver` call site passes an inline literal carrying `object:` — `driver.find(' + + '"account", { object: "account", where: … })` becomes `driver.find("account", { where: ' + + '… })`. `tsc` is the verify loop for typed callers and reports every remaining site by ' + + 'name; an untyped JS caller must be swept by hand, because nothing will report it. Any ' + + 'driver IMPLEMENTATION that read `query.object` is rewritten to use the object-name ' + + 'argument instead — that read now yields `undefined` whenever a caller exercises its ' + + 'new right to omit the key, and it fails at runtime rather than at compile time, so it ' + + 'is the one change on this surface a type check cannot find for you.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts new file mode 100644 index 00000000000..e8e6ba6ec5f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.data-engine-batch-retired.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'data-engine-batch-retired', + surface: 'contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema', + replacement: + '`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata ' + + "protocol's `batchData` with `options.atomic: true` for a batch over one object; " + + '`POST {basePath}/batch` on the wire', + reason: + '`batch?` was declared on `IDataEngine` for as long as that contract existed and was ' + + 'never implemented by any engine: `ObjectQL` has no `batch` method and there is no ' + + 'other engine in the tree. It also had no caller — `DataEngineRequest` was imported ' + + 'by exactly one file, the contract declaring the member. Its entire specification ' + + 'was a three-word doc comment ("Batch Operations (Transactional)"), which settles ' + + 'nothing about partial failure, ordering, cross-object references, rollback scope, ' + + 'or what `transaction: false` was supposed to mean — the questions a batch API ' + + 'exists to answer. Contrast its neighbours `getDefaultDriverName?` / ' + + '`getDriverByName?`, whose optionality is evidenced: each names its implementer and ' + + 'its probing caller. The tell that nobody ever designed against it is in the schema: ' + + '`DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a ' + + 'batch could contain batches, with no statement anywhere about what that meant for ' + + 'ordering or rollback. The only test was a type pin — an ad-hoc object literal ' + + 'carrying a `batch` property, asserting the property was defined — which could not ' + + "fail while the declaration existed and would have passed unchanged for the " + + "member's whole life with no engine implementing it. What it claimed is now covered " + + 'by members that are real, so the removal deletes a false affordance rather than a ' + + 'capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 ' + + "made `batchData`'s `atomic` honest, while the wire batch has always validated with " + + '`CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from ' + + '`api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces ' + + 'only: an engine is CODE, never stack metadata, so there is no source for the chain ' + + 'to rewrite. Deliberately no schema tombstone either — nothing ever parsed ' + + '`DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one ' + + 'to reach; its three `authorable-surface.json` baseline lines and its ' + + '`json-schema.manifest.json` entry are dropped in the same change, deliberately. ' + + 'The enforced channel is tsc. ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No code calls `engine.batch(...)` and no type references `DataEngineBatchRequest`; ' + + 'in-process multi-write atomicity goes through `IObjectQLEngine.transaction(cb)`, a ' + + 'batch over one object through `batchData` with `options.atomic: true`, and a ' + + 'cross-object batch over the wire through `POST {basePath}/batch`. Because no engine ' + + 'implemented the member, an implementation left behind still compiles and is simply ' + + 'never reached; a CALLER of it no longer type-checks — and there were none.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts b/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts new file mode 100644 index 00000000000..16ceceec282 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.data-field-changed-event-retired.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'data-field-changed-event-retired', + surface: "api.DataEventType 'data.field.changed'", + replacement: + "the `data.record.updated` event, whose payload already carries the per-field " + + 'detail: `changes` (the changed fields), plus `before` / `after`', + reason: + '`data.field.changed` was declared in `DataEventType` and emitted by nothing — the ' + + 'engine\'s `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since ' + + 'multi-record predicate writes were given events of their own) ' + + '`data.records.{updated,deleted}`, and no other producer exists in either ' + + 'repository. A subscriber that switched on it was waiting on an event no producer ' + + 'sends: the branch never ran, and because the surrounding `switch` still compiled, ' + + 'nothing anywhere reported the gap (ADR-0078\'s silently-inert declaration, on the ' + + 'event vocabulary). `DataEventSchema` could not have carried the semantics even if ' + + 'something had emitted it — the payload is record-shaped (`recordId`, `changes`, ' + + '`before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member ' + + 'promised a granularity the contract has no room for. Per-field detail is therefore ' + + 'not lost: it has always ridden on `data.record.updated` as `changes`, which is one ' + + 'event per write rather than N events on a wide table. This is a runtime EVENT ' + + 'surface — no stack, example or template authors an event name (webhooks subscribe ' + + 'through the separate authorable `WebhookTriggerType`, whose vocabulary was already ' + + 'trimmed to producers that exist) — so there is no source for the chain to ' + + 'rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a ' + + 'retiredKey() fix-it error the way an authorable object key can (the same limit the ' + + 'sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, ' + + 'which ' + + 'fails any consumer still naming the value in a `DataEventType` position, and the ' + + 'enum parse, which now rejects the name instead of accepting an event that never ' + + 'arrives. A genuine per-field stream, if one is ever wanted, gets its own honest ' + + 'contract the way bulk writes were given theirs. ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No consumer subscribes to or switches on `data.field.changed`; per-field change ' + + 'detail is read from a `data.record.updated` event\'s `changes` map (with `before` / ' + + '`after` for the surrounding state). Deleting the dead branch changes no observable ' + + 'behaviour — it never executed — so the migration is removing code that could not ' + + 'run, not rebuilding a capability.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts new file mode 100644 index 00000000000..4e0dbb3fba9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'datasource-config-inline-credential-refused', + surface: 'datasource.config.password (postgres / mysql / mongo) and ' + + 'datasource.config.authToken (turso)', + replacement: "the datasource secret binder: the Setup → Datasources connection form's " + + 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + + 'or a direct `external.credentialsRef` secrets-store reference', + reason: + 'A datasource artefact is persisted whole into `sys_metadata`, which is served back by ' + + 'the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled ' + + 'on 2026-08-12 to close that per artefact: each schema that admitted an inline credential ' + + 'refuses it at publish and points at the secret mechanism the artefact already had, rather ' + + 'than a heuristic guard at the `sys_metadata` write. There is no mechanical rewrite: ' + + 'moving the value requires ENCRYPTING it into a `sys_secret` row through a running ' + + "secret binder and deleting the cleartext, which a source-file transform cannot do — " + + 'auto-deleting the key alone would silently drop a live credential instead.', + acceptanceCriteria: + 'Every datasource parses with no `config.password` / `config.authToken` key; each ' + + 'affected datasource carries `external.credentialsRef` (or has its secret bound through ' + + 'the connection form) and still connects; no cleartext credential remains in any ' + + 'stored `sys_metadata` row or authored source.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts new file mode 100644 index 00000000000..cd708631200 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'datasource-config-placeholder-refused', + surface: 'connection-material string keys of the built-in driver configs — ' + + 'postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/' + + '`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), ' + + 'turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values ' + + 'containing `${…}` placeholder syntax', + replacement: 'the literal value. For secret material, the datasource secret binder: the ' + + "Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle " + + 'stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. ' + + 'For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` ' + + 'and friends are translated into driver config by the boot hosts and never pass through ' + + 'the publish door', + reason: + 'A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is ' + + 'stored verbatim in `sys_metadata` and handed verbatim to the database client at connect ' + + '(measured by the credential census while the inline-credential refusal was being built), ' + + 'so the connection fails, or connects somewhere ' + + 'unintended, with no error naming the unresolved placeholder — the masked-failure shape. ' + + 'The syntax looked supported: it parsed green, stored fine, and failed at a distance; two ' + + 'shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had ' + + 'to warn "do NOT substitute a placeholder" around the broken escape. The maintainer ruled ' + + 'on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; ' + + 'implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and ' + + 'zero measured pull for actual substitution. There is no mechanical rewrite: the ' + + 'placeholder names a value that exists only in the author\'s intended deployment ' + + 'environment, which a source-file transform cannot know — substituting anything would ' + + 'invent a connection target.', + acceptanceCriteria: + 'Every datasource parses with no `${…}` span in any connection-material config value; ' + + 'environment-driven deployments carry their DSN in the runtime environment ' + + '(`OS_DATABASE_URL` and friends) or bind secrets via `external.credentialsRef`, and ' + + 'still connect; no unresolved placeholder remains in any stored `sys_metadata` row or ' + + 'authored source.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts new file mode 100644 index 00000000000..b811c391f8b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'datasource-config-url-userinfo-refused', + surface: 'datasource.config.url (postgres / mysql / mongo / turso) and ' + + 'datasource.config.syncUrl (turso) — the URL userinfo password segment ' + + '(`user:password@host`)', + replacement: 'the same URL with its userinfo password removed (a bare `user@host` stays ' + + "legal), plus the datasource secret binder: the Setup → Datasources connection form's " + + 'secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), ' + + 'or a direct `external.credentialsRef` secrets-store reference', + reason: + 'The inline-credential closure refused the credential KEYS, and building it measured that ' + + '`config.url` still accepted the identical secret one syntax over — ' + + '`postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as ' + + '`config.password` did, and the key refusal itself steered authors there. The maintainer ' + + 'ruled on 2026-08-12 (Option A) to refuse the URL userinfo password at publish, through ' + + 'one value-level parse the driver schemas share. Runtime-environment DSNs ' + + '(`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected ' + + 'by construction. There ' + + 'is no mechanical rewrite, for the same reason as the sibling entry ' + + '`datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it ' + + 'into a `sys_secret` row through a running secret binder and stripping the cleartext, ' + + 'which a source-file transform cannot do — auto-stripping the userinfo alone would ' + + 'silently drop a live credential instead. Do not substitute a `${…}` placeholder into ' + + 'the URL: placeholders in authored metadata are resolved by nothing and reach the ' + + 'database client verbatim (measured when the inline-credential refusal was built).', + acceptanceCriteria: + 'Every datasource parses with a credential-free `config.url` / `config.syncUrl` (no ' + + 'userinfo password segment); each affected datasource carries ' + + '`external.credentialsRef` (or has its secret bound through the connection form) and ' + + 'still connects; no URL-embedded credential remains in any stored `sys_metadata` row ' + + 'or authored source.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts b/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts new file mode 100644 index 00000000000..f35a4e0842a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.declarative-apis-endpoints-live.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'declarative-apis-endpoints-live', + surface: 'stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)', + replacement: + 'the same declarations, re-read as LIVE HTTP routes: `path` moved under ' + + '`/api/v1/apps//`, and every entry that declares ' + + '`authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying ' + + '`rateLimit: { enabled: true, … }`', + reason: + 'This is the one protocol-17 entry that turns metadata ON rather than off, so read it ' + + 'as a SECURITY review item and not as a rename. Before 17 the declarative endpoint ' + + 'surface executed NOTHING: no route was mounted for a declared `path`, no matcher ' + + 'existed, and every key — `authRequired` included — parsed green and gated nothing ' + + '(which is why the maintainer\'s 2026-08-04 ruling refused a non-empty `apis:` outright ' + + 'until an executor existed). Protocol 17 ships that ' + + 'executor and narrows the refusal to a per-endpoint ' + + 'publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as ' + + 'soon as the stack is published. So an `apis:` block written against an older major — ' + + 'or one restored from a source older than that ' + + 'refusal, or authored from a doc that predates the ' + + 'refusal — changes meaning without changing a byte: what used to be inert ' + + 'documentation becomes an execution entry point into the data and automation ' + + 'pipelines. Nothing about that transition can be applied mechanically, because the ' + + 'judgment it needs is "did the author of this endpoint mean for the internet to reach ' + + 'it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its ' + + 'schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT ' + + '`authRequired: false` is the only thing that opens anonymous access, and under ' + + 'ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key ' + + 'defaults to `false`, so a budget written without it meters nothing) or the stack ' + + 'refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with ' + + '`ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: ' + + '`const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the ' + + 'safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type ' + + '(defaults materialized, ADR-0122), where `authRequired` is required — annotating a ' + + 'declaration with it forces you to write the key out, and being made to think about a ' + + 'key whose only unrecoverable value is `false` is the one thing this entry is trying ' + + 'to avoid. Hold a parse RESULT with `ApiEndpointParsed`; write declarations ' + + 'as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you ' + + 'upgrade, delete the ones that were never meant to be public, and arm a budget on the ' + + 'ones that were. The path move is the mechanical-looking half and is still yours: ' + + 'ADR-0121 D1/D2 confine a declared path to your own namespace carve-out ' + + '(`/api/v1/apps//…`), the namespace comes from an explicit ' + + '`manifest.namespace` with no derivation fallback, and the subpath is the only part ' + + 'you name — rewriting it for you would silently change a URL third parties call.', + acceptanceCriteria: + 'You have READ every entry of every `apis:` block, not just the ones that fail to ' + + 'publish. Concretely: (1) each declared `path` is ' + + '`/api/v1/apps//` and the stack declares that ' + + '`manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is ' + + 'one you INTEND to be reachable without a session, and each carries ' + + '`rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not ' + + 'intended to be anonymous have the key removed so the safe default (`true`) applies; ' + + '(3) `objectstack validate` passes, which also proves no endpoint declares a shape ' + + '17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an ' + + '`object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, ' + + '`inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and ' + + '(4) after publishing, each endpoint answers as you expect — an anonymous request to ' + + 'a session-only endpoint returns 401 rather than data.', + relevantWhen: { kind: 'stack-declares', keys: ['apis'] }, +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts b/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts new file mode 100644 index 00000000000..e9fd30754a6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'delete-by-id-before-hook-repoint-retired', + surface: + 'a `beforeDelete` handler on a BY-ID `delete()` assigning `ctx.input.id` a DIFFERENT id, ' + + 'to move the delete onto that row', + replacement: + 'delete the other row explicitly — `ctx.ql.delete(object, otherId)` / `ctx.api` — and let ' + + 'the addressed delete proceed or `throw` from the handler to stop it; to delete MANY rows, ' + + "have the CALLER pass `{ multi: true, where: … }`. Writing the SAME id back is unaffected " + + 'and stays legal.', + reason: + 'The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` ' + + 'handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table ' + + 'still answering differently: it HONOURED a repoint, re-resolving the new target by ' + + "re-reading its pre-image and rebinding `previous` (the fix that first made a single-row " + + "delete bind `previous` at all), so `afterDelete` and the roll-up recompute saw the row " + + 'actually deleted. It now refuses with ' + + '`HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: \'by-id\'`, exactly as the ' + + '`update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\n' + + 'Read this as a RULING, not a defect report — that distinction is the reason the entry ' + + 'is worth its length. That re-resolution was internally CORRECT and nothing stale ' + + 'ever leaked from it; the case that retires a rebind on `update()` (the write landing on ' + + 'a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) ' + + 'simply did not apply to it. The engine change that dispatches `before*` hooks per matched ' + + 'row on a bulk write therefore left the asymmetry standing on purpose rather than folding ' + + 'a behaviour removal into an ordering change, and filed it as a finding of its own. The ' + + '2026-08-09 maintainer ruling on that finding closed it on three ' + + 'measured axes instead: compatibility cost zero (a repository-wide grep for assignments ' + + "into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL " + + 'SIX are this family\'s own pins — no consumer anywhere repoints); one rule across both ' + + 'verbs beats two individually-correct rules an author has to memorize, since the ' + + 'justification for the split lived in an ADR rather than at the call site; and "a hook ' + + 'silently redirects which row gets deleted" is a top-grade footgun for authored — ' + + 'especially AI-authored — handlers however correctly the redirect is implemented. ' + + 'Correctness of a mechanism does not justify the surface it exposes. Aligning the other ' + + 'way, by building `update()` the same re-resolution, stays excluded by the recorded ' + + 'ruling that extended per-row hook semantics to `before*` hooks on bulk writes ("do not ' + + 'silently pick re-resolution instead").\n\n' + + 'Why this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as ' + + '`hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at ' + + 'this step: FIRST, there is no source to convert — a `HookContext` is constructed per ' + + 'write and never persisted, so no `sys_metadata` row, example or template can carry the ' + + 'assignment. SECOND, the only place it is ever SPELLED is inside a handler body: ' + + 'author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative ' + + 'transform cannot safely rewrite an assignment inside free-form code, and the intent is ' + + 'not recoverable anyway — only the author knows whether the repoint meant "delete that ' + + 'row INSTEAD" or "delete that row TOO".\n\n' + + 'What makes this one cheaper to meet than its two siblings, and worth saying because it ' + + 'bounds the work: the removed capability has an ENFORCED channel at run time. The refusal ' + + 'throws before anything is written and its message NAMES the retired capability and the ' + + 'three replacement routes, so a handler that still repoints fails loudly and self-' + + 'describingly on its first execution rather than going quiet. This ledger entry is the ' + + 'channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2.', + acceptanceCriteria: + 'No `beforeDelete` handler assigns `ctx.input.id` anything but the id it arrived with — ' + + 'grep handler bodies for assignments into `input.id` and rewrite each into an explicit ' + + '`ctx.ql.delete()` for the other row, a caller-side `{ multi: true, where: … }`, or a ' + + '`throw`. A delete-heavy smoke run completes with no `HookTargetRebindError` ' + + "(`ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, `event: 'beforeDelete'`) — and any that does " + + 'raise names its `expectedId` and `observedId`, which identifies the handler that moved ' + + 'the target.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts new file mode 100644 index 00000000000..aaa21aa7de5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.driver-aggregate-undeclared-key-aliases-removed.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'driver-aggregate-undeclared-key-aliases-removed', + // No backticks in `surface`: the upgrade-guide renderer wraps this string + // in a code span of its own, and a nested pair renders as literal ticks. + surface: "driver aggregate() call argument — query.aggregate and aggregations[].func", + replacement: + 'query.aggregations and aggregations[].function — the spellings QueryASTSchema and ' + + 'AggregationNodeSchema have always declared', + reason: + '`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the ' + + 'Query Protocol has never declared: `query.aggregations || query.aggregate` and ' + + '`agg.function || agg.func`. "Never declared" is measured, not assumed — `git log ' + + '-S` over `data/query.zod.ts` finds no commit that ever introduced either name, ' + + 'there is no `retiredKey()` tombstone and no alias-table entry for them (the file\'s ' + + 'only alias table is `SortNode`\'s `direction` → `order`), and neither appears in any ' + + 'upgrade guide or release note. So this entry does not record a declared surface ' + + 'being withdrawn; it records a LENIENCY being withdrawn, which is why it is here ' + + 'rather than behind a tombstone. The only writers in this repository were the two ' + + 'driver packages\' own fixtures — the family of the org-axis red-line gate that read ' + + 'only rejected aliases while its own fixtures spelt them, so its tests stayed green ' + + 'and the rule stayed dead: a fixture spelling the alias keeps the tolerant limb green ' + + 'forever and no test in existence can go red on its deletion — so ADR-0049 ' + + 'enforce-or-remove applies once those are re-spelt. ⚠️ Do ' + + 'NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical ' + + 'key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ' + + 'ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most ' + + 'likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two ' + + 'surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, ' + + 'never stack metadata — so there is no source for the D2 chain to rewrite and ' + + 'deliberately no schema tombstone: nothing ever ran a query through ' + + '`QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call ' + + 'site, once the parameter is `DriverQuery` — and for an untyped JS caller there is ' + + 'no enforced channel at all, which is exactly why this ledger entry has to exist: ' + + 'the generated upgrade guide is the only way such a reader learns of the rename. ' + + 'Same disposition, and the same reason, as `data-driver-find-stream-retired` ' + + '(`IDataDriver.findStream`, removed with no tombstone because nothing parses a driver ' + + 'object), `storage-service-list-retired` (the zero-consumer `IStorageService.list`, ' + + 'whose two adapters answered differently and both incompletely) and ' + + '`actor-user-roles-to-positions` (the `ctx.user` `roles` alias, closed at once on the ' + + 'maintainer\'s word rather than given a window). The removal ran in a fixed order — ' + + 'the fixtures re-spelt first, the two alias branches deleted second, the parameter ' + + 'narrowed to `DriverQuery` last — because the reverse order yields red nobody can ' + + 'explain. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No caller passes `aggregate:` to a driver\'s `aggregate()`, and no aggregation entry ' + + 'spells its function `func:`; both are written `aggregations:` / `function:`. An ' + + 'inline literal still using either old spelling no longer type-checks (TS2353 at the ' + + 'call site). An untyped JS caller that keeps writing `aggregate:` silently receives ' + + 'no aggregate column — the grouping still happens, the measure is simply absent — ' + + 'and one that keeps writing `func:` receives INVALID_QUERY / 400 naming the ' + + 'undeclared function, identically on the local driver and the Turso remote ' + + 'transport.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts new file mode 100644 index 00000000000..577af41f14d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.driver-capabilities-inert-bits-removed.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'driver-capabilities-inert-bits-removed', + surface: + 'data.DriverCapabilities.create / data.DriverCapabilities.read / ' + + 'data.DriverCapabilities.update / data.DriverCapabilities.delete / ' + + 'data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / ' + + 'data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / ' + + 'data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / ' + + 'data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / ' + + 'data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / ' + + 'data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / ' + + 'data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / ' + + 'data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / ' + + 'data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / ' + + 'data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / ' + + 'data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / ' + + 'data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / ' + + 'data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / ' + + 'data.DriverCapabilities.queryCache', + replacement: + '(removed — delete the keys. A driver advertises a capability by implementing the ' + + 'corresponding IDataDriver method; the three bits that survive because method ' + + 'presence cannot carry the signal are `queryDateGranularity`, `autonumber` and ' + + '`batchSchemaSync`)', + reason: + 'Retiring `IDataDriver.findStream` (it had no production caller, and two of its three ' + + 'implementations read the whole result set into memory before yielding a row) left ' + + '`DriverCapabilities.streaming` pointing at a capability the contract no longer ' + + 'declares, and the follow-up audit checked ' + + 'every bit in the record the same way, across objectstack and cloud (objectui ' + + 'confirmed clean): of 34 declared bits, THREE have a decision-making reader — ' + + '`queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), ' + + '`autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ' + + 'ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` ' + + 'from a base whose transport batches while its own cannot) — and THIRTY-ONE were ' + + 'written by every driver and read by nothing. Their `.describe()` strings promised ' + + 'engine adaptation ("if false, ObjectQL will filter/sort/paginate in memory") that ' + + 'was never built, and zero readers let the values go WRONG unnoticed: SqlDriver ' + + 'declared `streaming: false` while implementing `findStream`; InMemoryDriver ' + + 'declared `streaming: true` over a full-table read (ADR-0078 false affordance, on ' + + 'the capability record itself). The real mechanism everywhere else is METHOD ' + + 'presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on ' + + '`typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the ' + + 'REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never ' + + 'stack metadata — `supports` literals live in driver classes and ' + + '`DriverConfig.capabilities` is plugin TS configuration, neither ever a ' + + '`sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was ' + + 'retired separately, as a whole block nothing read) — so there is no source for the ' + + 'D2 chain to rewrite ' + + 'and this entry is the D3 record. The keys are tombstoned rather than deleted ' + + 'because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed ' + + '(DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): ' + + 'a plain delete would silently strip a vendor\'s authored bit, replacing one ' + + 'silent no-op with another. `batchSchemaSync` also drops its `.default(false)` ' + + 'for `.optional()` — absence already meant false at both readers, and the default ' + + 'forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No `supports` literal or `DriverConfig.capabilities` object authors any of the 31 ' + + 'retired bits — a driver class that still writes one fails tsc against ' + + '`IDataDriver.supports` (the bit is `never`), and a parsed config fails with the ' + + 'per-key prescription. The three in-repo drivers (memory / mongodb / sql) declare ' + + 'only live bits; cloud\'s TursoDriver keeps compiling via its `...super.supports` ' + + 'spread (its stale explicit overrides are cleanup, tracked cloud-side). Engine ' + + 'behaviour is byte-identical: every removed bit had zero readers, and the three ' + + 'live bits keep their readers (engine.ts autonumber defer / aggregate dispatch, ' + + 'plugin.ts + engine.ts batched schema sync, verify date-bucket parity).', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts b/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts new file mode 100644 index 00000000000..11601ea23ac --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.driver-sql-distinct-bare-filter-typed.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'driver-sql-distinct-bare-filter-typed', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: 'SqlDriver.distinct() third argument — any value', + replacement: + 'a bare FilterCondition (@objectstack/spec/data) — the same value find() carries ' + + 'under query.where, never a query envelope', + reason: + 'This entry records a TYPE being added, not a surface being withdrawn, and it says ' + + 'so up front because the distinction decides who has to do anything. `distinct` is ' + + 'not declared on `IDataDriver`, so neither the narrowing of `IDataDriver`\'s query ' + + 'parameters to `DriverQuery` nor the follow-through that brought five drivers\' ' + + 'implementations in line ever reached it, and it kept ' + + '`filters?: any` while its body said something far more specific — ' + + '`applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` ' + + 'off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry\'s change: not one ' + + 'statement moved, so no upgrade breaks at run time and nothing that answered ' + + 'correctly stops. What the annotation removes is a compile-time hole, measured ' + + 'rather than assumed: a truthy NON-OBJECT third argument — ' + + '`distinct(\'orders\', \'product\', \'completed\')` — used to type-check and resolve ' + + 'the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy ' + + 'non-object, non-array filter. A call meaning "which products among completed ' + + 'orders" answered with EVERY product, silently. That spelling is now TS2345 at the ' + + 'call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there ' + + 'is no source for the D2 chain to rewrite and deliberately no schema tombstone, the ' + + 'disposition `data-driver-find-stream-retired`, `storage-service-list-retired`, ' + + '`actor-user-roles-to-positions` and ' + + '`driver-aggregate-undeclared-key-aliases-removed` already carry. ⚠️ It ' + + 'differs from those four in ONE measured way a reader should not have to infer: ' + + 'because nothing changed at run time, an untyped JS caller is not affected BY THE ' + + 'UPGRADE at all. The entry is here for a different reason — such a caller is exactly ' + + 'the one tsc can never reach, and the silent widening above is a defect they may ' + + 'ALREADY be sitting on, before and after this major. The generated upgrade guide is ' + + 'the only channel that reaches them, which is why the fix is written down rather ' + + 'than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can ' + + 'close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter ' + + 'key IS a field name, so a query envelope `{ object, where }` is structurally a ' + + 'valid filter — one constraining columns named `object` and `where` — and so is a ' + + 'FilterArray. Both reach `distinct` type-checked and are refused at run time, ' + + 'loudly, with INVALID_FILTER / 400. `driver-memory`\'s opposite half — where the ' + + 'BARE spelling returns the unfiltered set in silence — stayed open under the ' + + 'maintainer\'s 2026-08-05 investment freeze on driver-memory, which was lifted on ' + + '2026-08-11; it is still open, now unexcused rather than deferred (the measurement ' + + 'that found the two drivers reading this argument differently split the fix: the sql ' + + 'half is this entry, and the memory half was held back by that freeze). ADR-0087.', + acceptanceCriteria: + 'No caller passes a non-object to `distinct()`\'s third argument. A scalar there is ' + + 'now a compile error (`TS2345: Argument of type \'string\' is not assignable to ' + + 'parameter of type \'FilterCondition\'`); rewrite it as the bare filter it was ' + + 'always meant to be — `\'completed\'` becomes `{ status: \'completed\' }`. ⚠️ That ' + + 'is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the ' + + 'answer changes once fixed, and the changed answer is the one the call always meant. ' + + 'An untyped JS caller gets no compile error and no behaviour change — for them this ' + + 'entry is the only notice that the spelling never filtered anything. A query ' + + 'envelope or a FilterArray in that slot still compiles and is rejected at run time ' + + 'with INVALID_FILTER / 400.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.engine-dotted-projection-refused.ts b/packages/spec/src/migrations/entries/semantic/17.engine-dotted-projection-refused.ts new file mode 100644 index 00000000000..60762a2618f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.engine-dotted-projection-refused.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'engine-dotted-projection-refused', + surface: + 'engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a ' + + 'dotted entry (`account.name`) — the direct engine path, not the REST ingress', + replacement: + "read the related record with `expand` (`{ expand: { account: { object: '', " + + "fields: ['name'] } } }`), keeping the reference column itself in `fields` — the " + + 'relation is carried by that column and projecting it away leaves expansion nothing ' + + 'to resolve, the same silent no-op a nested projection omitting the related `id` ' + + 'produced before the engine began keeping that join key itself; or denormalise the ' + + 'value onto the queried object (a stored field, written when the source changes) ' + + 'and name that — the same remedy the REST ingress prescribes when it refuses a ' + + 'dotted projection, and the sort axis when it refuses a dotted sort', + reason: + "The REST ingress closed the PROJECTION axis' dotted leg first, refusing a dotted " + + 'entry instead of widening the response to every field ' + + '(`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything ' + + 'reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` ' + + 'DIRECTLY passed through none of it, and that caller set was measured, not assumed: ' + + "a flow `get_record` node's authored `fields: ['name', 'account.name']` " + + 'parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into ' + + "`data.find(...)`, cleared the engine's head-only projection filter on its head " + + 'segment (`account` IS a field), and reached the driver as a projection column — ' + + 'where SQL renders `"account"."name"` against a table that was never joined, the ' + + "DB answers `no such column`, and the driver's unknown-column recovery ladder — " + + 'there so an unknown column never reads as "no rows" — retries ' + + "`select('*')`. The caller asked to narrow and silently received EVERY field, " + + 'byte-identical to no projection at all, pointing away from both FLS and data ' + + 'minimisation.\n\n' + + 'Ruled by the maintainer on 2026-08-12: a dotted entry the engine cannot resolve is ' + + "refused loudly at the engine's own head-only projection filter, covering every " + + 'caller that reaches the engine. The check it replaces was justified by a comment ' + + 'claiming the engine resolves relationship paths "via populate"; a measurement found ' + + 'that NO populate step exists — once the spec and docs stopped prescribing a dotted ' + + '`fields` path, that comment was the last place in the repo asserting dotted-path ' + + 'resolution does — so what was removed is not a ' + + 'working feature but a path to widening, kept alive by a false premise. The ' + + 'unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown ' + + 'plain name still drops silently; an all-unknown projection still falls back to ' + + '`*`), a registry-less host gets no verdict (the driver-side recovery ladder remains ' + + 'its documented backstop, and a driver-side carve-out is measured-need only), and ' + + 'a dotted `fields` inside a nested `expand` degrades to an observable warning ' + + "rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation " + + '`catch` swallows every expand failure, the same posture the formula-sort refusal ' + + '(`engine-find-formula-order-by-refused`) records for the same catch.\n\n' + + 'This is a CODE-path API, not stored metadata, so — like ' + + '`engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` ' + + 'row for the D2 chain to rewrite and the ledger entry is the notification channel. ' + + 'No mechanical rewrite exists: the platform cannot decide between `expand` and ' + + 'denormalisation for the caller, and it must not resolve the path itself — no ' + + 'driver ever did, and inventing a join here is a feature decision, not a ' + + 'migration — the line analytics already takes for a relation-traversing dotted ' + + 'measure, refused with a 400 naming the caller\'s spelling rather than computed ' + + 'against the wrong column. ADR-0112.', + acceptanceCriteria: + 'No `engine.find` / `engine.findOne` call site passes a dotted `fields` entry, no ' + + "flow `get_record` config authors one, and no saved report's " + + '`query.fields` names one — grep flow definitions and report definitions for a ' + + '`fields` entry containing a `.`, and rewrite each to `expand` (keeping the ' + + 'reference column projected) or to a denormalised stored column. Reads complete ' + + 'with no `INVALID_FIELD` whose message says "follows the relationship" or "a ' + + 'dotted path", and no "Failed to expand relationship field" warning whose error ' + + 'text does.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-filter-refused.ts b/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-filter-refused.ts new file mode 100644 index 00000000000..74b52cbc0c6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-filter-refused.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'engine-find-formula-filter-refused', + surface: + 'a `where` / filter naming a `formula` field — at BOTH doors: the REST ingress ' + + '(`assertFilterFieldsExist`, covering everything that reaches `findData`) and the ' + + 'engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` / `update` / ' + + '`delete`), which saved reports, flows and dashboard widgets reach directly', + replacement: + 'denormalise the value onto the object (a stored field, written when the source ' + + 'changes) and filter that — deliberately the same remedy, in the same words, the ' + + 'SORT axis prescribes when it refuses a formula sort, at the ingress and at the ' + + 'engine, and the SEARCH axis prescribes when it refuses a formula search field; ' + + '`summary` and `autonumber` fields need NO action, because both get ' + + 'real maintained columns and filter correctly', + reason: + '`formula` is the one field type no driver materialises a column for, and FILTER was ' + + 'the last of the three query axes still fail-open on it: SORT refuses it (at the ' + + 'REST ingress and at the engine) and SEARCH refuses it by name, while a ' + + '`where` on a `formula` field cleared every gate precisely BECAUSE the object ' + + 'declares the field, reached a driver with no column behind it, and answered 200 ' + + 'with zero rows. Measured on a real `ObjectQL` with `is_open` a `formula` over the ' + + "stored `status` column: `where {is_open: true}` and `where {is_open: false}` each " + + "returned 0 rows with NO error, while the controls `where {status: 'open'}` returned " + + '4 rows and `where {subtask_total: 5}` (a `summary`, which HAS a column) returned 1 ' + + 'row.\n\n' + + 'BOTH directions are wrong and the `false` one is the dangerous one: the same ' + + 'predicate against a STORED boolean returns every matching row, so a filter meaning ' + + '"not yet done" silently became "no records at all" — a row SET changed under a 200, ' + + 'which no amount of inspecting the response can reveal, and the formula READS ' + + 'correctly in that very same response, so the field is visibly populated and ' + + 'simultaneously unfilterable. That is strictly worse than the sort axis it mirrors: ' + + 'a refused sort returns the same rows in a different order, a refused filter changes ' + + 'which rows exist.\n\n' + + 'Both doors now refuse it with `400 INVALID_FIELD`, naming the ' + + 'offending key path and carrying the remedy sentence — the ingress gate ' + + '(`assertFilterFieldsExist`, `@objectstack/metadata-protocol`) for everything ' + + 'reaching `findData`, and `assertFilterIsMaterializable` ' + + "(`@objectstack/objectql`, `filter-comparand-shape.ts`) at the engine's own filter " + + 'seam, which every caller-supplied `where` passes through whichever verb it arrived ' + + 'by. Both judge the field by the SAME `@objectstack/spec/data` predicate the SEARCH ' + + 'axis uses (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`, which holds `formula` ' + + 'and nothing else), so gate and drivers cannot disagree about which types have a ' + + 'column: a gate widened to the spec\'s `COMPUTED_VALUE_TYPES` (the WRITE contract) ' + + 'would refuse two working types. DOTTED filter paths are deliberately not judged on ' + + 'this axis at either door.\n\n' + + 'This is a CODE-path API, not stored metadata, so — like ' + + '`engine-find-formula-order-by-refused` and `engine-dotted-projection-refused` at ' + + 'this step — there is no `sys_metadata` row for the D2 chain to rewrite and this ' + + 'ledger entry is the notification channel. No mechanical rewrite exists in either ' + + 'direction: the platform cannot invent the stored column the remedy prescribes, and ' + + 'it must not filter post-hoc instead — `driver.find` has already applied `limit` / ' + + '`offset`, so a predicate applied after the formulas are evaluated would filter an ' + + 'ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment ' + + 'pagination is involved.\n\n' + + 'AUTHOR-REACHABLE SURFACES are why this is not merely a code-side note. A saved ' + + "report's `query.filter` (`sys_saved_report`) is forwarded VERBATIM into " + + '`engine.find` by `plugin-reports` (`report-service.ts`, `where: q.filter`), ' + + 'bypassing the ingress gate entirely; flow node `config.filter` and dashboard widget ' + + 'filters are author-written the same way. A report or flow authored to filter on a ' + + 'formula field used to run and quietly return the wrong row set; it now fails ' + + 'loudly, with the remedy in the message.\n\n' + + 'Registered on the ruling inherited from the SORT axis — its engine refusal was ' + + 'registered in this ledger although no stored row needs rewriting, because the ' + + 'ledger is the one channel that carries its rewrite instructions to the author — ' + + 're-affirmed for this axis at triage on 2026-08-13: the shape is identical to the ' + + 'sort axis and the consequence here is larger. ADR-0112.', + acceptanceCriteria: + 'No filter names a `formula` field on any surface — grep your saved report definitions ' + + "(`sys_saved_report.query.filter`), flow node `config.filter`, dashboard widget " + + 'filters and view filters for a filtered field whose object declares it as a ' + + '`formula`, and denormalise each onto a stored column written when the source ' + + 'changes. A `summary` / `autonumber` field needs no action: both have real ' + + 'maintained columns and filter correctly. Reads complete with no `INVALID_FIELD` ' + + 'naming a virtual `formula` field in a filter, at either door.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts b/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts new file mode 100644 index 00000000000..452b8f525ac --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.engine-find-formula-order-by-refused.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'engine-find-formula-order-by-refused', + surface: + 'engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a ' + + '`formula` field — the direct engine path, not the REST ingress', + replacement: + 'denormalise the value onto the object (a stored field, written when the source ' + + 'changes) and sort by that — the same remedy the REST ingress prescribes when it ' + + 'refuses a dotted or formula sort; a `summary` field is unaffected and still sorts, ' + + 'because it gets a real maintained column', + reason: + 'The SORT axis is closed at the REST ingress for an unknown field, a dotted path and ' + + 'a `formula` field alike ' + + '(`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching ' + + '`findData`: the list route, `POST /data/:object/query`, the export route and the ' + + 'RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY ' + + 'passed through none of it, and a `formula` ORDER BY there was dropped in silence. ' + + 'Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion ' + + 'order, under a success, with the rows carrying the very values they were asked to ' + + 'be ordered by. No column exists to order by (a formula is computed on read, so no ' + + 'driver materialises one), so the ORDER BY reached the driver, found nothing, and ' + + 'the unknown-column backstop returned the rows unordered.\n\n' + + 'Ruled by the maintainer on 2026-08-10: an ORDER BY the engine cannot apply is a 4xx ' + + 'with guidance prose at the public boundary, never a silent drop — the same direction ' + + "as the analytics dataset refusal envelope and the ingress sort hint's stored-field " + + 'prescription. The ' + + "engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s " + + 'docblock) was to survive only behind a pinned internal path, and only if a MEASURED ' + + 'internal call site relied on it. The sweep of every in-tree `orderBy` reaching ' + + 'the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata ' + + 'loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real ' + + 'stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, ' + + '`started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo ' + + 'declares a `formula` field at all. So no internal path shipped, and there is no flag ' + + 'to opt back into the drop.\n\n' + + 'This is a CODE-path API, not stored metadata, so — like ' + + '`hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` ' + + 'row for the D2 chain to rewrite and the ledger entry is the notification channel. ' + + 'No mechanical rewrite exists in either direction: the platform cannot invent the ' + + 'stored column the remedy prescribes, and it must not sort post-hoc instead — ' + + '`driver.find` has already applied `limit` / `offset`, so re-sorting after the ' + + 'formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small ' + + 'result sets and is wrong the moment pagination is involved.\n\n' + + 'ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a ' + + "code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded " + + 'verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A ' + + 'report authored to sort by a formula field used to run and return rows in an ' + + 'arbitrary order; it now fails loudly, with the remedy in the message. One further ' + + 'path is deliberately NOT a refusal: a nested `expand` sort raises this refusal ' + + 'inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` ' + + 'swallows every expand failure and retains the raw foreign keys — so that path moves ' + + 'from silent to OBSERVABLE (a warning naming the field and the fix) rather than ' + + 'refusing. Reversing that backstop is a separate decision on all expand failure ' + + 'modes. ADR-0112.', + acceptanceCriteria: + 'No `engine.find` / `engine.findOne` call site sorts by a `formula` field, and no saved ' + + "report's `query.orderBy` names one — grep your report definitions for an `orderBy` " + + 'field whose object declares it as a `formula`, and denormalise it onto a stored ' + + 'column written when the source changes. A `summary` / rollup field needs no action: ' + + 'it has a real maintained column and sorts correctly. Reads complete with no ' + + '`INVALID_SORT` naming a formula field, and no "Failed to expand relationship field" ' + + 'warning whose error text names one.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.engine-update-upsert-retired.ts b/packages/spec/src/migrations/entries/semantic/17.engine-update-upsert-retired.ts new file mode 100644 index 00000000000..ad827654165 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.engine-update-upsert-retired.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'engine-update-upsert-retired', + surface: 'data.engine.update options.upsert', + replacement: + '(removed — never implemented; express create-if-absent explicitly: `findOne` first, then ' + + '`insert` or `update` on what you find)', + reason: + 'The `upsert` flag promised insert-if-absent on `engine.update()` but no engine or driver ' + + 'path ever read it: the key was declared on both update-options schemas and allowlisted by ' + + 'the unknown-option gate, yet `ObjectQL.update()` never referenced it and it was not a ' + + 'driver pass-through key — `{ upsert: true }` was accepted and silently dropped and the ' + + 'update stayed a plain update (ADR-0049 declared-but-unenforced). There is no behaviour to ' + + 'preserve and nothing stored to rewrite (it only ever appeared in a call-time option bag). ' + + "Any future first-class upsert must reconcile with the engine's not-found gate — a by-id " + + 'update whose id names no row throws RECORD_NOT_FOUND rather than inserting — which is why the ' + + 'flag is removed rather than implemented here.', + acceptanceCriteria: + 'No caller passes `options.upsert` to `engine.update()`; a call that includes it is refused ' + + 'loudly (the engine gate and both schemas quote one removal prescription) instead of ' + + 'succeeding with the option silently ignored.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts b/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts new file mode 100644 index 00000000000..4129b5ab5d6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.enhanced-api-error-field-errors-renamed.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'enhanced-api-error-field-errors-renamed', + surface: 'api.enhancedApiError.fieldErrors', + replacement: 'fields', + reason: + 'The wire has always carried `fields` — the validators, import coercion, ' + + 'validation-failure.ts, @objectstack/client and the console\'s field-error extractor ' + + 'all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it ' + + 'was reading a field no server sent (ADR-0078\'s silently-inert declaration, on the ' + + 'error envelope). This is a RESPONSE surface: no stack, example or template carries ' + + 'the key, so there is no source for the chain to rewrite — the schema tombstones it ' + + 'via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the ' + + 'field-level error code catalog).', + acceptanceCriteria: + 'No consumer reads `error.fieldErrors`; per-field validation detail is read from ' + + '`error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse ' + + 'with the rename prescription instead of silently losing the array.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts b/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts new file mode 100644 index 00000000000..7efc5b7e2cf --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.etl-pipeline-layer-retired.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'etl-pipeline-layer-retired', + surface: + 'automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / ' + + 'automation.etlDestination / automation.etlTransformation (the whole L2 layer of ' + + 'automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported ' + + 'names)', + replacement: + '(removed — no protocol surface replaces it, deliberately. Layer by layer: ' + + 'connector-attached synchronisation is `ConnectorSchema.syncConfig` ' + + '(`integration/connector.zod.ts`), which is PARSED AND VALIDATED but NOT ' + + 'EXECUTED — a declared shape, not a running sync. ' + + '`AutomationEngine.registerConnector` runs `ConnectorSchema.parse` and stores the ' + + 'parsed definition; nothing reads `syncConfig` back off it, and the key has no ' + + 'reader outside `packages/spec` at all — the same measurement that deleted ' + + '`syncConfig.schedule` in @objectstack/spec 17 under ADR-0049, with the other cron-typed positions ' + + 'nothing reads. What the platform DOES ' + + 'execute on a connector is its `actions`: a flow\'s `connector_action` node ' + + 'resolves the registered handler and awaits it, so an author who needs data ' + + 'actually moved drives it from there. Per-field value ' + + 'transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied ' + + 'row by row by the REST import path and recorded key by key in ' + + '`packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has ' + + 'NO replacement is multi-source, multi-stage movement with joins and aggregations — ' + + 'because it never had an implementation either. It returns through the ENFORCE route: ' + + 'the engine first, the vocabulary second)', + reason: + 'The reading the spec dual-source cleanup used to retire L1 `DataSyncConfig` (its ' + + 'automation copy deleted as dead), re-measured one layer up and ' + + 'identical: narrative-only. No engine ever parsed, scheduled or executed an ' + + '`ETLPipeline`. Measured on origin/main immediately before the removal: the only ' + + 'non-spec references in this repo are two fumadocs-generated documentation sources ' + + '(`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there ' + + 'is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading ' + + 'on it — while the same file family\'s EXECUTED half does have one ' + + '(`liveness/mapping.json`), which is the contrast that makes the absence meaningful ' + + 'rather than an oversight. The `etl` string in this registry was the one untested ' + + 'link the finding named, and it is not a loader path: it was the id of the ' + + 'retry-vocabulary entry for `ETLPipeline.retry` (a third retry-policy vocabulary the ' + + 'retry convergence had not covered), absorbed here. ' + + 'The layer was ADR-0078\'s asymmetry in its purest form — an author could write a ' + + 'complete ten-stage pipeline, get no error, and get no execution. It was also ' + + 'advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the ' + + 'recommended destination for authors displaced by the L1 retirement and ' + + 'listed ten transformation types with copyable examples down to ' + + '`script | Custom JavaScript/Python`. That document is rewritten in the same change; ' + + 'a retirement whose own doc still recommends the retired layer is self-contradictory, ' + + 'and forwarding L1\'s authors to a second layer with no executor was the defect ' + + 'compounding rather than closing. ' + + '⚠️ `etl-retry-converged-onto-retry-policy` is SUBSUMED here, the way the ' + + '`activationEvents`, dynamic plugin-loading and widget / i18n retirements each let an ' + + 'earlier tombstone go with the shape that carried it: both land in the unreleased ' + + 'protocol 17, so composed, a ' + + 'rename of `retry.maxAttempts` on a shape that does not survive the major has no ' + + 'observable effect — and keeping both would tell an upgrader to rewrite a key on a ' + + 'schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes ' + + 'with the shape that carried it, which is strictly stronger than the tombstone: there ' + + 'is no longer a `retry` block to author the key into. Route 3 — no carrier key, no ' + + 'parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this ' + + 'entry are the declaration. ADR-0049, ADR-0078.', + acceptanceCriteria: + 'No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, ' + + '`ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, ' + + '`ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, ' + + '`ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or ' + + 'the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 ' + + 'on any that survives. Every author who was pointed at L2 has been re-pointed by ' + + 'name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends ' + + '`ETLPipeline` as L1\'s destination and no longer advertises a transformation-type ' + + 'table. The surviving layers still parse unchanged — a connector declaring ' + + '`syncConfig` and an import declaring `mapping.transform` both behave exactly as they ' + + 'did in 16.x.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts b/packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts new file mode 100644 index 00000000000..98c872120fd --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.export-axis-opt-in.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'export-axis-opt-in', + surface: + 'security.permissionSet.objects[].allowExport (ABSENT — a permission set that never ' + + 'declared the key)', + replacement: + 'an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every ' + + 'permission set whose holders are meant to keep exporting', + reason: + 'A secure-default FLIP, not a shape change — the same class as ' + + '`rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and ' + + '`action-descriptor-resume-authority-default-flip`, and it is registered for the same ' + + 'reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will ' + + 'tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset ' + + 'inherited read; from 17 it denies. Reading a record and taking a bulk ' + + 'machine-readable copy of the whole table are different privileges — Salesforce ' + + '"Export Reports", Dynamics "Export to Excel", NetSuite "Export Lists" and SAP `S_GUI` ' + + '61 all separate them — and the axis now says so. This cannot be a mechanical ' + + 'conversion in either direction: writing `allowExport: true` wherever the key is absent ' + + 'would preserve today\'s behaviour while silently defeating the entire point of the ' + + 'flip, and writing `false` would revoke a capability the deployment may legitimately ' + + 'want. Whether a given set\'s holders SHOULD be able to take a bulk copy is exactly the ' + + 'segregation-of-duties judgement the axis exists to make explicit, and it belongs to ' + + 'the operator. Two details decide who is actually affected: package-shipped sets are ' + + 're-seeded on upgrade, so the built-ins are handled — at 17.0.0 `admin_full_access` and ' + + '`organization_admin` carried the grant explicitly, ON A `*` WILDCARD, and protocol 18 ' + + 'REMOVES it (see `admin-export-wildcard-removed`: the wildcard made the axis undeniable ' + + 'for an org admin, so from 18 an admin exports only what an app set grants — do not read ' + + 'this clause as a standing promise that the built-ins keep exporting) — while ' + + 'ENVIRONMENT-AUTHORED sets ' + + 'are not and must be edited by hand. `member_default` deliberately does NOT carry the ' + + 'grant, so ordinary authenticated users lose export until an admin grants it; that is ' + + 'the point of the flip, not an oversight. Merge semantics are unchanged and ' + + 'most-permissive, exactly like the CRUD bits: any set granting `true` grants export, ' + + 'and `false` is authoring intent rather than a veto, because permission sets are ' + + 'additive capability containers (ADR-0090). The super-user bits no longer confer it: ' + + '`viewAllRecords` / `modifyAllRecords` are "may see all data", not "may take a bulk ' + + 'copy". Registered (backfilled) by the stock reconciliation that compared the breaking ' + + 'changesets already on the v17 release train against this ledger: the export axis, and its ' + + 'extension to the CSV attachments scheduled reports mail out, both predate the gate that makes ' + + 'a breaking changeset state its ADR-0087 disposition. ADR-0087.', + acceptanceCriteria: + 'Every environment-authored permission set has been READ and decided, not just parsed: ' + + 'each object entry whose holders should keep exporting carries `allowExport: true`, and ' + + 'each one that should not is knowingly left without it. The verify loop is behavioural, ' + + 'because nothing fails at parse time — sign in as a holder of each affected set and ' + + 'confirm an export either succeeds or is refused as intended, including the report ' + + 'export path, which this change brought under the same axis. ⚠️ Silence is not success ' + + 'here: a deployment that upgrades without editing anything is VALID metadata whose ' + + 'ordinary users have quietly lost export, and the first sign will be a user report ' + + 'rather than an error. Check `member_default` explicitly — it is the set most likely to ' + + 'have been carrying export by inheritance.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts b/packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts new file mode 100644 index 00000000000..a19d239c22b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.export-field-meta-constraints-retired.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'export-field-meta-constraints-retired', + surface: + '@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / ' + + '.min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, ' + + 'reached as `PreparedImport.metaMap` from `prepareImportRequest`)', + replacement: + 'the object schema you already hold — read `fields[name].required` / `.system` / ' + + '`.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off ' + + 'the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ' + + 'ENGINE reads them and therefore the only copy that cannot drift', + reason: + 'ADR-0049 enforce-or-remove. These eight were never a source of truth: ' + + '`buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller ' + + 'passed in, so the map carried a second copy of facts the caller already held. ' + + "They existed for exactly one consumer — the import dry run's hand-copied " + + 'pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, ' + + 'added when the dry run was found skipping the field-level validation the real write ran) ' + + '— and the maintainer\'s 2026-08-06 ruling D (a validate-only protocol operation, so the ' + + 'dry run\'s prediction is the engine\'s verdict by construction) retired that mirror: the dry run ' + + "now asks `DataProtocol.validateData` for the engine's verdict, which reads the " + + "object's own schema. That left all eight computed on every import and read by " + + 'NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint ' + + 'vocabulary standing next to the presentation one with no enforcer behind it is ' + + 'precisely the thing an AI-authored consumer mistakes for a contract. Verified ' + + 'zero-reader before removal, per key and by type, across this repo (`packages/rest` ' + + 'itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, ' + + "verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's " + + 'identity import forwards `prepared.metaMap` into `runImport` but reads only the ' + + 'presentation keys through `coerceRow`. ' + + 'Why this needs a ledger entry despite that sweep: it is the `findStream` / ' + + '`IStorageService.list` / `actor-user-roles-to-positions` ' + + 'disposition — a published TS surface with NO spec schema, so there is no ' + + '`retiredKey()` tombstone and no parse rejection that could carry a prescription, ' + + 'and the ledger is the only channel that reaches an upgrader. It is if anything ' + + 'blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` ' + + '14.5.0) and have been published in every release since, and because they were ' + + 'OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading ' + + '`meta.required` after the upgrade gets `undefined` with no error at all — tsc ' + + 'reports at the read site only for a typed consumer. ' + + 'Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored ' + + 'or stored metadata changes shape — `required` / `min` / `maxLength` and the rest ' + + 'remain fully authorable on a field definition and fully enforced by the engine, ' + + 'which is where they always lived. The only place these eight are ever spelled is ' + + "inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can " + + 'reach them. ADR-0049 / ADR-0087; this is the removal the dry-run change deliberately ' + + 'deferred to a sweep of its own.', + acceptanceCriteria: + 'No code of yours reads any of the eight off a `buildFieldMetaMap` / ' + + '`prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / ' + + '`.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an ' + + '`ExportFieldMeta`-typed value; each hit moves to the object schema you already ' + + 'passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, ' + + 'so an untyped or `any`-typed read compiles clean and silently becomes `undefined` ' + + '— assert that the constraint your code acts on is still observed on a real import, ' + + 'not merely that the build is green. Note `hasDefault` has no one-to-one ' + + "replacement key: it was the derived predicate `defaultValue != null`, mirroring the " + + "engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply " + + 'that same `!= null` test yourself.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts b/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts new file mode 100644 index 00000000000..02776520e91 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.external-lookup-message-queue-families-retired.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'external-lookup-message-queue-families-retired', + surface: + 'data.externalLookup / data.externalDataSource / data.externalFieldMapping ' + + '(the whole of data/external-lookup.zod.ts — 3 defs, 8 exported names) and ' + + 'system.messageQueue (the whole of system/message-queue.zod.ts — ' + + 'MessageQueueConfig, MessageQueueProvider, TopicConfig, ConsumerConfig, ' + + 'DeadLetterQueue — 5 defs, 14 exported names)', + replacement: + '(removed — there is no replacement key, because there was never a key: neither ' + + 'family was reachable from any metadata-type binding, stack collection or /meta ' + + 'door, so no document could carry either. For external data: `object.external` ' + + '(`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference ' + + 'and connection credentials live in the datasource config — never inline in ' + + 'object metadata; `data/external-catalog.zod.ts` is that federated path\'s ' + + 'catalog surface and is untouched. For message queues: the LIVE surface is ' + + '`kernel/events/integrations.zod.ts`\'s `EventMessageQueueConfig` ' + + '(`EventBusConfig.messageQueue`), which deliberately carries NO credential ' + + 'field — broker connection and SASL credentials are runtime deployment ' + + 'configuration, not authorable metadata. Either capability returns via the ' + + 'ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin ' + + 'service first, the vocabulary second)', + reason: + 'Both families are the verdict of the 2026-08-12 census of spec schemas that ' + + 'permit inline credentials (fork (b): no `sys_metadata` door reaches them; ' + + 'accepted 2026-08-12): ' + + 'security-shaped declared surface with inline-credential sinks and ZERO ' + + 'consumers. `ExternalDataSourceSchema.authentication.config` is a record of ' + + 'unknown whose own docblock example wrote `"clientSecret": "..."` inline, and ' + + '`MessageQueueConfigSchema.sasl.password` was a required inline broker ' + + 'credential — the class of the `sys_metadata` cleartext-sink finding ' + + '(cleartext-at-rest credential sinks), except that unlike its two measured ' + + 'surfaces (driver config and connector `authentication`) nothing ever ' + + 'persisted these: no ' + + 'metadata-type binding (kernel/metadata-type-schemas.ts imports neither ' + + 'module), no stack collection, no object/field embedding (`object.external` ' + + 'binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/' + + 'columnMap, no authentication), and zero imports outside packages/spec ' + + 'repo-wide, with the corpus-reach control (`DatasourceSchema` under identical ' + + 'exclusions) returning hits in the same run. The consumed MQ near-namesake ' + + '`kernel/EventMessageQueueConfig` deliberately has no credential key, so the ' + + 'consumed shape had no credential and the credential-bearing shape had no ' + + 'consumer. A dead schema minus one field is still a dead schema, so the whole ' + + 'declarations go, not just the credential faces (the lesson of the plugin ' + + 'sandboxing config that was never wired to anything: an exported schema ' + + 'with no consumer reads as a capability to whoever finds it — here it read as ' + + 'an invitation to author secrets in cleartext). With no carrier key there is ' + + 'nothing to tombstone and no source or `sys_metadata` row for a D2 conversion ' + + 'to rewrite: route 3, the shape of the earlier removals of the dynamic ' + + 'plugin-loading family, the `ui/` interaction configs, the widget / i18n ' + + 'shapes and the sweep of five declared-but-inert surfaces — ' + + 'RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ' + + '⚠️ The `data/ExternalFieldMapping:transform` tombstone of the field-mapping ' + + 'transform retirement (one of that retirement\'s three spellings; the whole ' + + 'transform union left because no runtime ever executed any of its five ' + + 'members) is SUBSUMED by the def retirement, the ' + + 'WidgetManifest.performance way: it goes with the shape that carried it. The ' + + 'base `shared/FieldMapping` tombstone and the `integration/' + + 'ConnectorFieldMapping` spelling are untouched and still reject `transform` ' + + 'with that retirement\'s prescription. ' + + '⚠️ The reopen trigger of the maintainer\'s 2026-08-12 ruling on the ' + + 'cleartext sink — it closed each artefact\'s contract (Option A) and parked ' + + 'the class-level `sys_metadata` write-boundary guard (Option B) until "a ' + + 'third measured artefact-type surface" — is NOT met by this census: that ' + + 'guard stays parked; this is the ADR-0049 leg of the fork the ' + + 'triage pre-agreed.', + acceptanceCriteria: + 'No code imports `ExternalLookup(Schema|Parsed)`, `ExternalDataSource(Schema)`, ' + + '`ExternalFieldMapping(Schema|Parsed)`, `MessageQueueConfig(Schema|Parsed)`, ' + + '`MessageQueueProvider(Schema)`, `TopicConfig(Schema|Parsed)`, ' + + '`ConsumerConfig(Schema|Parsed)` or `DeadLetterQueue(Schema|Parsed)` from ' + + '`@objectstack/spec`, `@objectstack/spec/data` or `@objectstack/spec/system` — ' + + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved ' + + 'symbol identity in `data/external-lookup-retirement.test.ts` and ' + + '`system/message-queue-retirement.test.ts`). No metadata document needs ' + + 'editing, because none could ever carry one of these shapes. ' + + '`kernel/EventMessageQueueConfig` (with its inline provider enum and no ' + + 'credential key), `data/external-catalog.zod.ts`, `object.external` and ' + + '`kernel/DeadLetterQueueEntry` survive unchanged.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts b/packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts new file mode 100644 index 00000000000..ab923ba76cc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'field-runtime-create-withdrawn', + surface: 'PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)', + replacement: + 'Author the field inside its object and write the whole object — ' + + 'PUT /api/v1/meta/object/{object} with the new field in `fields` — ' + + 'or declare it in the object source (`**/*.object.ts`) and redeploy', + reason: + 'The `field` registry entry declared `allowRuntimeCreate: true` and the platform never ' + + 'built a read path for it. Measured end-to-end through the real HttpDispatcher -> ' + + 'ObjectStackProtocolImplementation -> SysMetadataRepository: ' + + "`PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with " + + '{"success":true,"state":"active","message":"Saved field …"}, the row persisted, and ' + + '`GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with ' + + 'zz_probe ABSENT — forever. The row is even self-readable by name ' + + '(`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which ' + + 'makes it well-formed and universally inert rather than malformed. The seam is that ' + + '`field` is the ONE declared type with no standalone existence: fields are authored ' + + 'inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ' + + "('field','.'), and nothing composes fragment rows into their parent — " + + "`applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` " + + '(`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot ' + + 'honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 ' + + 'rather than build the read path, which is a feature spanning at least three packages ' + + '(a composition step that does not exist, ~20 `gate.fields` call sites, physical ' + + 'schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a ' + + 'separate card — implementation first, declaration second. ' + + '⚠️ This is NOT the `api` withdrawal\'s rationale reused (`api-runtime-create-withdrawn`): ' + + 'that ruling rested on "zero ' + + 'business pull", and "add a field" is the opposite — a core Studio/CRM operation. The ' + + 'justification here is that the operation REMAINS AVAILABLE on the route that actually ' + + 'composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, ' + + 'broken SPELLING of adding a field, not the ability to add one. ' + + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored ' + + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an ' + + 'authorable one, and no authored source changes — an `**/*.object.ts` file valid before ' + + 'this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, ' + + 'so it is one semantic TODO for operators and Studio callers rather than a stack ' + + 'conversion — the same disposition `api` and `BatchOptions.validateOnly` take. ' + + 'ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No caller creates a standalone `field` item through the runtime metadata API. ' + + '`PUT /api/v1/meta/field/{object}.{name}` answers 403 with `code: "NOT_CREATABLE"` and ' + + 'a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the ' + + 'prescription `PUT /api/v1/meta/object/:object with the new field in `fields``. The ' + + 'plural spelling `PUT /api/v1/meta/fields/{object}.{name}` folds onto the singular ' + + 'and earns the same refusal — verify it, because it was a separate door until ' + + '2026-08-12. ⚠️ Verify the OBJECT route is UNAFFECTED, which is the whole point of the ' + + 'change: `PUT /api/v1/meta/object/{name}` with a new entry in `fields` still answers ' + + '200, and `GET /api/v1/meta/object/{name}` READS THE NEW FIELD BACK (assert on ' + + '`body.data.item.fields`, not `body.item`, which is undefined and makes an empty read ' + + 'look like a pass). Assert a DECLARED field is present in the same response, so a dead ' + + 'read cannot be what makes the check pass. ' + + '⚠️ The field overlay refusal is untouched and must stay: overwriting a field a code ' + + 'package ships is still 403 `NOT_OVERRIDABLE`, a different gate for a different ' + + 'question — making field OVERRIDES legal was never part of this decision. ' + + 'DISPOSITION OF EXISTING ROWS: `field` rows already written through the retired channel ' + + 'stay in `sys_metadata` and are INERT — they were inert before this change too, since ' + + 'no read path ever composed them into an object, so nothing that used to work stops ' + + 'working and no data is silently reinterpreted. They remain self-readable by name and ' + + 'still report `_diagnostics.valid: true`, which asserts only that the isolated document ' + + 'is well-formed (the envelope has no "in effect" axis). They may be deleted ' + + 'at leisure: `deleteMetaItem` is deliberately NOT gated by this refusal, so repair stays ' + + 'possible. An operator who needs the write door back on one deployment sets ' + + '`OS_METADATA_WRITABLE=field`; note this unlocks the WRITE only — the field still will ' + + 'not reach its object, which is why it is a diagnostic and not a workaround.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts b/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts new file mode 100644 index 00000000000..635650a1d90 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.filter-regex-options-retired.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'filter-regex-options-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'data.filter $regex / $options — in a STORED filter (dashboard widget filter and ' + + 'globalFilters, report runtimeFilter, page and component filter, solution-blueprint ' + + 'filter), and equally in the where clause of a query request', + replacement: + '$icontains for the case-insensitive substring match this was almost always used ' + + 'for, or $contains for a case-sensitive one — a pattern that genuinely needs a ' + + 'regular expression has no filter-level replacement', + reason: + 'Like `driver-aggregate-undeclared-key-aliases-removed` and ' + + '`driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being ' + + 'withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` ' + + 'and never a key on `StringOperatorSchema`. That is measured, not assumed — `git ' + + 'log -S\'$regex\'` over `packages/spec/src` returns only doc comments describing how ' + + '`$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: ' + + '$regex`), plus the retirement\'s own contract-half change, which added the name ' + + 'solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the ' + + 'disposition, so a reader should not have to infer it: those were driver CALL ' + + 'ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. ' + + '`FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) ' + + 'because a filter key is a field name, so a stored `{ name: { $regex: \'acme.*\' } }` ' + + 'parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open ' + + 'map, which is exactly why the ledger has to carry this. What such a stack used to ' + + 'get was four different answers from four backends: `driver-sql` and Turso\'s remote ' + + 'transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal ' + + '`a.b` and the regex was silently never a regex), `driver-memory` and objectql\'s ' + + '`having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an ' + + 'INVALID pattern was caught and answered `false` — zero rows, in silence), and ' + + '`driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. ' + + 'It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. ' + + 'There is deliberately NO D2 conversion and this sits in `semantic` rather than among ' + + 'the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in ' + + 'either direction — a regex metacharacter becomes a literal — so an auto-applied ' + + 'rewrite would silently change which rows a dashboard, report or permission filter ' + + 'selects, a wrong number rather than a missing one. Choosing the substring the ' + + 'pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers ' + + 'BOTH HALVES of the maintainer\'s 2026-08-06 ruling (option B: retire `$regex` loudly ' + + 'and add `$icontains`, rather than make five backends agree on one regex dialect), not ' + + 'just the driver one: the contract half ' + + '(the `$icontains` declaration, the `$contains` family pinned ' + + 'case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ' + + 'gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was ' + + 'never asked for a ledger entry; ' + + 'the driver half is where the refusal became executable. One surface, one ' + + 'entry, registered from the half that made it observable. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No stored filter and no request `where` spells `$regex` or `$options` — grep the ' + + 'stack for both. Each one is rewritten by asking what the pattern MEANT, not by ' + + 'transliterating it: a bare substring pattern becomes `$icontains` (or `$contains` ' + + 'when the match must stay case-sensitive), and its metacharacters are dropped rather ' + + 'than escaped, because they were never honoured as a regex on the SQL family in the ' + + 'first place. ⚠️ Expect the answer to CHANGE on any stack that ran on ' + + '`driver-memory`, `driver-mongodb` or objectql `having`, where the pattern really was ' + + 'evaluated as a regular expression; on the SQL family the rewritten filter returns ' + + 'what it always returned. A pattern that genuinely needs alternation, anchoring or ' + + 'character classes has no filter-level replacement — move that predicate into a ' + + 'formula field or a server-side view, or open an issue for it. Verify by loading the ' + + 'stack: a surviving `$regex` or `$options` is answered INVALID_FILTER / 400 with a ' + + 'message naming the replacement, on every backend.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts b/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts new file mode 100644 index 00000000000..ccccb64271b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.flow-retry-max-retries-required.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// `etl-retry-converged-onto-retry-policy` (#4962) was registered in this step and +// ABSORBED by `etl-pipeline-layer-retired` (#6414), the §0 same-major +// rule: both land in the unreleased protocol 17, and composed, the rename +// `ETLPipeline.retry.maxAttempts` -> `maxRetries` has no observable effect +// because the shape carrying it does not survive the major. Leaving both +// would tell an upgrader to rewrite a key on a schema this same upgrade +// deletes, and would break the fixture-disjointness the replay contract +// asserts. The `agent.knowledge` / `WidgetManifest.performance` precedent: +// a tombstone goes with the shape that carried it, which is strictly +// stronger than the tombstone. +export const entry: SemanticMigration = { + id: 'flow-retry-max-retries-required', + surface: "flow.errorHandling.maxRetries (under strategy: 'retry')", + replacement: 'an explicit count >= 1 (e.g. maxRetries: 3), or strategy: \'fail\'', + reason: + 'maxRetries had two defaults — FlowSchema `.default(0)` and the engine\'s ' + + '`maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 ' + + 'times through a hand-built definition. With the engine\'s copy removed the ' + + 'unstated count is unambiguously 0, and retrying zero times is exactly ' + + "`strategy: 'fail'`, so the schema now refuses the combination instead of it silently " + + 'doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow ' + + 'got but contradicts what its author wrote, and any positive count is a NEW decision ' + + 'about re-running the whole flow with its side effects. That choice is the author\'s.', + acceptanceCriteria: + "Every flow declaring `errorHandling.strategy: 'retry'` also declares " + + '`maxRetries` >= 1, and each count was chosen knowing a retry replays the flow FROM ' + + 'THE START (records re-created, callouts re-fired); flows that never actually wanted ' + + "retries say `strategy: 'fail'`. No flow fails to register with the maxRetries " + + 'prescription.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts b/packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts new file mode 100644 index 00000000000..a75cfcb3e2b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.hook-context-session-roles-retired.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'hook-context-session-roles-retired', + surface: 'data.hookContext.session.roles', + replacement: + '(removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the ' + + 'security service, which reads `permissions` / `positions` / posture off the ' + + 'execution context, ADR-0095 D3)', + reason: + 'Declared on the runtime hook context, read by exactly two consumers, produced by ' + + 'nobody. The two readers were the approvals record lock and the delegation write ' + + 'guard, each opening with `session.roles?.includes(\'admin\')`; ObjectQL\'s ' + + '`buildSession()` builds the session field by field and has never written `roles`, ' + + 'and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud\'s hook ' + + 'consumers read `hookContext?.session?.userId`; objectui\'s `roles` are the ' + + '`/auth/me` user payload, a different surface; an ACTION body\'s `ctx.session` is a ' + + 'different untyped object that does carry `roles`, tracked apart and unaffected). ' + + 'Both branches were therefore dead on ' + + 'every real engine path — an authorization decision in shape only, and a second admin ' + + 'dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. An earlier fix removed ' + + 'both readers, returning the record lock and the delegation guard to the one permission ' + + 'vocabulary; this removes the declaration, per ADR-0049 enforce-or-remove. ' + + 'This is a RUNTIME context, not stored metadata: the engine builds a HookContext per ' + + 'operation and nothing persists one, so no `sys_metadata` row, example or template ' + + 'can carry the key and there is no source for the D2 chain to rewrite — the ' + + '`openApi31` / `activationEvents` shape, one semantic TODO rather ' + + 'than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately ' + + 'not `.strict()` — a plain delete would strip it silently, as a removed field key was ' + + 'measured to be, ADR-0104), so a ' + + 'consumer that parses a context it was handed still meets the prescription. ' + + 'ADR-0049.', + acceptanceCriteria: + 'No hook reads `ctx.session.roles`; caller gating uses `ctx.session.userId` / ' + + '`ctx.session.isSystem`, and privilege comes from the security service ' + + '(`permissions` / `positions` / posture). Constructing a HookContext session with ' + + '`roles` fails `tsc` (the input type is `never`) and fails `HookContextSchema.parse` ' + + 'with the retirement prescription instead of being silently stripped. Nothing ' + + 'regresses at runtime: the key had no producer, so no decision anywhere ever saw a ' + + 'value in it.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts b/packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts new file mode 100644 index 00000000000..3996b301797 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.hook-register-empty-object-target-refused.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'hook-register-empty-object-target-refused', + surface: + "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose " + + '`excludeObjects` cancels its `object` entirely', + replacement: + "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for " + + "a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen " + + '`object` or drop the overlapping names from `excludeObjects`', + reason: + 'An earlier breaking fix established that an empty hook target is not "no target" and ' + + 'closed the shape at the ' + + "two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s " + + '`normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so ' + + 'all three spellings still registered, each producing a defect the author did not ' + + "write: `''` is FALSY, so the allow face was skipped entirely and the entry became a " + + "GLOBAL hook (that fix's headline failure mode — blank intent taking the broadest " + + "possible blast radius); `[]` and `['']` are truthy but admit no object name, so the " + + 'entry could never fire. The later `excludeObjects` face (a hook global except for the ' + + 'objects it names) then brought a ' + + 'fourth shape reached by arithmetic rather than by one bad name: an `object` list ' + + 'every member of which is also excluded admits nothing, so that entry can never fire ' + + 'either. All four are ADR-0078 silently-inert declarations, and all four are now ' + + 'refused at REGISTRATION.\n\n' + + 'No mechanical rewrite exists, in either direction. The refused values carry no ' + + "recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a " + + 'specific object name the author forgot to fill in, and those are opposite ' + + 'registrations; choosing between them is a judgment the chain cannot make. Nor could ' + + "the MATCHING read be changed instead: teaching the matcher that `''` is an " + + 'unmatchable name would silently convert a hook firing on every object into one ' + + 'firing on none — the same class of defect pointing the other way, which is why ' + + 'the `excludeObjects` change declined to do it in passing.\n\n' + + 'This is a RUNTIME registration API, not stored metadata, so — like ' + + '`hook-context-session-roles-retired` at this step — there is no `sys_metadata` row ' + + 'for the D2 chain to rewrite and the ledger entry is the notification channel. One ' + + 'metadata surface reaches it INDIRECTLY and is the reason this is not purely a ' + + "code-side note: a `record-change` flow's start node forwards `config.objectName` " + + 'verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with ' + + 'a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now ' + + "fails to bind instead, loudly — the automation engine's per-flow bind guard warns " + + 'and the `kernel:bootstrapped` binding audit re-reports it — which is the correct ' + + 'end state, but it is an observable change for that flow. ADR-0078.', + acceptanceCriteria: + 'No `registerHook` call site passes an empty `object` target, and none passes an ' + + '`excludeObjects` list covering every name in its `object` list. Every `record-change` ' + + 'flow start node declares a non-blank `config.objectName`, or omits the key if the ' + + 'flow is genuinely meant to fire on every object. Boot completes with no ' + + '"[ObjectQL] Hook ... declares an empty `object` target" throw and no ' + + '"[record-change] ... not bound" warning naming a flow you expect to fire.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts b/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts new file mode 100644 index 00000000000..25e185f655b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.http-request-errors-total-retired.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'http-request-errors-total-retired', + surface: + 'observability.SEMCONV.httpRequestErrorsTotal (the published metric name ' + + 'http_request_errors_total{method,route}, and its emission from the runtime ' + + "dispatcher's per-route wrapper)", + replacement: + 'the 5xx rate is `http_requests_total{status=~"5.."}` — the TRANSPORT emits that family ' + + 'through the `IHttpServer.afterResponse` seam, so it covers every inbound surface; ' + + 'unhandled-exception rate specifically, which is the one thing the retired counter ' + + 'uniquely reported, is the `errorReporter` (Sentry / Datadog / your adapter), which ' + + 'still fires on every 5xx throw', + reason: + 'ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` ' + + 'published `http_request_errors_total` as part of a stable namespace declared "so ' + + 'hosts can wire alerts/dashboards against it", but the only emitter was ' + + '`@objectstack/runtime`\'s `instrumentRouteHandler`, applied only by the dispatcher\'s ' + + 'own route Proxy — so the series never saw auth\'s `getRawApp()` mount, the REST data ' + + 'API via `RouteManager`, or any other inbound surface. Its two siblings in the same ' + + 'family were moved to the transport seam (the request counter, then the latency ' + + 'histogram, both through the response-observing hook the transport was given) and this ' + + 'one could not follow: `HttpResponseObservation` carries ' + + '`{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every ' + + 'transport-side shape would have counted a DIFFERENT population rather than the same ' + + 'one more widely. The divergence was measured in both directions — the dispatcher ' + + 'answers its own errors through `errorResponseBase`, which sets a status and does not ' + + 're-throw, so the old counter MISSED those, while its `catch` incremented ' + + 'unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` ' + + 'already carries a `status` label, so a status-class error counter would be fully ' + + 'derivable from data the transport already publishes. Maintainer ruling 2026-08-20 ' + + '(option C of four presented, over B "move it to the transport as a status class" and ' + + 'D "keep it dispatcher-scoped and rename it"): RETIRE. A metric NAME is a RESPONSE ' + + 'surface, not authorable metadata — no stack, example or template carries it, so there ' + + 'is no source for a D2 conversion to rewrite and no schema to tombstone; a host names ' + + 'the series in its own dashboard or alert file, outside this repo. That is exactly why ' + + 'this entry exists: for an operator whose Grafana keys on the string, the ledger is the ' + + 'only notification channel there is. Same disposition, and the same reason, as ' + + '`runtime-httpserver-wrapper-retired` and `enhanced-api-error-field-errors-renamed`. ' + + 'ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No dashboard, alert rule or exporter config names `http_request_errors_total`: the ' + + 'series stops receiving samples the moment 17.2.0 is deployed, so a panel keyed on it ' + + 'draws a flat zero that reads as a healthy server rather than as a removed metric — ' + + 'the one failure mode this retirement can produce, and the reason the changeset ' + + 'announces it loudly. A 5xx-rate panel or alert is rewritten to ' + + '`http_requests_total{status=~"5.."}` and then PROVEN wider, not merely non-empty: ' + + 'make an auth route or a REST data-API route answer 5xx and confirm the new query ' + + 'moves, where the retired counter would not have moved at all. If the signal you were ' + + 'actually alerting on was "a handler threw rather than returning an error envelope", ' + + 'that is the `errorReporter`, not a counter — wire an APM adapter and assert one ' + + 'synthetic 5xx throw arrives. In code, `SEMCONV.httpRequestErrorsTotal` and ' + + '`RUNTIME_METRICS.httpRequestErrorsTotal` no longer resolve (tsc reports TS2339 at any ' + + 'surviving read) and no `metrics.counter` call names the string.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts b/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts new file mode 100644 index 00000000000..84c5736db4f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.http-server-runtime-vocabulary-retired.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'http-server-runtime-vocabulary-retired', + surface: + 'system.serverEvent / system.serverEventType / system.serverCapabilities / ' + + 'system.serverStatus (the lifecycle-event, capability-report and status vocabulary of ' + + 'system/http-server.zod.ts — 4 defs, 8 exported names)', + replacement: + '(removed — there is no replacement key, because there was never a key. Server lifecycle ' + + 'is the transport plugin\'s own start/stop seam; per-request and per-server ' + + 'observability is `system/metrics.zod.ts` and `system/logging.zod.ts` (plus ' + + '`OS_SERVER_TIMING` for timings), and liveness is the `/health` endpoint. What a ' + + 'transport plugin can DO it states by implementing the kernel plugin contract — the ' + + 'seams it registers are the capability statement, and a self-described capability ' + + 'record can only disagree with them. Server-level configuration that IS authorable ' + + 'lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)', + reason: + 'The second and final ADR-0049 pass over `system/http-server.zod.ts`. The first removed ' + + 'the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring ' + + 'entry); this removes the RUNTIME half — a 7-member lifecycle event union with a ' + + 'timestamped envelope, an eight-boolean capability report, and a five-state status ' + + 'record with connection and request counters. Nothing ever emitted, consumed or ' + + 'parsed any of them. ' + + 'This card was HELD for four days rather than queued, on a specific and legitimate ' + + 'doubt: a response/capability vocabulary can be a REFERENCE surface for host ' + + 'implementers, so "zero consumers in this repo" is weaker evidence for one of those ' + + 'than for an authorable key (the CSS-variable rebuttal). The hold was lifted by ' + + 'measuring the reference reader itself rather than by re-running the same grep: ' + + '`plugin-hono-server`, the one in-tree host implementation, neither implements nor ' + + 'reports any of the three — it names no capability record, no status shape and no ' + + 'event union, and what it registers is routes and middleware through the kernel ' + + 'plugin contract. A declaration-site grep put every declaration in this one file, a ' + + 'quoted-name sweep across objectstack and objectui found no reader outside it, and ' + + 'the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, ' + + 'resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in ' + + 'this file when there was one. ' + + 'With no carrier key there is nothing to tombstone, and with no author there is no ' + + 'source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR ' + + 'plus this entry are the declaration — route 3, the same shape as the config half\'s ' + + 'removal in this very file and the earlier removals of the dynamic plugin-loading family, ' + + 'the `ui/` interaction configs and the widget / i18n shapes. If host-implementer ' + + 'conformance becomes a real ' + + 'requirement it returns through the ENFORCE route: an adapter contract with a checker ' + + 'behind it, vocabulary second. ADR-0049.', + acceptanceCriteria: + 'No source imports `ServerEvent`, `ServerEventType`, `ServerEventSchema`, ' + + '`ServerCapabilities`, `ServerCapabilitiesSchema`, `ServerCapabilitiesParsed`, ' + + '`ServerStatus` or `ServerStatusSchema` from `@objectstack/spec/system` — a grep over ' + + 'consumer code resolves none of them, and `tsc` reports TS2724/TS2305 on any that ' + + 'survives. The route-registration half of the same module still resolves ' + + '(`RouteHandlerMetadataSchema`, `MiddlewareType`, `MiddlewareConfigSchema`, ' + + '`MiddlewareConfig`), and `StackServerConfigSchema` — the one authorable server ' + + 'surface — is untouched: a stack declaring `server: { trustProxy, security }` parses ' + + 'exactly as it did in 16.x.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts b/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts new file mode 100644 index 00000000000..a443ce5620d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.import-run-automations-declared-default-corrected.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'import-run-automations-declared-default-corrected', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). + surface: + 'api.ImportRequest runAutomations — the declared default of the key on BOTH import ' + + 'bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST ' + + '/api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema ' + + 'object). It was declared default(false) and described as "off by default for ' + + 'bulk"; it is now default(true), which is what the server has always done', + replacement: + 'an explicit runAutomations: false on any import request that is meant to load rows ' + + 'without firing triggers/hooks. That spelling is unchanged and has always been the ' + + 'only one the server read — what changes is that omitting the key now DECLARES what ' + + 'it already DID. Callers who want automations on need write nothing', + reason: + 'A DECLARATION corrected to match a runtime that did not move — the inverse of a ' + + "behaviour flip, and registered here for the reason protocol 12's " + + '`rest-requireauth-default-flip` and this major\'s ' + + '`action-descriptor-resume-authority-default-flip` are: whether a given import was ' + + 'meant to fire triggers is a judgment no transform can make, so the prescription is ' + + 'a TODO rather than a rewrite. The server decides in import-prepare.ts with ' + + '`body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has ' + + 'since the flag was first honoured — automations always ran on import historically ' + + '(the engine ignored the flag entirely before then), ' + + 'so opt-out was made the explicit act, matching ' + + 'platform convention. The schema said the opposite in both machine-readable and ' + + "human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s " + + 'JSON Schema, and the describe prose in the published reference tables for both ' + + 'defs. ' + + '⚠️ Nothing in this repo reconciled the two and NO deployed caller changes ' + + 'behaviour: no request path parses an import body through this schema — the route ' + + 'reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is ' + + 'the declarative `ImportJobApiContracts` catalog entry, a declaration and not a ' + + 'parse. That is exactly why this needed a ruling rather than a docs edit: the ' + + 'divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. ' + + 'A client or SDK that validated its request through the published schema ' + + 'materialised `runAutomations: false` from the declared default and sent it ' + + 'explicitly, and the server honoured it — so the same request body produced ' + + 'opposite behaviour depending on whether the caller validated before sending, with ' + + 'the validating caller silently losing its triggers. Nothing rejected it, nothing ' + + 'warned, and the reference page told an author the wrong thing in the other ' + + 'direction. There is deliberately NO schema tombstone and no D2 conversion: no key ' + + 'is removed, and an HTTP request body is neither authored nor persisted — the same ' + + 'disposition `notification-list-cursor-retired` takes for the sibling ' + + 'default on this major, and `batch-options-validate-only-retired` before it. The ' + + 'declared move itself is recorded mechanically, per key, in ' + + 'DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped ' + + 'default was found invisible to every gate — whose `from`/`to` fingerprints are ' + + 're-derived on every build. Maintainer ruling 2026-08-09, disposition A: ' + + 'the spec follows the runtime. ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'Every import request of yours that must NOT fire triggers sends `runAutomations: ' + + 'false` explicitly, rather than omitting the key and trusting the old declared ' + + 'default. The check is worth doing precisely where it looks unnecessary: if you ' + + 'build the body by parsing it through `ImportRequestSchema` (or the published JSON ' + + 'Schema) and then send the PARSED object, your bulk loads were running with ' + + 'automations OFF and will now run with them ON — that is the only class whose ' + + 'behaviour changes, and it changes toward what an unvalidated caller always got. ' + + '⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as ' + + 'such: a body that omits `runAutomations` fired triggers before this change and ' + + 'fires them after, and `runAutomations: false` turns them off before and after. ' + + 'Nothing starts being refused — the route never validated this body against the ' + + 'schema and does not begin to. `dryRun` is unaffected and still runs NO automations ' + + 'whatever the flag says: it asks the engine\'s validate-only write path for its verdict, ' + + 'and that path deliberately fires no hooks.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts b/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts new file mode 100644 index 00000000000..2a4f0fa325b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.job-retry-policy-constraints-tightened.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'job-retry-policy-constraints-tightened', + surface: 'job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)', + replacement: 'maxRetries <= 10, and backoffMultiplier >= 1', + reason: + 'The RetryPolicy converged onto one declaration from its automation and system copies ' + + 'keeps the automation side\'s bounds, which the job ' + + 'side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. ' + + 'Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a ' + + 'retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay ' + + 'that SHRINKS on each attempt — retrying a failing dependency ever faster, which is ' + + 'the opposite of backoff and was never a shape the engine meant to offer. Both now ' + + 'fail at parse time with the bound named, rather than being silently reinterpreted. ' + + 'Choosing the replacement count (or accepting the cap) is the author\'s call.', + acceptanceCriteria: + 'Every job declaring `retryPolicy` parses: no `maxRetries` above 10 and no ' + + '`backoffMultiplier` below 1 remain, and each adjusted value was re-chosen knowing a ' + + 'retry re-runs the handler with its writes and callouts. No job fails to register ' + + 'with the retry-policy bound prescription.', + relevantWhen: { kind: 'stack-declares', keys: ['jobs'] }, +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts new file mode 100644 index 00000000000..408acc95e9d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.notification-list-cursor-retired.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'notification-list-cursor-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell (see the note on `spec-type-alias-input-suffix-retired`). + surface: + 'api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications ' + + '(ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor ' + + 'argument of the client SDK call client.notifications.list(). The same entry covers ' + + 'the limit default: the request schema no longer declares default(20)', + replacement: + 'a larger `limit` — the route answers the newest N notifications and has no page 2. ' + + 'There is no replacement for `cursor`, deliberately: nothing ever minted one, so no ' + + 'caller holds a value to carry over. Callers that looped on it were re-reading the ' + + 'first window and should read one window sized to what they display (the Console ' + + 'bell polls exactly this way). For the removed `limit` default, send the number you ' + + 'want explicitly if you were relying on 20 — omitting it takes the server window, ' + + 'which is 50 on the platform inbox and clamped into 1..200, and has been since ' + + 'before the declaration existed', + reason: + 'One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, ' + + 'Option A, ruled jointly with the repair that made `unreadCount` really count the whole ' + + 'inbox). `cursor` was declared on the request and on ' + + 'the response and honoured on neither: the dispatcher domain reads `read` / `type` / ' + + '`limit` and nothing else, and no emit site has ever written the response key. It ' + + 'was worse than inert because it had a shipped PRODUCER — the SDK appended it to the ' + + 'query string — so a caller paginating by the published contract looped on page 1 ' + + 'forever, with no error and no 400. Measured over a real boot with 60 unread before ' + + 'the removal: page2 === page1, both parsing green against the response schema, which ' + + 'is why no conformance gate could see it. ' + + 'This is `data.query.cursor` (`query-cursor-retired`) one layer up, with the ' + + 'same verdict for the same reason, down to deleting the SDK producer alongside the ' + + 'key. A first-class inbox cursor, if one is ever designed, will be a ' + + 'response-minted opaque token — a different API — so keeping this one preserved a ' + + 'wrong design rather than a roadmap. ' + + 'The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the ' + + 'number: no request path parses a query string through this schema (the fix for ' + + 'request bodies never checked against their declared schemas wired the ' + + "catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never " + + 'stamped anything onto anything, and the server has always applied its own 50. ' + + 'Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a ' + + 'declaration that does not execute and merely made it coincide with the ' + + 'implementation until someone moved the clamp; `.optional()` plus prose is true ' + + 'about both the schema and the server. No constraint (`.int()` / `.max(200)`) is ' + + 'declared either, because the service CLAMPS an out-of-range limit rather than ' + + 'refusing it, and declaring a rejection the wire does not perform is the same defect ' + + 'mirrored. ' + + 'Route 2, and the split is worth stating exactly because the two halves of the ' + + 'bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, ' + + 'so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept ' + + 'sending — a clean parse and a parameter that never takes effect, which is this ' + + "issue's own defect re-created one layer down (the silent strip measured when a field " + + 'key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). So ' + + '`cursor` is ' + + '`retiredKey()` on both halves, typed `never` for tsc and raising the prescription ' + + 'at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is ' + + 'NO D2 conversion: a conversion rewrites an authored source or a stored ' + + '`sys_metadata` row, and these two shapes are HTTP-only — nobody authors a ' + + '`ListNotificationsRequest` and nothing persists one. Request AND response shapes: ' + + 'two semantic TODOs for API callers, no stack conversion — the same disposition ' + + '`BatchOptions.validateOnly` (a declared dry-run that wrote for real) and the ' + + '`AnalyticsQueryRequest` envelope keys ' + + 'already take in this major. The `limit` default is declared separately and ' + + 'mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot ' + + 'where a default or constraint change on an authorable key was recorded by no gate), ' + + 'whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes ' + + 'it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess ' + + 'property), which is the enforced channel — the removal is loud at compile time for ' + + 'every TypeScript consumer. Reading `response.cursor` no longer type-checks either, ' + + 'and always answered `undefined` before. ⚠️ Behaviour on the wire is deliberately ' + + 'UNCHANGED and must be verified as such: a request still carrying `?cursor=…` is ' + + 'IGNORED, not refused — the domain reads three named query keys and no route ' + + 'validates this query against a schema, so an unknown key has never produced a 400 ' + + 'and does not start doing so here. The declaration stopped promising what the wire ' + + 'never did; the wire did not change. `unreadCount` is untouched (it was the jointly ' + + 'ruled repair\'s business) and still ' + + 'reports the total across the whole matching inbox rather than the window. A caller ' + + 'that omitted `limit` receives the same 50 rows it always received.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts b/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts new file mode 100644 index 00000000000..6cd25d22a99 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.package-uninstall-explicit-all-tenants.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'package-uninstall-explicit-all-tenants', + surface: 'protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)', + replacement: 'explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it', + reason: + 'An uninstall that named no organization matched EVERY organization\'s rows — measured ' + + 'at 5 of 5 deleted, including a foreign org\'s, while uninstall\'s orphaned-row defect was ' + + 'being repaired. That width was never ' + + 'chosen; it fell out of a missing argument, and the two transports of the same route ' + + 'disagreed because of it. In protocol 17 the call is REFUSED instead: neither ' + + '`organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and ' + + 'deletes nothing, as does supplying both (they are contradictory, not redundant). ' + + 'Whether a given caller meant "this tenant" or "every tenant" is an intent no ' + + 'transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an ' + + 'accidental org-less call and a deliberate environment-wide one are byte-identical ' + + 'at the call site — which is the whole reason the parameter had to become explicit ' + + 'rather than conventional. Nothing in authored metadata spells this: it is a runtime ' + + 'call-site contract, so it is one semantic TODO for operators and API callers rather ' + + 'than a stack conversion — the same disposition `rest-requireauth-default-flip` (protocol 12) ' + + 'takes for its own default flip.', + acceptanceCriteria: + 'Every caller of `deletePackage` states its tenant scope. A caller that intends an ' + + 'environment-wide uninstall passes `allTenants: true`; a caller that intends a scoped ' + + 'one passes `organizationId`; no caller passes both. An explicit `allTenants: false` ' + + 'is treated as undeclared and refused, since it is not an affirmative request for ' + + 'cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 ' + + '`TENANT_SCOPE_REQUIRED` reaching a deploy script that previously "succeeded" means ' + + 'that script was relying on the cross-tenant reading and must now say so on purpose. ' + + 'The org-scoped path is unchanged — an uninstall carrying an `organizationId` still ' + + 'removes that org\'s rows AND the environment-wide (`organization_id IS NULL`) rows, ' + + 'exactly as the orphaned-row repair left it.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts b/packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts new file mode 100644 index 00000000000..efc4ec40690 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.plugin-activation-events-retired.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'plugin-activation-events-retired', + surface: + 'kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents', + replacement: + '(removed — delete the key. Every plugin activates immediately on load/registration, ' + + 'which is the only behaviour that has ever existed; `activate()` still runs at ' + + 'registration time. Lazy activation, if built, returns via the enforce route of ' + + 'ADR-0049 through a new ADR, with a vocabulary its executor actually honours)', + reason: + 'Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary ' + + 'they embedded (`onCommand` / `onRoute` / … / `onView`, once the kernel and studio ' + + 'copies had converged on the kernel\'s structured `{ type, pattern }` shape) — ' + + 'promised lazy plugin activation ("plugins remain dormant until an activation event ' + + 'fires") that no runtime in objectstack, cloud, cloud-v1 or objectui ever ' + + "implemented: nothing anywhere read the key, every plugin activates immediately, and " + + "cloud-v1's own ROADMAP recorded lazy activation as unimplemented (planned v0.4.0). " + + 'That is the ADR-0049 false-compliance shape in the semantically-lying direction: an ' + + 'author writing `activationEvents: [{ type: \'onMetadataType\', pattern: \'flow\' }]` ' + + 'expected deferral and got eager activation with a clean parse. Neither parent shape ' + + 'is stored metadata — `StudioPluginManifest` is TS configuration parsed by ' + + '`defineStudioPlugin` (a root schema, never part of a stack tree) and ' + + '`DynamicLoadRequest` is a runtime request shape with no caller — so no ' + + '`sys_metadata` row can carry the key and there is no source for the D2 chain to ' + + 'rewrite; this entry is the D3 record. The kernel key is tombstoned via ' + + '`retiredKey()` (its schema is not `.strict()`; a plain delete would strip an ' + + "authored value silently), the studio key is rejected by the strict manifest parse " + + 'with a guidance prescription (as are its former VS Code-flavoured aliases ' + + '`activation` / `events` / `onActivate`), and the orphaned `ActivationEventSchema` / ' + + '`ActivationEvent` exports are removed from `./kernel` and `./studio` with the keys ' + + '(the lesson of the unwired plugin sandboxing / integrity / approval config removed ' + + 'before this: an exported schema with no consumer is read as a capability). Both keys ' + + 'took ADR-0049\'s REMOVE answer, not ENFORCE, while protocol 17 was still unreleased. ' + + 'SUPERSEDED ON THE KERNEL SIDE by the maintainer\'s REMOVE ' + + 'ruling on the rest of the plugin-runtime family (same unreleased major): the whole ' + + '`DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — ' + + 'was removed, which took this key\'s `retiredKey()` tombstone with it. That is ' + + 'strictly stronger than the tombstone, not weaker: there is no longer a ' + + '`DynamicLoadRequest` to author the key INTO, so the prescription an author needs ' + + 'is no longer "delete this key" but "this request shape does not exist" (see ' + + '`plugin-runtime-family-retired`). The studio half of this entry is ' + + 'unaffected and still enforced by the strict manifest parse.', + acceptanceCriteria: + 'No `defineStudioPlugin` input authors `activationEvents` — authoring it is an ' + + 'unknown key on the strict studio manifest and a parse error carrying the ' + + 'prescription. On the kernel side the stronger criterion of the plugin-runtime ' + + 'family\'s removal applies instead: ' + + 'there is no `DynamicLoadRequest` type or schema left to author it into at all. No ' + + 'code imports `ActivationEventSchema` / `ActivationEvent` from ' + + '`@objectstack/spec/kernel` or `@objectstack/spec/studio` (TS2305 after upgrade). ' + + 'Runtime behaviour is byte-identical: plugins loaded eagerly before and after.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts b/packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts new file mode 100644 index 00000000000..dc6b9dbb553 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.plugin-manifest-loading-retired.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'plugin-manifest-loading-retired', + surface: + 'manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / ' + + 'initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)', + replacement: + 'nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` ' + + 'registers them and the kernel runs `init` then `start` in an order topologically ' + + "resolved from each composed plugin's own `dependencies` / `optionalDependencies` " + + '(`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation ' + + '`loading.sandboxing` appeared to configure, note that the plugin trust tier ' + + '(`manifest.runtime`, ADR-0025 §3.6) does not supply it either: that tier is enforced ' + + 'at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the ' + + '`node` tier is rejected with HTTP 422 and forced to manual review), while load-side ' + + 'enforcement is NOT implemented, so a locally installed plugin is not isolated by the ' + + 'tier it declares. ⛔ Nor do the manifest permission declarations give it back: ' + + 'the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load ' + + 'and queried by nothing, so it refuses no operation. Neither surface confines a ' + + 'plugin today — do not author either one expecting isolation', + reason: + 'ADR-0049 enforce-or-remove; the maintainer ruled REMOVE on 2026-08-04, on condition ' + + 'that a bare-name sweep of cloud and objectui came back clean first. The block declared a ' + + 'complete plugin loading policy and NOTHING read it. A bare-name scan of all three ' + + 'repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), ' + + 'each with a control probe proving the scan saw the tree — put every hit inside ' + + '`packages/spec` itself: this module\'s own declaration, its own unit tests, the ' + + '`Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero ' + + 'readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key ' + + 'parsed, entered the manifest, and changed nothing — an exported schema with no ' + + 'consumer, read as a capability, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared ' + + 'process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ' + + 'ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform ' + + 'isolates plugins, wrote the config, and received a clean parse and zero isolation. An ' + + 'inert security control is worse than an absent one because it is believed. Hot reload ' + + 'was additionally a TWO-SOURCE defect: the docs pointed at this dead ' + + '`PluginHotReloadSchema` while the only implementation body, `HotReloadManager` ' + + '(`packages/core/src/hot-reload.ts`), reads a different vocabulary — ' + + '`HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on ' + + 'the surviving side: that schema is KEPT as the starting point for a future enforce ' + + 'decision (it has an implementation body but no runtime composes it yet), and ' + + 'enforcing it is deliberately a separate decision, not this retirement. ' + + '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. A ' + + 'package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` ' + + 'entry, so a manifest is not a stack collection member and a stored manifest row ' + + 'passes that seam through unchanged. A conversion would be a transform with no seam ' + + 'that ever runs.', + acceptanceCriteria: + 'No `objectstack.plugin.json` and no stored package manifest carries a `loading` key. ' + + 'The enforced channel is the one place a manifest is parsed with an author present: ' + + '`os plugin build` runs `ManifestSchema.safeParse` and exits non-zero, printing the ' + + 'tombstone prescription, so a manifest still declaring `loading` fails its build ' + + 'rather than shipping. TypeScript authors get it earlier still — `loading` is typed ' + + '`never`, so assigning it is a `tsc` error. ⚠️ Runtime behaviour is deliberately ' + + 'UNCHANGED and must be verified as such: nothing ever read the block, so removing it ' + + 'removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries ' + + '`loading` keeps working — the registry\'s `validate()` is an explicit diagnostic and ' + + 'not a gate (it catches, logs `[metadata_spec_invalid]`, and registers the item ' + + 'anyway, deliberately, so bad metadata is never a data outage), so such a row ' + + 'degrades to one log line at registration rather than a boot failure. Clear it by ' + + 'deleting the key from the source manifest and reinstalling.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts b/packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts new file mode 100644 index 00000000000..1897bb610b1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.plugin-runtime-family-retired.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'plugin-runtime-family-retired', + surface: + 'kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult ' + + '/ kernel.pluginSource / kernel.dynamicPluginOperation', + replacement: + '(removed — there is no replacement shape, because there is no operation to describe. ' + + 'Plugins are composed at boot: `defineStack` registers them and the kernel runs ' + + 'register → init → start; the set is fixed until the process restarts. Delete the ' + + 'import and the value. Runtime plugin loading, if it is ever built, returns via the ' + + 'enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)', + reason: + 'The five schemas declared the "Dynamic Loading" capability — runtime load / unload / ' + + 'reload of plugins without a kernel restart, with sandboxing, integrity hashes, ' + + 'drain strategies and dependent-cascade policy — and NOTHING implemented it. A ' + + 'bare-name scan of objectstack, cloud and objectui found zero references outside ' + + "this package's own declaration, its unit tests and the generated artifacts: no " + + 'runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced ' + + 'a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most ' + + 'inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the ' + + 'published IDE bundle as proof the platform hot-loads plugins and constructs a ' + + 'request that parses clean and is received by nobody (an exported schema with no ' + + 'consumer is read as a capability). The earlier removal of this module\'s ' + + 'discovery/sandbox config island — plugin sandboxing, integrity and approval settings ' + + 'that nothing read — left these five in place explicitly: ' + + '"operation contracts, not security promises; the enforce-or-remove call on them is ' + + 'a design decision rather than a correction" — but that suspension lived only in a ' + + 'changeset paragraph with no issue carrying it. The maintainer\'s ruling of ' + + '2026-08-03 is that decision, answered REMOVE: hot loading is a real future ' + + 'capability, but nothing is being built and nothing pulls it, and when it is built ' + + 'its vocabulary enters the schema with the implementation. `experimental` was ' + + 'considered and rejected: it is only `.describe()` prose ' + + 'and cannot stop an import, the weakest of the three ADR-0049 channels. None of the ' + + 'five is stored metadata — they are root request/result payload shapes embedded in ' + + 'no parent schema and parsed against no metadata document — so no `sys_metadata` ' + + 'row can carry one and there is no source for the D2 chain to rewrite; this entry ' + + 'is the D3 record. The removal also subsumes the kernel half of ' + + '`plugin-activation-events-retired`: that tombstone goes with the shape ' + + 'that carried it. ADR-0049.', + acceptanceCriteria: + 'No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, ' + + '`DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` ' + + 'or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, ' + + '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, ' + + '`DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from ' + + '`@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after ' + + 'upgrade, on every public entry (pinned by symbol identity in ' + + '`plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because ' + + 'nothing called anything: a caller that believed it was hot-loading a plugin was ' + + 'already only building an object. Boot-time composition through `defineStack` is ' + + 'unchanged.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts b/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts new file mode 100644 index 00000000000..e3dc2759e58 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.position-permissions-column-retired.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'position-permissions-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_position.permissions — the "JSON-serialized array of permission strings" ' + + 'textarea column left the platform position table declared by plugin-security ' + + '(packages/plugins/plugin-security/src/objects/sys-position.object.ts), together ' + + 'with the clone_position copy entry that carried it between rows', + replacement: + 'nothing on this table — delete the key from any authored `sys_position` seed row ' + + '(stack `data` entries) or data-door write that still carries it. There are no ' + + 'direct position-level permission strings anywhere on the platform: capability ' + + 'reaches a position ONLY through permission-set bindings ' + + '(`sys_position_permission_set` rows, created in Setup or by an app\'s ' + + 'kernel:ready binder) and is resolved from the position `name` at request time. ' + + 'A value that was recording intent as documentation belongs in `description`, ' + + 'which remains declared', + reason: + 'Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ' + + 'ADR-0049 enforce-or-remove: REMOVE. The ' + + 'object-scoped census (all sys_position-naming files, with same-object positive ' + + 'controls resolving `active` / `delegatable` / `is_default` / `name` to real ' + + 'readers) measured the column at zero on both sides: the only row writers — the ' + + 'builtin and declared position bootstrappers — set label / description / ' + + 'managed_by / active / is_default, and position→grant resolution consults ' + + '`sys_position_permission_set` rows plus the position `name`, never this column. ' + + 'Its only in-repo reference was the clone_position action copying it between ' + + 'rows — a copy of a value nothing writes. objectui was searched under the same ' + + 'discipline (evidenceScope closure): no console surface names the column — the ' + + 'position pickers and Setup views read name / label / id only, so a designer ' + + 'preview consumer does not exist either. That left a declared free-text grant ' + + 'catalogue on a security object that no runtime enforced: an author — human or ' + + 'AI — who filled it believed they granted permission strings directly on the ' + + 'position, and nothing refused or honoured the value. ' + + 'This is a platform-object COLUMN retirement, not a spec-key retirement, so the ' + + 'bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands ' + + 'in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never ' + + 'declared `permissions`, and the surface ratchets are expected byte-identical), ' + + 'no liveness-ledger row is added (the ledger walks PositionSchema\'s shape, ' + + 'which never carried the key — a row would be an orphan), and the disposition ' + + 'is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain ' + + 'rewrites seed rows today and the measured author base is zero, while the loud ' + + 'channel already exists at runtime — the engine schema preflight refuses an ' + + 'undeclared field with 400 INVALID_FIELD before the driver or any hook runs — ' + + 'so this entry carries the prescription and the refusal carries the ' + + 'enforcement. The live-authoring half is the PositionSchema strict-parse ' + + 'guidance for `permissions`, which names the binding table in the rejection. ' + + '⚠️ Existing physical columns are deliberately untouched: schema sync is ' + + 'additive (ADR-0045), so a deployed database keeps the column; the platform ' + + 'stops declaring, projecting or accepting it. Zero producers means no rows are ' + + 'expected to carry a value; no backfill or destructive DDL is required or ' + + 'wanted. If position-level direct grants ever become a real need, the column is ' + + 're-declared then, WITH a runtime reader in the same PR — declare-and-enforce ' + + 'or do not declare.', + acceptanceCriteria: + 'No authored stack seeds `permissions` on a sys_position record, and no client ' + + 'write to that table carries the key. Concretely: (1) grep your stack sources ' + + 'for permissions next to sys_position — delete the key from any seed row; prose ' + + 'that was documenting intent belongs in `description`. (2) Boot and load your ' + + 'stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming ' + + 'the column — that refusal is the enforced channel, not a silent drop. (3) If ' + + 'you meant to grant capability, author it where it is enforced: bind permission ' + + 'sets to the position (`sys_position_permission_set` rows, created in Setup or ' + + 'by an app\'s kernel:ready binder) — the authz resolver then expands the ' + + 'bindings from the position name at request time.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts new file mode 100644 index 00000000000..025164529d7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.query-array-string-agg-retired.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'query-array-string-agg-retired', + surface: "data.query.aggregations[].function ('array_agg' / 'string_agg')", + replacement: + 'an ordinary `fields` query, shaped in the caller — or a stored field that materialises ' + + 'the roll-up. For a deduplicated COUNT the live spelling is unchanged: ' + + '`count_distinct` stays declared', + reason: + 'The stored half of this retirement is a conversion ' + + '(`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. ' + + '`QueryAST` is never stored in stack metadata — it is the client SDK builder\'s output ' + + 'and the `POST /data/:object/query` body — so there is no source for the chain to ' + + 'rewrite and callers move their own queries. Both values were declared-but-unlowered ' + + 'on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso ' + + '`RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller ' + + 'following the schema against a SQL datasource got a refusal, not an array. They did ' + + 'run on `driver-mongodb` and on the engine\'s in-memory fallback, which is what makes ' + + 'this the one narrowing in the batch that removes reachable behaviour: an aggregation ' + + 'that worked on one backend and failed on another is exactly the unpredictability the ' + + 'ruling ended, and the maintainer\'s 2026-08-05 investment freeze on driver-memory and ' + + 'driver-mongodb had both of those backends frozen at the time (that freeze ' + + 'was lifted on 2026-08-11). `count_distinct` was ' + + 'deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049\'s ' + + 'enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049.', + acceptanceCriteria: + 'No caller sends `array_agg` or `string_agg` in `aggregations[].function`; list-style ' + + 'roll-ups are assembled by the caller from an ordinary `fields` query, or materialised ' + + 'as a stored field. A query still carrying either value fails to parse with the ' + + 'removal prescription naming it, and authoring it is a `tsc` error at the call site; ' + + '`count_distinct` continues to parse and is unaffected.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts new file mode 100644 index 00000000000..1ac0e9c7296 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.query-cursor-retired.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'query-cursor-retired', + surface: 'data.query.cursor', + replacement: + 'a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` ' + + 'with the matching `orderBy` (the documented manual-keyset pattern)', + reason: + 'The `cursor` key promised keyset pagination and no driver implemented it: the cursor ' + + 'was accepted and ignored, so every page came back identical — a caller looping ' + + '"until hasMore is false" never terminates. Worse than inert, it had a shipped public ' + + 'producer (`QueryBuilder.cursor()`, removed with the key). The caller-built ' + + '`Record` shape also leaks sort/storage detail and squats on the ' + + 'reserved REST parameter set; a first-class cursor, if ever designed, will be a ' + + 'response-minted opaque token — a different API, so keeping this one preserved a ' + + 'wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to ' + + 'rewrite. ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No caller sends `cursor` and no SDK call site uses `QueryBuilder.cursor()`; deep ' + + 'pagination expresses the keyset as a `where` predicate on the sort key. A query ' + + 'still carrying `cursor` fails to parse with the removal prescription, and authoring ' + + 'it is a `tsc` error.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts new file mode 100644 index 00000000000..b2a05c80313 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.query-distinct-retired.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'query-distinct-retired', + surface: 'data.query.distinct', + replacement: + '`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated ' + + "counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values", + reason: + 'The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it ' + + 'was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list ' + + 'path treated a distinct query as not countable and silently degraded ' + + '`total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND ' + + 'worse pagination metadata, and a side effect that "confirmed" the flag was doing ' + + 'something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with ' + + 'the key). The count suppression is deleted in the same change — `total` is truthful ' + + 'for those queries again. A REQUEST surface, never stored; nothing to rewrite. ' + + 'ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No caller sends `distinct` and no SDK call site uses `QueryBuilder.distinct()`; ' + + 'deduplication goes through `groupBy` / `count_distinct` / the drivers\' `distinct()` ' + + 'door. A query still carrying the key fails to parse with the removal prescription, ' + + 'and the REST list response reports a real `total` for queries that used to send it.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts new file mode 100644 index 00000000000..47122d33cfd --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.query-field-node-object-form-retired.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'query-field-node-object-form-retired', + surface: 'data.query.fields', + replacement: + "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested " + + "query selects the related record's own columns — keeping the foreign key in your own " + + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " + + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' + + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' + + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' + + 'the response to every field). Where the value is wanted on the queried object itself, ' + + 'denormalise it onto that object (a stored field, written when the source changes) — the ' + + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' + + 'or rollup field materialises no column to sort or select by', + reason: + 'The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that ' + + 'was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` ' + + '— objectql\'s formula projection and known-field filters, driver-sql\'s `select()` and ' + + 'driver-memory\'s projection all treat the list as `string[]`, driver-mongodb keyed its ' + + 'projection with the entry itself, and the REST ingress stringified it. Nested selection ' + + 'is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST ' + + 'surface — `QueryAST` is never stored in stack metadata (no view, dataset or report ' + + 'authors one), so there is no source for the chain to rewrite: the schema narrows to ' + + '`z.string()` and callers move their own select lists. ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No caller puts an object in `fields[]`; related records AND single related columns are ' + + 'read through `expand`, with the foreign-key column retained in the projection so ' + + 'expansion has something to resolve. A `fields` entry that is not a string ' + + 'fails to parse with the removal prescription, and the list/query/export routes answer ' + + '400 INVALID_FIELD naming the retired form instead of the field `"[object Object]"`.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts new file mode 100644 index 00000000000..c9b6f2b79ee --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'query-joins-retired', + surface: 'data.query.joins', + replacement: + "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested " + + "query selects the related record's own columns — keeping the foreign key in your own " + + "projection (`fields: ['title', 'owner_id']`), because the relation is carried by that " + + 'column and projecting it away leaves expansion nothing to resolve. A dotted `fields` ' + + 'path is NOT a replacement: no driver ever resolved one, and the ingress refuses it ' + + '(`400 INVALID_FIELD` — refused since a dotted projection was found silently widening ' + + 'the response to every field). Where the value is wanted on the queried object itself, ' + + 'denormalise it onto that object (a stored field, written when the source changes) — the ' + + 'same remedy the sort axis\'s refusal hint was corrected to prescribe, because a formula ' + + 'or rollup field materialises no column to sort or select by', + reason: + 'The `joins` array was declared-but-inert: no engine or driver read `query.joins` ' + + 'anywhere on the query path, so a query carrying it behaved exactly as if the key were ' + + 'absent — while the name squatted on the reserved REST parameter set. Related-record ' + + 'retrieval already has a live spelling (`expand`, resolved by the engine via batch ' + + '`$in` queries), so the removal deletes the second, broken spelling rather than the ' + + 'capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with ' + + 'the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there ' + + 'is no source for the chain to rewrite; callers move their own queries. ' + + 'ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No caller sends `joins`; related records AND single related columns are read through ' + + '`expand`, with the foreign-key column retained in the projection so expansion has ' + + 'something to resolve. A query that still carries `joins` fails to parse with the removal ' + + 'prescription (even as an empty array), and authoring it is a `tsc` error at the call site.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts b/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts new file mode 100644 index 00000000000..96678778769 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.query-window-functions-retired.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'query-window-functions-retired', + surface: 'data.query.windowFunctions', + replacement: + '`aggregations` + `groupBy` for request-level analytics; ' + + '`SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource', + reason: + 'The `windowFunctions` array was declared-but-inert on the query path: `find()` never ' + + 'applied a window function, so every OVER clause a caller declared was silently ' + + 'dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, ' + + 'a driver-level door that is not on the `IDataDriver` contract and whose flat input ' + + 'shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never ' + + 'matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door ' + + 'never read, so that cluster is removed with the key rather than left as a false ' + + 'affordance. A REQUEST surface, never stored; no source to rewrite. ' + + 'ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No caller sends `windowFunctions` in a query; request-level analytics use ' + + '`aggregations` + `groupBy`, and embedders needing OVER-clause SQL call the SQL ' + + "driver's `findWithWindowFunctions` door directly. A query that still carries the key " + + 'fails to parse with the removal prescription naming that door.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts b/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts new file mode 100644 index 00000000000..8029d810579 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.record-details-sections-object-form.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'record-details-sections-object-form', + surface: 'ui.RecordDetailsProps.sections (the `record:details` page component)', + replacement: + 'an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the ' + + 'string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the ' + + 'heading translatable, and the new sibling `hideFields` omits named fields from the body', + reason: + '`record:details` declared `sections` as a list of section IDs — `["overview", ' + + '"financials"]` — a shape nothing produced and nothing consumed, while every real page ' + + 'authored the object form. This is authorable metadata on the publish/parse path, so it ' + + 'is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical ' + + 'conversion because a string ID carries no field list and the chain cannot invent one — ' + + 'only the author knows which fields the section named `overview` was meant to render. ' + + 'The measurement that made the type change safe is also what makes the prescription ' + + 'unambiguous: the ID-list form had zero read paths and zero producers. objectui\'s ' + + '`RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` ' + + '/ `s.fields`) with no string branch at all — a string entry spreads into a character ' + + 'map and renders nothing; `@object-ui/types`\' `RecordDetailsComponentProps` mirror ' + + 'already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can ' + + 'only author `{ label, columns, fields }`; `packages/lint` has modelled it as ' + + '`nestedSections` all along; and every page in this repo — three showcase pages plus the ' + + '`sys_user` platform page — authors the object form. So the break lands only on stored ' + + 'metadata written against a declaration nothing ever honoured, and it lands at publish ' + + 'time rather than rewriting data at rest. The same change DECLARED `hideFields`, which ' + + 'the `sys_user` platform page had been authoring undeclared. Registered late, by the ' + + 'stock reconciliation that compared the breaking changesets already on the v17 release ' + + 'train against this ledger: the change that declared the object form predates the gate ' + + 'that makes a breaking changeset state its ledger disposition, so nothing asked it ' + + 'what it had done about the ledger, and the sibling key on the same def — ' + + '`ui/RecordDetailsProps:layout`, retired in a neighbouring change — carries a tombstone ' + + 'while this face carried none. ADR-0087.', + acceptanceCriteria: + 'Every `record:details` component in authored metadata spells `sections` as an object ' + + 'array: each entry names the fields it renders (`fields: [...]`), optionally with ' + + '`label` / `name` / `columns`. `objectstack validate` passes and each detail page ' + + 'renders the same sections, in the same order, with the same fields as before the ' + + 'upgrade — a page whose sections silently render EMPTY is the signature of an ID list ' + + 'left in place. A section that existed only as an ID, with no field list recoverable ' + + 'from the page it belonged to, is a judgement for the author: name the fields it was ' + + 'meant to show, or delete the entry. Fields previously hidden by a convention outside ' + + 'the schema move onto the declared `hideFields`.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts b/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.ts new file mode 100644 index 00000000000..0f2e4e04ac1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.rest-server-openapi31-block-removed.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: 'rest-server-openapi31-block-removed', + surface: 'restServer.openApi31', + replacement: + '(removed — no replacement key exists. Delete the key; for a real outbound webhook use ' + + '`Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 ' + + 'webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 ' + + 'through a new ADR)', + reason: + 'The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / ' + + '`pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with ' + + '`OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 ' + + "document synthesis nothing delivered: the REST server's `normalizeConfig` forwards " + + 'only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the ' + + 'pre-generated @objectstack/spec contract enriched with the live server URL and the ' + + 'registered objects — a webhook declared here never appeared in any served document ' + + '(ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector ' + + 'webhook and event enums one layer up). There is no behaviour ' + + 'to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS ' + + 'configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never ' + + "a `sys_metadata` shape — the stack tree's `api` block declares only its four " + + 'scoping/auth knobs. The three schemas are removed with the key (zero import-level ' + + 'consumers in objectstack / cloud / objectui); the key itself is tombstoned because ' + + 'the schema is not `.strict()` and a plain delete would strip it silently.', + acceptanceCriteria: + 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` ' + + '`restConfig`) carries `openApi31` — a config that includes it now fails the parse ' + + 'with the retirement prescription instead of being silently stripped. No code ' + + 'imports `OpenApi31Extensions(Schema)`, `Callback(Schema)` or ' + + '`OpenApiWebhookEvent(Schema)` from `@objectstack/spec/api` (TS2305 after upgrade). ' + + 'The served /openapi.json is byte-identical before and after — the block never ' + + 'reached it.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts b/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts new file mode 100644 index 00000000000..181209b6372 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.runtime-httpserver-wrapper-retired.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'runtime-httpserver-wrapper-retired', + surface: 'runtime.HttpServer (the exported delegating wrapper class)', + replacement: + 'register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — ' + + '`HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one', + reason: + '`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its ' + + 'constructor, declared `implements IHttpServer`, and forwarded only the contract\'s ' + + 'REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / ' + + '`close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, ' + + '`setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs ' + + 'consumers to feature-detect exactly those members with `typeof server.X === ' + + '"function"` and to degrade when absent, so wrapping a capable adapter made every ' + + 'probe answer false and the capability vanish with the adapter underneath providing it ' + + 'the whole time. The sharpest consequence is worth writing down before anyone reaches ' + + 'for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered ' + + 'the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, ' + + 'because `setFallbackHandler` — the ONLY entry path for declarative `apis:` endpoints ' + + 'since publish stopped refusing them and 17 began executing them — was never forwarded. ' + + 'This is a TS/API contract surface: an HTTP server ' + + 'adapter is CODE, never stack metadata, so there is no authored source for the chain to ' + + 'rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a ' + + '`.parse()`. That is precisely why this entry must exist: for an untyped JS host the ' + + 'ledger is the only notification channel there is, and for a typed one tsc reports at ' + + 'the construction site. Same disposition, and the same reason, as ' + + '`storage-service-list-retired` and `data-driver-find-stream-retired`. ' + + 'Registered by the stock reconciliation that compared the breaking changesets already on ' + + 'the v17 release train against this ledger, not by the original change: the wrapper\'s ' + + 'removal landed before the gate that makes a breaking changeset state its ledger ' + + 'disposition existed, so nothing ever asked it what it had done about the ledger. ' + + 'ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No code constructs `new HttpServer(...)` from `@objectstack/runtime`, and no import of ' + + 'the name resolves — the export is gone, so a typed caller fails to compile at the ' + + 'construction site. A host that was wrapping an adapter registers the ADAPTER INSTANCE ' + + 'as `http.server` instead, and then proves the capability came back: `getPort()` returns ' + + 'the real bound port after `listen(0)`, `getRawApp()` returns the framework-native app, ' + + 'and a declarative `apis:` endpoint declared in metadata answers its route rather than ' + + '404 — the last of which is the failure a wrapper produced silently. An adapter that ' + + 'genuinely needs to intercept calls implements `IHttpServer` in full, forwarding the ' + + 'optional members too, rather than declaring `implements` and dropping them.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts b/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts new file mode 100644 index 00000000000..7e3f7c207ba --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.sharing-execution-context-retired.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'sharing-execution-context-retired', + surface: + '@objectstack/spec: the exported type `SharingExecutionContext` ' + + '(`contracts/sharing-service`), and its re-export from ' + + '@objectstack/plugin-sharing — the six-field context shape ' + + '(`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / ' + + '`isSystem`) that sharing, approval and report enforcement signatures used to name', + replacement: + '`ExecutionContext` from `@objectstack/spec` — the complete ' + + '`resolveAuthzContext` envelope the sharing, approval and report contracts have declared ' + + 'since they converged onto it. Every one ' + + 'of the retired type\'s six fields exists on it under the same name and type, so a ' + + 'value that satisfied the old type already satisfies the envelope: only the ' + + 'annotation is rewritten, never the value', + reason: + 'ADR-0049 enforce-or-remove, completing the maintainer\'s ruling of 2026-08-07 on the ' + + 'share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site ' + + 'subset). This type was the ' + + 'declared context parameter of 36 signatures across three contracts — ' + + '`ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — ' + + 'and it omitted four fields those gates need: `accessible_org_ids` (under the ' + + '`group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, ' + + '`posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR ' + + 'direction of the share-link twin, which that same ruling moved onto the whole context: ' + + 'nothing trimmed the VALUES — ' + + "the engine middleware always handed the whole context down — it was the declared " + + 'TYPE that was narrow, so an implementation could not READ what it had been given ' + + 'without casting out of its own contract (`const posture = (context as any).posture` ' + + "in plugin-approvals' privileged-override gate). One change converged the " + + 'contracts, two more re-annotated the four implementations (sharing and audit, then ' + + 'approvals and reports), and this change removes the now-unreferenced declaration, the ' + + 'deletion that split had deferred. ' + + 'Why this needs a ledger entry despite nothing in-repo referencing it: it is the ' + + '`export-field-meta-constraints-retired` / `hook-context-session-roles-retired` ' + + 'disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no ' + + '`retiredKey()` tombstone and no parse rejection that could carry the prescription, ' + + 'and the ledger is the only channel that reaches an upgrader. ' + + 'Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. ' + + 'The name is only ever spelled inside a consumer\'s own TypeScript, so no ' + + '`objectstack migrate meta` transform can reach it, and no `sys_metadata` row ' + + 'carries it. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No source of yours imports `SharingExecutionContext` from `@objectstack/spec` or ' + + '`@objectstack/plugin-sharing`; each such import becomes `ExecutionContext` from ' + + '`@objectstack/spec` and the build is green. tsc IS a sufficient detector here, ' + + 'unlike the optional-key retirements at this step: the name is gone outright, so ' + + 'every remaining reference is a hard resolution error rather than a silent ' + + '`undefined`. ⚠️ Then check the direction tsc CANNOT see: widening an annotation ' + + 'never rejects a value, so an enforcement path that only ever received a hand-built ' + + 'six-field object still compiles and still under-adjudicates. Confirm each caller ' + + 'passes the context it was HANDED, unchanged, rather than a literal it assembled — ' + + 'and that any gate of yours reading `posture`, `accessible_org_ids`, `org_user_ids` ' + + 'or `tabPermissions` now reads them declared, with no `as any` in the path.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts b/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts new file mode 100644 index 00000000000..0c04bdde69e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.sharing-rule-recipient-reconcile.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'sharing-rule-recipient-reconcile', + surface: + 'security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules ' + + '(`type: owner` + `ownedBy`)', + replacement: + '`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose ' + + 'the records through a public form or a share link; `type: owner` → rewrite as a ' + + '`type: criteria` rule. `business_unit` is newly authorable for the single-unit case', + reason: + 'The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename ' + + 'and the enforced runtime, in both directions at once. It still offered the pre-rename ' + + '`group`, which the seed path silently SKIPPED, while omitting two recipients the ' + + 'runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and ' + + '`business_unit`). It also offered `guest`, which had no runtime recipient mapping at ' + + 'all. Each of those is a rule that validated and then materialised nothing — the ' + + 'ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: ' + + 'the author sees a valid rule and believes a set of people can reach the records, and ' + + 'no error ever contradicts them. Owner-type rules go for a different and sharper ' + + 'reason: they depend on live team / position membership, which the static materialiser ' + + 'cannot track, so they could not be made to work by fixing a name. They return as an ' + + 'enforced form only if membership-reactive re-materialisation is designed. This is a ' + + 'semantic entry rather than a mechanical conversion because only one of the three ' + + 'rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` ' + + 'have no target — the author has to decide who was actually meant to reach those ' + + 'records and say so in a form the runtime enforces, and a transform that guessed would ' + + 'be inventing an access grant. After this change every authorable recipient and rule ' + + 'type on the SharingRule surface is enforced; the `queue` recipient stays ' + + 'runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note ' + + 'the two neighbouring conversions cover DIFFERENT faces of this schema and not this ' + + 'one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and ' + + '`sharing-rule-access-level-full-to-edit` is the access-level vocabulary. The change came ' + + 'out of the metadata property liveness audit, which found security properties parsed but ' + + 'never enforced, and was registered late, by the stock reconciliation that compared the ' + + 'breaking changesets already on the v17 release train against this ledger. ADR-0078 / ' + + 'ADR-0090 D3 / ADR-0087.', + acceptanceCriteria: + 'No sharing rule names `group` or `guest`, and none carries `type: owner`; stale ' + + 'definitions now FAIL parse with the valid options listed, so the sweep is "fix until ' + + 'nothing raises". ⚠️ Parsing clean is the weaker half — verify the SHARES, because a ' + + 'rule that was silently materialising nothing looked exactly like one that worked. For ' + + 'every rule that named `group`, confirm the `sys_team` it now resolves to has the ' + + 'membership you expected, and that records reach the people the rule was written for. ' + + 'Each former `guest` rule needs an explicit decision about anonymous access — a public ' + + 'form grant or a share link, or knowingly no access at all — and each former owner-type ' + + 'rule needs a `criteria` predicate that names the same population, checked against a ' + + 'representative record. Where a single business unit was meant, use `business_unit`; ' + + '`unit_and_subordinates` is the subtree and grants strictly more.', + relevantWhen: { kind: 'stack-declares', keys: ['sharingRules'] }, +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts b/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts new file mode 100644 index 00000000000..e5e9dbdad83 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.sort-node-direction-rejected.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'sort-node-direction-rejected', + surface: 'data.query.orderBy[].direction (SortNode)', + replacement: + '`order` — `orderBy: [{ field: "updated_at", order: "desc" }]`. One word, same values ' + + '(`asc` / `desc`)', + reason: + '`SortNodeSchema` was a plain `z.object`, so zod\'s default `.strip` applied and a sort ' + + 'node spelling its direction `direction` lost the key silently. Measured on `main` ' + + 'before the change: `SortNodeSchema.parse({ field: "updated_at", direction: "desc" })` ' + + 'returned `{ field: "updated_at", order: "asc" }` — the key discarded and `order` ' + + 'falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the ' + + 'request succeeded. Paired with `limit`, which is how a caller asks for "the latest N", ' + + 'that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary ' + + '200 with nothing in the response to distinguish it from the answer that was asked for. ' + + '`direction` is not a typo: it is the live vocabulary of a neighbouring contract, ' + + '`IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated ' + + 'between the two by hand — a translation known to be necessary and enforced nowhere, ' + + 'the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a ' + + '`strictObject` carrying `aliases: { direction: "order" }`, and `normalizeSortNodes` ' + + 'in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The ' + + 'alias is deliberate rather than left to the edit-distance fallback, because no edit ' + + 'distance bridges `direction` → `order` and a bare "unrecognized key" would leave the ' + + 'caller exactly where the silent strip did. This is registered as a semantic entry ' + + 'rather than a mechanical conversion for one reason worth stating: the rewrite itself ' + + 'is trivially mechanical, but a stored `direction: "asc"` is ambiguous evidence — the ' + + 'author may have written it meaning ascending and been silently GIVEN ascending, so ' + + 'the visible behaviour never contradicted them, and only they can say whether the ' + + 'sort they have been reading was the sort they asked for. Registered by the stock ' + + 'reconciliation of the v17 train\'s breaking changesets: the in-code alias tombstone ' + + 'shipped with the change that closed both doors ' + + '(maintainer ruling 2026-08-03), but the ledger half ' + + 'never did, and a retirement needs both — the tombstone is the proof the removal was ' + + 'declared, the ledger entry is what `spec-changes.json`, the upgrade guide and ' + + '`os migrate meta` project to consumers. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No authored `orderBy` entry — in metadata, in a saved view\'s `sort[]`, or in a REST / ' + + 'RPC request body — spells the key `direction`. The upgrade\'s own verify loop is that ' + + 'the failure is now LOUD: a stale `direction` raises a named parse error (or `400 ' + + 'INVALID_SORT` at ingress) quoting `order`, so a sweep is "fix until nothing raises" ' + + 'rather than a search. ⚠️ Check the RESULTS, not just the parse: every list, report and ' + + 'paged query that carried `direction: "desc"` has been silently serving ASCENDING order ' + + 'and, wherever it was paired with `limit`, a different set of rows. After the rename ' + + 'those pages change what they return — that is the defect being corrected, not a ' + + 'regression, and any downstream expectation baked against the old output has to be ' + + 're-read rather than restored.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts b/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts new file mode 100644 index 00000000000..65c347b7906 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.spec-type-alias-input-suffix-retired.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'spec-type-alias-input-suffix-retired', + // Plain text, no markdown: build-upgrade-guide.ts renders this field inside a + // code span AND inside a table cell, so backticks here break both. + surface: + 'type alias: the 102 XInput names of @objectstack/spec ' + + '(ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ' + + 'ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ ' + + 'identity/ integration/ kernel/ security/ system/ ui/)', + replacement: + 'the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` ' + + 'a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the ' + + '`Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held ' + + 'a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already ' + + 'declared for every schema whose two shapes differ, so the target name has existed for ' + + 'a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, ' + + '`CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare ' + + 'aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, ' + + '`ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or ' + + '`Partial`-shaped) types no bare alias denotes.', + reason: + 'This entry exists for the reason `data-driver-find-stream-retired`, ' + + '`storage-service-list-retired` and `actor-user-roles-to-positions` ' + + 'exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack ' + + 'metadata, so there is no source for a D2 conversion to rewrite and deliberately no ' + + 'schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, ' + + 'and no `.parse()` ever saw it. Measured and verified rather than assumed: ' + + '`json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL ' + + 'across this change, because those generators enumerate runtime `z.ZodType` exports ' + + 'and never read a type alias. So nothing left the published metadata surface and ' + + 'RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim ' + + 'the metadata contract shrank. The enforced channel is tsc: the name is gone, so every ' + + 'consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the ' + + 'replacement — a compile error says `ConnectorInput` does not exist, not that ' + + '`Connector` now means what it meant. The generated upgrade guide is the only channel ' + + 'that carries the second half, which is precisely the gap ADR-0087 registration ' + + 'exists to close — the `ctx.user` `roles` alias was removed with no ledger entry, and ' + + 'that entry had to land separately. ⚠️ Deliberately ' + + 'NOT registered alongside it: the 1384 bare aliases ' + + 'the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and ' + + 'still resolve; what moved is which of a schema\'s two shapes they denote, and only ' + + 'where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op ' + + 'there, pinned as such). A consumer holding an authored literal is made MORE correct ' + + 'by it, silently; one holding a parse result gets a tsc error at the first defaulted ' + + 'key it reads. Registering that as a rename would misdescribe it — no name was ' + + 'retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9 (its ' + + 'phase 2, which moved every bare name to the author state).', + acceptanceCriteria: + 'No source imports a name ending `Input` from `@objectstack/spec` except the nine listed ' + + 'above: `rg "\\b\\w+Input\\b" --type ts` over consumer code resolves only to those. A ' + + 'literal annotated with a bare spec type compiles while listing ONLY the keys the ' + + 'author means — `const c: Connector = { name, label, type }` type-checks, which it did ' + + 'not in 16.x — and a value read out of `XSchema.parse()` annotated with the bare name ' + + 'no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal ' + + 'that the annotation should be `XParsed`. `pnpm check:spec-parsed-alias` reports every ' + + 'bare alias as `z.input` and refuses both a bare `z.infer` alias and a reintroduced ' + + '`XInput` synonym.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts b/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts new file mode 100644 index 00000000000..cbc057a0258 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.storage-service-list-retired.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'storage-service-list-retired', + surface: 'contracts.IStorageService.list', + replacement: + 'track the keys you wrote (sys_file / file-reference records, queryable through ' + + 'ObjectQL with real pagination) instead of enumerating the bucket — and where no ' + + 'such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this ' + + 'entry reserved, restored since, once cloud proved to be the first-party caller this ' + + 'repository could not see', + reason: + '`list(prefix)` was an OPTIONAL contract method documented as "List files in a ' + + 'directory/prefix", and the two shipped adapters answered the same call with two ' + + 'different semantics — both of them silently incomplete. `LocalStorageAdapter.list` ' + + 'was a single-level `readdir`, so a nested key `a/b/c` was invisible under ' + + '`list(\'a\')` (only `a/b` came back), and a subdirectory that `stat` succeeded on ' + + 'was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is ' + + 'a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` ' + + 'was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither ' + + '`IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a ' + + 'caller received was the first page, with no signal. One contract method, two ' + + 'dialects, both quietly incomplete — and the first feature that genuinely needed to ' + + 'enumerate a prefix (backup, orphan sweep, migration audit) would have got two ' + + 'different answers on two deployments without an error on either. The email plugin\'s ' + + 'large-attachment storage work was nearly that feature: it planned to drive attachment ' + + 'reclamation off ' + + '`list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one ' + + 'level down, and switched to queue-driven deferred work instead. Nothing consumed ' + + 'it afterwards: the only in-repo call site was the `SwappableStorageService` ' + + 'pass-through (which itself rejects when the active adapter has no `list`), and ' + + 'REST, CLI and the storage routes never called it. Remove was chosen over ' + + 'align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two ' + + 'dialects): aligning would grow a ' + + 'conformance surface nobody walks, while a prefix listing that cannot paginate is ' + + 'the wrong signature to inherit — when a real caller needs enumeration it returns ' + + 'cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases ' + + '(nested keys, directory entries, >1000 objects) proving both backends agree. This ' + + 'is a TS/API contract surface — a storage adapter is CODE, never stack metadata — ' + + 'so there is no source for the chain to rewrite, and deliberately no schema ' + + 'tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription ' + + 'there would reach no one. The enforced channel is tsc, and it reports at the call ' + + 'site. Same disposition, and the same reason, as ' + + '`data-driver-find-stream-retired`. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No code calls `storage.list(...)` on the `file-storage` service or on any ' + + '`IStorageService` value. Code that needed "which files are under this prefix" ' + + 'reads the records it wrote — `sys_file` / file-reference rows carry the storage ' + + 'key and page deterministically through ObjectQL — rather than asking the bucket, ' + + 'which is also the only form that stays correct past 1000 objects and across both ' + + 'adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra ' + + 'method is not an error on a class) and is simply unreachable through the ' + + 'contract, so deleting it is cleanup that can follow. The break is on the CALLER ' + + 'side: `storage.list(...)` no longer type-checks, and a PROXY typed against ' + + '`IStorageService` that forwards to `inner.list` is exactly such a caller — the ' + + 'one in `@objectstack/service-storage` goes with the adapters\' own `list` ' + + 'implementations, removed in the retirement\'s implementation half. ' + + '⚠️ AMENDED 2026-08-09, under the maintainer\'s 2026-08-08 ruling on cloud\'s ' + + 'storage-enumeration callers (option B: restore enumeration upstream, correctly shaped, ' + + 'rather than hand-roll S3 pagination one repository over): the ' + + 'RESERVED route in the paragraph above was taken. `list` exists again on the ' + + 'contract, cursor-shaped — `list(prefix, { cursor, limit })` returning ' + + '`{ items, nextCursor }` — because cloud had two first-party callers this repo ' + + 'could not see when the measurement said "nothing calls it" (tenant attachment ' + + 'reclamation, marketplace snapshot GC). This does NOT un-retire anything and the ' + + 'acceptance criterion above is unchanged for what it actually governs: the ' + + 'single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written ' + + 'against it still fails to compile, and the two dialects it had are now pinned ' + + 'against each other in `storage-adapter-list.conformance.test.ts` rather than left ' + + 'to diverge. What changed for an upgrader is only the destination: prefer the ' + + 'records you wrote, and reach for the restored member when there are none.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts new file mode 100644 index 00000000000..33440edd382 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'tool-requires-confirmation-retired', + surface: 'ai.tool.requiresConfirmation', + replacement: + 'put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — the ' + + 'flag the platform confirmation CONTRACT is written against, and that contract is ' + + 'ENFORCED. An AI-facing call on an action declaring the flag must carry the ' + + 'confirmation member `confirm: true` on the request and is REFUSED without it with ' + + '`ACTION_CONFIRMATION_REQUIRED` (428), the refusal naming the action and the exact ' + + 'member to set. A gate, not a queue: nothing is parked, and a refused call did not ' + + 'run — no record was read and none was written. ⚠ Two bounds: the enforced set is the ' + + 'doors that enforce the author\'s `ai.exposed` opt-in, today the action door reached ' + + 'from the MCP `run_action` tool, while REST `/actions` is not `ai.exposed`-gated and ' + + 'sits outside the gate; and `confirm: true` is an unverifiable caller claim, so the ' + + 'gate makes forgetting loud without proving a human approved', + reason: + '`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: ' + + 'not the LLM tool set (a tool reaches the model as name / description / parameters ' + + 'only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP ' + + 'bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a ' + + 'destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for ' + + 'a SAFETY property it is false compliance, the case ADR-0049 exists for — an author ' + + 'gates a destructive tool, sees the flag accepted, and ships believing a human is in ' + + 'the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries ' + + 'the same name and DOES work, so the mistake reads as correct in review. This is ' + + 'registered as a semantic entry rather than a mechanical conversion because the rewrite ' + + 'is not a rename at all — the replacement lives on a different metadata object at a ' + + 'different layer, and deciding which action should carry the gate (or whether the ' + + 'operation should be an action at all) is a judgement the chain cannot make. Deleting ' + + 'the key mechanically would be the worst possible transform here: it would leave the ' + + 'metadata parsing green while silently completing the removal of a safety gate the ' + + 'author believed was in place. `ToolSchema` was made `.strict()` in the same change, ' + + 'which is load-bearing rather than tidying — removing a key from a non-strict schema ' + + 'swaps one silent no-op for another, so the retired key now REJECTS and the parse error ' + + 'carries the prescription, that being the one channel every consumer bumping ' + + '`@objectstack/spec` is guaranteed to hit. Registered by the stock reconciliation of the ' + + 'v17 train\'s breaking changesets: the `retiredKey()` tombstone shipped with the change ' + + 'that executed ADR-0033\'s deletion of this unenforced key, and still stands in ' + + '`ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the ' + + 'tombstone is the proof the removal was declared, this entry is what `spec-changes.json`' + + ', the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ' + + 'ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'No tool definition carries `requiresConfirmation`; the key now raises a located parse ' + + 'error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The ' + + 'load-bearing half is what happens NEXT, and no gate can check it for you: for every ' + + 'tool that carried the flag, decide whether that operation genuinely needs a human in ' + + 'the loop. If it does, move it behind an action carrying `ai.requiresConfirmation: ' + + 'true`, which is what the platform confirmation contract gates on — and that gate is ' + + 'PERFORMED: invoking the operation over an AI-exposed door without the confirmation ' + + 'member is REFUSED with `ACTION_CONFIRMATION_REQUIRED` (428) and nothing runs, so ' + + 'that call is a real check you can make rather than a destructive experiment. ⚠ Two ' + + 'bounds on what it proves: the enforced set is the doors that enforce the author\'s ' + + '`ai.exposed` opt-in — today the action door reached from the MCP `run_action` tool ' + + '— while REST `/actions` is not `ai.exposed`-gated and sits outside the gate, so an ' + + 'agent holding an API key on that route is still yours to put a human in front of; ' + + 'and `confirm: true` is an unverifiable caller claim, so the gate makes forgetting ' + + 'loud without proving a human approved. The decision above is still the one this ' + + 'criterion asks you to make. If the operation does not need a human, delete the key ' + + 'knowingly. ' + + 'Deleting it without that decision leaves exactly the state the retirement exists to ' + + 'end: a destructive tool nobody is approving, now without even the false flag to show ' + + 'that somebody once meant to.', + relevantWhen: { kind: 'stack-declares', keys: ['tools'] }, +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts new file mode 100644 index 00000000000..4dd530a47ea --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.ui-interaction-config-family-retired.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-interaction-config-family-retired', + surface: + 'ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig ' + + '/ ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig ' + + '(the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ' + + 'ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)', + replacement: + '(removed — there is no replacement key, because there was never a key. Touch targets, ' + + 'drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN ' + + 'behaviour: the component library decides them, not a per-page metadata author. ' + + 'Offline is a platform capability, and its vocabulary belongs on the sync engine that ' + + 'owns the queue, the conflict policy and the cache — none of which exists yet. Delete ' + + 'the import and the value. Whichever of these earns real product pull returns WITH its ' + + 'own vocabulary and its executor — the way inbound rate limiting came back, as a new ' + + 'key carrying only what its executor consumes — not by un-retiring a declaration)', + reason: + 'Five `@objectstack/spec/ui` modules declared a full interaction-configuration ' + + 'vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, ' + + 'focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol ' + + 'carried them. This is the ADR-0049 false-compliance shape in its most inviting form ' + + 'for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: ' + + '`authorable-surface.json` listed 109 keys under these defs and ' + + '`content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them ' + + 'as authoring tables, so the published documentation advertised a vocabulary with no ' + + 'carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a ' + + 'page component was rejected by `PageComponentSchema` for an unrecognized key — the ' + + 'docs and the schema disagreeing about the platform (Prime Directive #10). Three ' + + 'independent measurements, each with its controls passing in the same run: (1) no ' + + 'module under `packages/spec/src` imported any of the five except the `ui/index.ts` ' + + 'barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph ' + + 'from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` (25 roots, ' + + '4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, ' + + '`WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic ' + + 'carrier flipped all 21 — so unreachability was a fact about the graph, not a broken ' + + 'walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud ' + + 'outside these modules\' own unit tests. objectui holds TYPE re-exports and parity ' + + 'ratchets, never validators, and says so (its types package deliberately dropped the ' + + 'spec/ui zod-validator re-exports and keeps type-only ones). The 2026-08-04 ruling ' + + 'retired the family — touch, drag-and-drop, keyboard and motion are renderer built-in ' + + 'behaviour and offline belongs to a sync engine, none of it per-page metadata — and weighed ' + + 'wiring a carrier key (option B) and rejected it: that is a feature with a renderer ' + + 'behind it, not ledger clean-up. It also weighed tightening the shapes to ' + + '`strictObject` and rejected that explicitly — strictness is a property of a PARSE and ' + + 'there is no parse, so it would spend a breaking change to leave "a precisely ' + + 'validated dead slot, the more convincing lie" (the lesson of the datasource capability ' + + 'flags: `readOnly` was precisely validated and read by nothing, while a shipped example ' + + 'called a datasource a read replica and wrote through it). Because there was no carrier ' + + 'key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 ' + + 'conversion to rewrite: this entry is the D3 record, the same route 3 as ' + + '`plugin-runtime-family-retired` (the kernel plugin-runtime family) and the ' + + '`HttpServerConfig` retirement (seven keys no runtime read and no authoring door ' + + 'reached, retired with their container). ⚠️ Not to be confused with the theme-token ' + + 'retirement (theme-driven typography is not a near-term capability, so nine token ' + + 'groups nothing consumed were retired), which retired the THEME `animation` block — a ' + + 'different file, different defs, and that one did have a carrier key and therefore a ' + + 'tombstone. ADR-0049.', + acceptanceCriteria: + 'No code imports any of the 64 retired names from `@objectstack/spec` or ' + + '`@objectstack/spec/ui` — `TouchTargetConfig(Schema)`, `GestureType(Schema)`, ' + + '`SwipeDirection(Schema)`, `SwipeGestureConfig(Schema)`, `PinchGestureConfig(Schema)`, ' + + '`LongPressGestureConfig(Schema)`, `GestureConfig(Schema)`, `TouchInteraction(Schema)`, ' + + '`TransitionPreset(Schema)`, `EasingFunction(Schema)`, `TransitionConfig(Schema)`, ' + + '`AnimationTrigger(Schema)`, `ComponentAnimation(Schema)`, `PageTransition(Schema)`, ' + + '`MotionConfig(Schema)`, `DragHandle(Schema)`, `DropEffect(Schema)`, ' + + '`DragConstraint(Schema)`, `DropZone(Schema)`, `DragItem(Schema)`, `DndConfig(Schema)`, ' + + '`FocusTrapConfig(Schema)`, `KeyboardShortcut(Schema)`, `FocusManagement(Schema)`, ' + + '`KeyboardNavigationConfig(Schema)`, `OfflineStrategy(Schema)`, ' + + '`ConflictResolution(Schema)`, `SyncConfig(Schema)`, `PersistStorage(Schema)`, ' + + '`EvictionPolicy(Schema)`, `OfflineCacheConfig(Schema)`, `OfflineConfig(Schema)` — ' + + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol ' + + 'identity in `ui/interaction-config-retirement.test.ts`). No metadata document needs ' + + 'editing, because none could ever carry one of these blocks: a stack that parsed ' + + 'before parses byte-for-byte the same after. If you consumed the bare ' + + '`ConflictResolution` from `@objectstack/spec/ui` as a TYPE for your own offline code, ' + + 'declare that union locally — it is your client\'s policy, not the platform\'s. ' + + '`@objectstack/spec/integration`\'s `ConnectorConflictResolution` (connector sync) and ' + + '`@objectstack/spec/api`\'s `ConflictResolutionStrategy` (route merge policy) are ' + + 'different concepts and are untouched.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts new file mode 100644 index 00000000000..9379092d840 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.ui-notification-action-embed-config-retired.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-notification-action-embed-config-retired', + surface: 'ui.notificationAction / ui.embedConfig', + replacement: + '(removed — there is no replacement shape, because there was never a key to write ' + + 'either into. Delete the import and the value. Notification presentation is still ' + + 'described by the surviving `NotificationType` / `NotificationSeverity` / ' + + '`NotificationPosition` vocabulary; public access to a form is granted by the LIVE ' + + '`FormView.sharing` block (`SharingConfig`), which is untouched. Notification action ' + + 'buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 ' + + 'through a new ADR — carrier key and renderer first, vocabulary second)', + reason: + 'Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. ' + + 'The v17 unknown-key strictness sweep, which measured each ui/ ' + + 'file for an authoring door before closing any shape, measured them three ways on ' + + '2026-08-03 and this retirement re-ran all ' + + 'three against `origin/main` before removing anything, each with a positive control ' + + 'that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared ' + + 'a key of either type (`ui/notification.zod`\'s only non-test importer was the ' + + 'barrel; `ui/sharing.zod`\'s were the barrel and `ui/view.zod.ts`, which names its ' + + 'SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than ' + + 'substring-matching, because the repo holds two `sharing.zod` modules and a substring ' + + 'test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 ' + + 'metadata-type roots plus `defineStack`\'s `ObjectStackSchema`, over ' + + '`build-schemas.ts`\'s own walk including its derived-clone bridge, never reached ' + + 'either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` ' + + 'itself all resolved `root-graph` in the same run and an injected synthetic carrier ' + + 'flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside ' + + 'their own unit tests. So nobody could author one and nothing ever validated one: ' + + 'the shape of the unwired plugin sandboxing config removed before it, an exported ' + + 'schema with no consumer read as a capability, and the ' + + 'ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle ' + + 'as proof the platform serves iframes. Neither is stored metadata and neither has a ' + + 'carrier, so no `sys_metadata` row can hold one and there is no source for the D2 ' + + 'chain to rewrite; this entry is the D3 record. The sweep deliberately did NOT close ' + + 'them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing ' + + 'parses buys only "a precisely-validated dead slot, the more convincing lie" (the lesson ' + + 'of the datasource capability flags, whose `readOnly` was precisely validated and read ' + + 'by nothing) — and left the disposition to ADR-0049\'s enforce-or-remove, which came ' + + 'back REMOVE on 2026-08-04: a dead surface with no authoring door retires ' + + 'implementation-first, as three same-shape rulings that week had already decided. Each ' + + 'was orphaned by an earlier retirement one level up: `NotificationAction` lost its ' + + 'wrappers when the dual-source cleanup removed the `./ui` copies of `NotificationSchema` ' + + '/ `NotificationConfigSchema` (the same names declared differently on other entry ' + + 'points) — that retirement\'s published "zero consumers" evidence was later ' + + 'falsified for objectui, which re-exported both names, and is corrected on ' + + '`ui/notification.zod`\'s tombstone; the removal itself stands — and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 ' + + 'liveness audit retired `App.embed` (no iframe route ever read it) — that key still ' + + 'stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already ' + + 'meets a prescription; this removes the value shape that outlived it. ⚠️ The ' + + 'retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, ' + + 'a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount ' + + 'the anonymous form routes, and `ui/notification.zod` keeps its three presentation ' + + 'enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY ' + + '(never a parse) to pin its own hand-written `NotificationActionButton` interface — ' + + 'which is exactly why "has a consumer" never meant "has an authoring door" here; that ' + + 'pin is adapted objectui-side when it refreshes this dependency. ADR-0049.', + acceptanceCriteria: + 'No code imports `NotificationActionSchema`, `NotificationAction`, `EmbedConfigSchema` ' + + 'or `EmbedConfig` from `@objectstack/spec` or `@objectstack/spec/ui` — both are ' + + 'TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in ' + + '`notification-embed-retirement.test.ts`). The same pin asserts the SURVIVORS in the ' + + 'same run, and that half is equally load-bearing: `NotificationTypeSchema` / ' + + '`NotificationSeveritySchema` / `NotificationPositionSchema` and `SharingConfigSchema` ' + + 'must still be exported from `./ui`, and both modules must still load — a retirement ' + + 'that deleted either file would satisfy the absence half while destroying working ' + + 'surface. Nothing regresses at runtime, because nothing ever ran: no notification ' + + 'action was ever parsed from metadata and no iframe route ever read an embed config. ' + + 'Public form sharing is unaffected — `FormView.sharing` still gates the anonymous ' + + 'endpoints on `allowAnonymous` + `publicLink`.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts new file mode 100644 index 00000000000..32a1e8aa621 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.ui-widget-i18n-family-retired.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-widget-i18n-family-retired', + surface: + 'ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty ' + + '/ ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat ' + + '/ ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the ' + + 'five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)', + replacement: + '(removed — there is no replacement key, because there was never a key. A custom field ' + + 'widget is still named the same way it always was: `field.widget` is a plain string ' + + 'naming a component the RENDERER has registered, and objectui\'s registry has always ' + + 'carried its own runtime manifest for that (`RuntimeWidgetManifest` / ' + + '`RuntimeWidgetSource` in `@object-ui/types`, renamed off the spec\'s names under ' + + 'objectui\'s rule that a symbol named like a spec export must import it or take a name ' + + 'of its own), which models ' + + 'different keys and never derived from these. For localisation: write the ' + + 'default-language string on `label` / `description` — the framework generates the ' + + 'translation key at registration time from the naming convention — and put ' + + 'translations in translation files, which is the LIVE `system/translation.zod.ts` ' + + 'surface. Widget registration and locale formatting as authorable protocol metadata ' + + 'return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / ' + + 'formatter first, the vocabulary second)', + reason: + '`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest ' + + 'with lifecycle hooks, custom events, configurable properties and an ' + + 'npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a ' + + 'structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the ' + + 'protocol carried either. Three independent measurements, re-run on `origin/main` ' + + 'immediately before the removal with their controls passing in the SAME run: (1) no ' + + 'module under `packages/spec/src` imported `widget.zod` at all, and the only imports ' + + 'of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no ' + + 'schema declared a carrier key — `field.widget` is a `z.string()` naming a registered ' + + 'component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory ' + + 'Zod graph from all 24 metadata-type roots plus `defineStack`\'s `ObjectStackSchema` ' + + 'reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` ' + + 'in the same run and a synthetic carrier flipped every one of them; (3) zero ' + + '`.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files\' ' + + 'own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key ' + + '(`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, ' + + 'so the subtree was `no door` rather than `no gate` and goes whole — leaving the two ' + + 'leaves behind would strand exported schemas with no consumer, which an author reads ' + + 'as a capability. ' + + '`I18nObjectSchema` was additionally superseded by its own file-neighbour: ' + + '`I18nLabelSchema`\'s documentation already says translation keys are generated at ' + + 'registration time and translations live in translation files, and the live ' + + 'translation surface is `system/translation.zod.ts`, which uses none of these shapes. ' + + 'The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that ' + + 'is a feature with a registry and a renderer behind it, not ledger clean-up. ' + + 'Tightening them to `strictObject` was rejected earlier and explicitly, by the batch of ' + + 'the v17 unknown-key strictness sweep that measured this file as having no authoring ' + + 'door — strictness is a property of a PARSE and there is no parse, so it would spend a ' + + 'breaking change to leave "a precisely validated dead slot, the more convincing lie" ' + + '(the lesson of the datasource capability flags, whose `readOnly` was precisely ' + + 'validated and read by nothing). With no carrier key there is nothing to tombstone and no `sys_metadata` row ' + + 'or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, ' + + 'the same shape as `ui-interaction-config-family-retired`, ' + + '`plugin-runtime-family-retired` and the `HttpServerConfig` retirement. ' + + '⚠️ `WidgetManifest.performance`\'s own `retiredKey()` tombstone (left by the ' + + 'close-out sweep that removed the inert `performance` keys no renderer applied) is ' + + 'SUBSUMED here, the way the kernel `activationEvents` tombstone went with the removed ' + + 'plugin-runtime family: it goes with the shape that carried it, which is ' + + 'strictly stronger than the tombstone, because there is no longer a manifest to ' + + 'author the key INTO. ' + + '⚠️ One of the nine widget sites is deliberately NOT retired. ' + + '`FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than ' + + 'authorable metadata (it never appeared in `authorable-surface/` or ' + + '`json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its ' + + 'design and not its defect, and it acquired a live cross-repo compile-time consumer ' + + 'one day before that sweep batch measured: an objectui fix of 2026-08-03, made to follow the ' + + 'spec, renamed ' + + '`@object-ui/fields`\' validation slot onto the spec\'s `error` with no alias, the ' + + 'form renderer began producing it, and ' + + '`packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against ' + + '`import type { FieldWidgetProps } from \'@objectstack/spec/ui\'` as an intentional ' + + 'tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049.', + acceptanceCriteria: + 'No code imports `WidgetManifest(Schema|Parsed)`, `WidgetLifecycle(Schema)`, ' + + '`WidgetEvent(Schema|Parsed)`, `WidgetProperty(Schema|Parsed)`, ' + + '`WidgetSource(Schema|Parsed)`, `I18nObject(Schema)`, `PluralRule(Schema)`, ' + + '`NumberFormat(Schema|Parsed)`, `DateFormat(Schema)` or ' + + '`LocaleConfig(Schema|Parsed)` from `@objectstack/spec` or `@objectstack/spec/ui` — ' + + 'every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol ' + + 'identity in `ui/widget-i18n-retirement.test.ts`). No metadata document needs ' + + 'editing, because none could ever carry one of these shapes: a stack that parsed ' + + 'before parses byte-for-byte the same after, and a `field.widget: "my_picker"` string ' + + 'is untouched. `FieldWidgetProps` / `FieldWidgetPropsSchema` / ' + + '`FieldWidgetPropsParsed`, `I18nLabel(Schema)` and `AriaProps(Schema)` all still ' + + 'resolve on `@objectstack/spec/ui` and are asserted to. ⚠️ objectui needs a companion ' + + 'PR in the same window: `packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts` ' + + 'asserts the spec STILL owns `WidgetManifest` / `WidgetSource` (it is the ' + + '"a workaround should not outlive its reason" half of the rename tripwire objectui ' + + 'added when it stopped declaring symbols under names the spec owns, designed to go red ' + + 'exactly here), and `packages/types/src/widget.ts`\'s ' + + '"Renamed off the spec\'s `WidgetManifest` name" comments now point at names that no ' + + 'longer exist. Both are prescribed responses to this removal, not collateral damage.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts b/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts new file mode 100644 index 00000000000..b617a51ba42 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.ups-delegated-from-column-retired.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ups-delegated-from-column-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the ' + + 'platform grant table declared by plugin-security (packages/plugins/plugin-security/' + + 'src/objects/sys-user-permission-set.object.ts). The sibling declaration on ' + + 'sys_user_position is untouched', + replacement: + 'nothing on this table — delete the key from any authored `sys_user_permission_set` ' + + 'seed row (stack `data` entries) or data-door write that still carries it. ' + + 'Delegation semantics live on `sys_user_position`, where `delegated_from` remains ' + + 'declared AND runtime-enforced: the delegated-admin gate is what makes a position ' + + 'insert a delegation, and the explain engine attributes "via delegation from X, ' + + 'until Y". A permission-set grant that needs a provenance note keeps `reason` ' + + '(free text), which remains declared on both grant tables', + reason: + 'Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this ' + + 'column on this object, ADR-0049 enforce-or-remove: REMOVE. The ' + + 'runtime delegation gate is structurally scoped to sys_user_position ' + + '(`isDelegationWrite` returns false for every other object, so ' + + '`assertSelfDelegation` is unreachable for this table), and the explain engine ' + + 'reads delegation provenance from sys_user_position rows only. On ' + + 'sys_user_permission_set the column was therefore declared and data-door-writable ' + + 'while NO runtime consumer read it — its only enforcement was an authoring-time ' + + 'lint (the D3 "delegation row needs a reason" rule), which a row written through ' + + 'the generic data door never meets. That is declared-but-unenforced in its pure ' + + 'form, on a security object: an author who stamped delegated_from on a ' + + 'permission-set grant believed they constrained delegation, and nothing refused or ' + + 'honoured it. Producers measured at zero — the only object literals naming both ' + + 'the table and the column were lint test fixtures. ' + + 'This is a platform-object COLUMN retirement, not a spec-key retirement, so the ' + + 'bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in ' + + 'RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are ' + + 'expected byte-identical), and the disposition is a SEMANTIC entry rather than a ' + + 'D2 conversion. A conversion over stack `data` seed records would be mechanically ' + + 'expressible, but no conversion in the chain rewrites seed rows today and the ' + + 'measured author base is zero; the loud channel already exists at runtime — the ' + + 'engine schema preflight refuses an undeclared field with 400 INVALID_FIELD ' + + 'before the driver or any hook runs — so this entry carries the prescription and ' + + 'the refusal carries the enforcement. ' + + '⚠️ Existing physical columns are deliberately untouched: schema sync is additive ' + + '(ADR-0045), so a deployed database keeps the column; the platform stops ' + + 'declaring, projecting or accepting it. Zero producers means no rows are expected ' + + 'to carry a value; no backfill or destructive DDL is required or wanted. ' + + 'If delegation at permission-set granularity ever becomes a real need, the column ' + + 'is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce ' + + 'or do not declare.', + acceptanceCriteria: + 'No authored stack seeds `delegated_from` on a sys_user_permission_set record, and ' + + 'no client write to that table carries the key. Concretely: (1) grep your stack ' + + 'sources for delegated_from next to sys_user_permission_set — delete the key from ' + + 'any seed row; a row that was recording genuine hand-over provenance should say it ' + + 'in `reason` instead, which the platform stores on both grant tables. (2) Boot and ' + + 'load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD ' + + 'naming the column — that refusal is the enforced channel, not a silent drop. ' + + '(3) If you meant actual delegation-of-duty, author it where it is enforced: a ' + + 'sys_user_position insert with delegated_from = the writer, a mandatory future ' + + 'valid_until within the ceiling, and a mandatory reason (ADR-0091 D3) — the ' + + 'delegated-admin gate then validates the whole shape at runtime.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts b/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts new file mode 100644 index 00000000000..f0fc12c7bcc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.view-filter-rule-value-shaped-by-operator.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'view-filter-rule-value-shaped-by-operator', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ' + + 'ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a ' + + 'related-list component filter and a lookup picker filter. It accepted any declared ' + + 'scalar or array for EVERY operator; the accepted shape is now decided by the rule ' + + 'operator — in / not_in require an array, between requires exactly two bounds, and ' + + 'every other operator is unchanged', + replacement: + 'an ARRAY for in / not_in (a single value becomes a one-element list: value: "won" ' + + 'becomes value: ["won"]), and a two-element [min, max] array for between. The empty ' + + 'list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a ' + + 'scalar operator carrying an array, a string operator carrying a number, and a unary ' + + 'operator carrying an ignored value all still parse', + reason: + 'A publish-time gate catching up to a query-time one, not a new rule. An earlier fix ' + + 'closed the RUNTIME half: `assertListComparandShapes` ' + + '(@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered ' + + '`{ stage: { $nin: "won" } }` with a named 400 INVALID_FILTER, and before that it ' + + 'was a 500. The authoring surface stayed silent, so the failure was two-stage: the ' + + 'view published cleanly and only broke when someone opened it. That file names this ' + + 'very schema as the reachable authoring source of the defect. The tightening MIRRORS ' + + 'that gate exactly — three constraints, one for one — and deliberately goes no ' + + 'further, because an earlier fix already settled the opposite error (the ordering ' + + 'operators\' comparand widened to the strings the platform itself produces): a schema ' + + 'stricter than ' + + 'the runtime "in ways the runtime deliberately allows" was the WRONG side and was ' + + 'widened to match. So `in: []` is still accepted (a declared predicate both drivers ' + + 'implement), `equals: ["a","b"]` is still accepted (it lowers to a deep-equality ' + + 'comparand), and `is_empty: ""` is still accepted (the null predicates take their ' + + 'direction from the operator NAME — convertComparison ignores the value position, ' + + 'and the ObjectUI client deliberately sends a truthy placeholder there). ' + + '⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. ' + + 'A D2 entry replays a shape the platform once WROTE and renamed; this shape was ' + + 'never written by any first-party producer (every in / not_in rule in this repo, in ' + + 'objectui and in the cloud repo already carries an array — measured) and has never ' + + 'EXECUTED, since it 400s on first render today. Coercing it at load would be the ' + + 'platform guessing intent rather than replaying a rename, and it cannot guess ' + + 'honestly: value: "" would become the predicate [""] (a real filter on the empty ' + + 'string) rather than the "not filled in yet" a console row means, and between: 5 has ' + + 'no defensible second bound at all. The read path does not re-validate stored rows ' + + '(applyConversionsToStoredItem never validates, by its own contract), so no stored ' + + 'view becomes unreadable; what changes is that RE-SAVING such a view is refused at ' + + 'the write gate naming `value`, instead of storing a filter that 400s. ' + + 'ADR-0049 / ADR-0078 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored views, pages and related-list components for a filter rule whose ' + + 'operator is in, not_in or between (including the alias spellings nin / notIn / ' + + 'notin) and whose value is not an array of the right arity, then wrap or complete ' + + 'it. `os validate` / `os lint` now report each one by path with the operator, the ' + + 'received shape and the corrected shape, so the sweep is mechanical rather than by ' + + 'eye. Two checks are worth doing where it looks unnecessary: a rule reading ' + + '`operator: "in", value: ""` is an UNFINISHED row, not a filter — decide what it was ' + + 'meant to select rather than mechanically rewriting it to [""], which is a real and ' + + 'different predicate. And a view that already carried one of these shapes was never ' + + 'returning filtered rows: it answered 400 INVALID_FILTER on render, so ' + + 're-check what the view is supposed to show rather than assuming the old result set ' + + 'was correct.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts b/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts new file mode 100644 index 00000000000..9a1f52e6a04 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.view-management-protocol-retired.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'view-management-protocol-retired', + surface: + 'api.listViews / api.getView / api.createView / api.updateView / api.deleteView ' + + '(the ViewProtocol interface and its ten Request/Response schemas in ' + + 'api/protocol.zod.ts — 10 defs, 25 exported names)', + replacement: + 'the two view surfaces that are actually routed. For a view\'s STORED definition, the ' + + 'generic metadata methods with `type: \'view\'` — `getMetaItem` / `getMetaItems` / ' + + '`saveMetaItem` / `deleteMetaItem`, served at `/api/v1/meta/view/:name`. For the ' + + 'RESOLVED render-time view, `getUiView` (`GetUiViewRequest` / `GetUiViewResponse`), ' + + 'served at `/api/v1/ui/view/:object/:type`. Neither is addressed by a `viewId`, which ' + + 'is the one thing the retired surface offered and the one thing nothing implemented', + reason: + 'A complete viewId-addressed CRUD surface — list (with a list/form filter), read, ' + + 'create, patch, delete — with none of the three things a protocol method needs. ' + + 'Measured on origin/main immediately before the removal: no implementation ' + + '(`packages/metadata-protocol/src/protocol.ts` declares no `listViews` / `getView` / ' + + '`createView` / `updateView` / `deleteView`; its only view resolver is `getUiView`), ' + + 'no route (`packages/rest/src/rest-server.ts` never mentions `viewId`, so nothing ' + + 'viewId-addressed is reachable over HTTP at all), and no caller (the only ' + + '`ViewProtocol` mention outside its own file was the services checklist, which ' + + 'already recorded the five as declared-and-unrouted). The look-alike hits a bare-name ' + + 'grep turns up are all different contracts: `metadata-manager.ts`\'s ' + + '`getView(name: string)` is another class, and objectui\'s ' + + '`getView(objectName, viewId)` resolves through `client.meta.getItem(\'view\', …)`, ' + + 'i.e. the metadata route. ' + + 'What makes this worth a removal rather than a note is that the cost is already ' + + 'measured. A declared surface that is name-identical and semantics-adjacent to a real ' + + 'one is an attractive nuisance in every grep, and it mis-directed a decision once: ' + + 'The issue asking what `GET /ui/view/:object/:type` answers AND its 2026-08-07 maintainer ' + + 'ruling both read ' + + '`GetViewResponseSchema` (zero implementations) as the contract of ' + + '`GET /ui/view/:object/:type`, whose declared response is `GetUiViewResponseSchema` — ' + + 'one word apart, 250 lines up. That ruling\'s reasoning happened to survive the ' + + 'mix-up ("nobody can consume `{object, view}` successfully today" was true, though ' + + 'not for the stated reason), which is the luck this removal stops relying on. ' + + 'Route 3: none of the ten was a key on an authorable shape, nothing parsed them, so ' + + 'there is no tombstone and no D2 conversion — RETIRED_DEFS_BY_MAJOR plus this entry ' + + 'are the declaration. If reading and writing ONE view by id becomes a real ' + + 'requirement it returns implementation-first. ADR-0049, ADR-0087, maintainer ruling ' + + '2026-08-07.', + acceptanceCriteria: + 'No source imports `ListViewsRequest(Schema)`, `ListViewsResponse(Schema)`, ' + + '`GetViewRequest(Schema)`, `GetViewResponse(Schema)`, `CreateViewRequest(Schema)`, ' + + '`CreateViewResponse(Schema)`, `UpdateViewRequest(Schema)`, ' + + '`UpdateViewResponse(Schema)`, `DeleteViewRequest(Schema)` or ' + + '`DeleteViewResponse(Schema)` from `@objectstack/spec/api`, and no host declares a ' + + '`ViewProtocol` member. Reading and writing views still works end to end through the ' + + 'surfaces that were always the live ones: `GET /api/v1/meta/view/:name` returns the ' + + 'stored definition and `GET /api/v1/ui/view/:object/:type` returns the resolved view, ' + + 'both unchanged by this removal. `GetUiViewRequestSchema` / `GetUiViewResponseSchema` ' + + 'still resolve — they are the shapes that ruling meant.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts b/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts new file mode 100644 index 00000000000..dda33ceed6e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.workflow-service-slot-retired.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'workflow-service-slot-retired', + surface: + "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / " + + 'discovery routes.workflow / RestApiRouteCategory workflow', + replacement: + 'the live mechanisms the slot only ever pointed at: `state_machine` validation rules ' + + 'for record state machines, approval flow nodes on the approvals runtime (ADR-0019) ' + + 'for approvals, lifecycle hooks + `record_change` flows (service-automation) for ' + + 'record-triggered automation', + reason: + 'The workflow slot was declared end to end and implemented nowhere: no code in either ' + + 'repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches ' + + 'were plugin-dev\'s retired stub probe and the generic discovery walk), no ' + + 'implementation of any WorkflowProtocol method ever existed, and no host ever ' + + 'mounted `/api/v1/workflow` (DEFAULT_DISPATCHER_ROUTES, before it was retired as a ' + + 'stale list, named it among ' + + 'routes that never existed). Every part of it was ADR-0078\'s silently-inert ' + + 'declaration: a CoreServiceName nothing filled, a contract nothing implemented, a ' + + 'protocol nothing served, a discovery route field no builder could truthfully ' + + 'populate. These are TS/API surfaces and a discovery RESPONSE field — never stored ' + + 'in stack metadata, so there is no source for the chain to rewrite; consumers of the ' + + 'deleted types move their imports themselves. ADR-0049 / ADR-0078.', + acceptanceCriteria: + 'No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/' + + 'Transition types resolves; no code calls getService(\'workflow\') or reads ' + + 'discovery `routes.workflow` / `services.workflow`; record state machines, ' + + 'approvals and record-triggered automation go through the replacement mechanisms. ' + + 'Discovery output on a default boot is unchanged (the slot was always reported ' + + 'unavailable; now it is simply absent).', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts index 2e1351366ef..92f5d149ac9 100644 --- a/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts @@ -3,11 +3,10 @@ import type { SemanticMigration } from '../../types.js'; // #23543 F1 — the value retirement's one D3 entry. The D2 half is -// `datasource-driver-mongo-to-mongodb` (protocol 17), now retired from the load -// path. It carries no `conversionIds`: since #25767 protocol 17 is -// MIGRATION_SUPPORT_FLOOR, so no step of the chain replays that conversion, -// and `os migrate meta` lists this entry as the hand edit it is — for the -// spellings inside metadata sources and the ones outside them alike. +// `datasource-driver-mongo-to-mongodb` (a step-17 conversion), which rewrites +// every `driver: 'mongo'` the chain can reach and is now retired from the load +// path; this entry carries what the chain cannot reach — the spellings that +// live outside metadata sources. export const entry: SemanticMigration = { id: 'datasource-driver-mongo-alias-retired', // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it @@ -24,11 +23,10 @@ export const entry: SemanticMigration = { + 'hosts accepted it as a driver selection. At 18 the authoring parse refuses it by name with the ' + 'prescription naming `mongodb`, and it is no longer a selection spelling, so a boot that sets ' + '`OS_DATABASE_DRIVER=mongo` is refused with the list of supported drivers. The D2 conversion ' - + '`datasource-driver-mongo-to-mongodb` belongs to protocol 17, the migration floor, so ' - + "`os migrate meta` applies no edit for it and prints each `datasources[].driver: 'mongo'` in your " - + 'metadata sources among the refusals left after the chain; change the value by hand. Stored ' - + 'datasource rows keep loading because every rehydration seam replays it. Outside metadata sources ' - + 'the same hand edit is owed: an environment variable or deployment manifest ' + + '`datasource-driver-mongo-to-mongodb` makes the mechanical edit wherever the chain reaches: ' + + "`os migrate meta` lists every `datasources[].driver: 'mongo'` in your metadata sources, and stored " + + 'datasource rows keep loading because every rehydration seam replays it. What the chain cannot ' + + 'reach is everything outside metadata sources: an environment variable or deployment manifest ' + 'setting `OS_DATABASE_DRIVER=mongo`, a program that passes `databaseDriver: \'mongo\'` to the ' + 'standalone stack, and a script or integration that creates or updates a datasource through the ' + 'metadata or datasource REST API with `driver: \'mongo\'`. Each of those is refused at 18 and needs ' @@ -40,4 +38,5 @@ export const entry: SemanticMigration = { + "`OS_DATABASE_DRIVER=mongodb` (or with the driver chosen by a `mongodb://` URL scheme and no " + 'override); every script or integration that writes a datasource sends `driver: \'mongodb\'` and ' + 'gets a success answer from the API.', + conversionIds: ['datasource-driver-mongo-to-mongodb'], }; diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-sibling-block-loose-config-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-sibling-block-loose-config-keys-refused.ts index a90c1303c1b..6520e360cf4 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-sibling-block-loose-config-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-sibling-block-loose-config-keys-refused.ts @@ -4,11 +4,10 @@ import type { SemanticMigration } from '../../types.js'; // #23543 F3 — the two lifts' one D3 entry. The D2 halves are // `flow-node-wait-event-config-lift` and `flow-node-connector-config-lift` -// (both protocol 17), now retired from the load path. It carries no -// `conversionIds`: since #25767 protocol 17 is MIGRATION_SUPPORT_FLOOR, so no -// step of the chain replays either conversion, and `os migrate meta` lists this -// entry as the hand edit it is. It also states the one shape no conversion ever -// repaired — a connector node whose pair the lift cannot complete. +// (both step-17 conversions), which move every named loose key the chain can +// reach into its block and are now retired from the load path; this entry +// states the refusal an authored source meets at 18, and the one shape no +// conversion repairs — a connector node whose pair the lift cannot complete. export const entry: SemanticMigration = { id: 'flow-sibling-block-loose-config-keys-refused', // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it @@ -27,12 +26,10 @@ export const entry: SemanticMigration = { + 'parse refuses each one where it is written, naming the block key it belongs on, at `objectstack ' + 'validate`, `objectstack build` and `defineStack`, so under `objectstack lint` too when the config ' + 'calls it — because with the lift gone a loose key would be read by nobody, and a loose `input` ' - + 'beside a complete `connectorConfig` would dispatch the action with no input. Both D2 conversions ' - + 'belong to protocol 17, the migration floor, so `objectstack migrate meta` applies no edit for ' - + 'them and prints each such key in your metadata sources, where it is written, among the refusals ' - + 'left after the chain; move the key by hand. Where the block already sets the same key, the block ' - + 'is the one the node always ran with: delete the loose key. Stored flows keep loading: every ' - + 'rehydration seam replays ' + + 'beside a complete `connectorConfig` would dispatch the action with no input. The D2 conversions ' + + 'make the move wherever the chain reaches: `objectstack migrate meta` lists every such key in your ' + + 'metadata sources. Where the block already sets the same key, the block is the one the node always ' + + 'ran with, and the loose key is deleted. Stored flows keep loading: every rehydration seam replays ' + 'both conversions, and each now deletes the loose keys its block shadows instead of leaving them ' + 'for the parse to refuse; the metadata save door judges a flow after the same replay, so a script ' + 'that still sends the loose spelling keeps saving. What no conversion repairs is a `connector_action` ' @@ -42,4 +39,5 @@ export const entry: SemanticMigration = { '`objectstack validate` reports no refusal at a `config` path of a `wait` or `connector_action` node, ' + 'every such node declares its settings in `waitEventConfig` / `connectorConfig` only, and each wait ' + 'resumes and each connector action dispatches with the same input as before.', + conversionIds: ['flow-node-wait-event-config-lift', 'flow-node-connector-config-lift'], }; diff --git a/packages/spec/src/migrations/entries/semantic/18.job-retry-policy-without-attempts-refused.ts b/packages/spec/src/migrations/entries/semantic/18.job-retry-policy-without-attempts-refused.ts index aa1e9975637..5a3e21eb328 100644 --- a/packages/spec/src/migrations/entries/semantic/18.job-retry-policy-without-attempts-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.job-retry-policy-without-attempts-refused.ts @@ -3,13 +3,11 @@ import type { SemanticMigration } from '../../types.js'; // #23543 F2 — the job default flip's one D3 entry. The D2 half is -// `retry-policy-converged` (protocol 17), which wrote the pre-17 +// `retry-policy-converged` (a step-17 conversion), which writes the pre-17 // `maxRetries: 3` / `backoffMultiplier: 2` into every job retry block the chain -// could reach and is now retired from the load path. It carries no -// `conversionIds`: since #25767 protocol 17 is MIGRATION_SUPPORT_FLOOR, so no -// step of the chain replays that conversion, and `os migrate meta` lists this -// entry as the hand edit it is — with the choice no conversion can make for the -// author: whether a job that never stated a count should retry at all. +// can reach and is now retired from the load path; this entry states the +// refusal an authored source meets at 18, and the choice no conversion can make +// for the author — whether a job that never stated a count should retry at all. export const entry: SemanticMigration = { id: 'job-retry-policy-without-attempts-refused', // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it @@ -25,12 +23,13 @@ export const entry: SemanticMigration = { + 'and `backoffMultiplier: 2` into such a block with a notice. At 18 the job parse refuses the block ' + 'at `retryPolicy.maxRetries` instead, at `objectstack validate`, `objectstack build` and ' + '`defineStack`, so under `objectstack lint` too when the config calls it: "a retry block with no ' - + 'attempts is no retry block". The D2 conversion belongs to protocol 17, the migration floor, so ' - + '`objectstack migrate meta` applies no edit for it and prints each block in your metadata sources ' - + 'that omits the count, or writes 0, among the refusals left after the chain. Stored jobs keep ' - + 'loading with the pre-17 numbers, because every rehydration seam replays the conversion. Decide ' - + 'per job, by hand: write `maxRetries: 3` and `backoffMultiplier: 2` to keep the retries the job ' - + 'ran with before 17, or delete the block if the job should run once. A block that already ' + + 'attempts is no retry block". The D2 conversion makes the 17 edit wherever the chain ' + + 'reaches: `objectstack migrate meta` lists every block in your metadata sources that omits the ' + + 'count and writes both pre-17 numbers into it, which keeps the retries the job ran with; a block ' + + 'that writes `maxRetries: 0` it does not list, and you delete that block by hand (or state a ' + + 'count). Stored jobs keep loading ' + + 'with the same numbers, because every rehydration seam replays the conversion. Decide per job: ' + + 'keep the written count, or delete the block if the job should run once. A block that already ' + 'states `maxRetries` and omits `backoffMultiplier` is not refused, and its delay is now flat ' + '(multiplier 1) where the loader used to write 2: state `backoffMultiplier: 2` to keep the old ' + 'curve.', @@ -38,4 +37,5 @@ export const entry: SemanticMigration = { '`objectstack validate` reports no refusal at a `jobs[].retryPolicy.maxRetries` path; every job that ' + 'should retry states `maxRetries` (1 to 10) in its block, every job that should run once has no ' + 'block, and a failing run is attempted the stated number of extra times.', + conversionIds: ['retry-policy-converged'], }; diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 131c36b9a5b..5b79a2413ef 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -26,7 +26,6 @@ import { } from './spec-changes.js'; const CONVERSION_IDS = new Set(ALL_CONVERSIONS.map((c) => c.id)); -const CONVERSION_TO_MAJOR = new Map(ALL_CONVERSIONS.map((c) => [c.id, c.toMajor] as const)); /** * Ruling B (ADR-0087 D3; the contract is `SemanticMigration` in `./types.ts`): @@ -90,12 +89,7 @@ describe('migration chain (ADR-0087 D3)', () => { links++; if (!CONVERSION_IDS.has(id)) { dangling.push(`protocol ${major}: ${s.id} → ${id} (no registered conversion has this id)`); - } else if (!replayed.has(id) && CONVERSION_TO_MAJOR.get(id)! > MIGRATION_SUPPORT_FLOOR) { - // A conversion of a major AT or BELOW the floor has no step left - // to replay it: its step aged out with the floor (ADR-0087 - // amendment, #25767) while the conversion row stays registered - // for the stored-row door. Such a link pairs with the edit that - // door applies, so it is not dangling. + } else if (!replayed.has(id)) { dangling.push(`protocol ${major}: ${s.id} → ${id} (registered, but no step at or below ${major} replays it)`); } } @@ -221,6 +215,232 @@ describe('migration chain (ADR-0087 D3)', () => { }); }); + // The rationale is not decoration: `docs/protocol-upgrade-guide.md` is a pure + // projection of it (ADR-0087 D4, `gen:upgrade-guide`), so this string IS the + // page an author upgrading 16 → 17 reads. A stale present-tense claim here is + // published advice, which is why it gets pinned like a prescription. + describe('protocol-17 rationale — the app-area section states the CURRENT fact', () => { + const rationale17 = () => MIGRATIONS_BY_MAJOR[17]!.rationale; + + it('does not repeat the retired "the server does not walk `areas`" claim', () => { + // #4722 (same 17.0.0 window) made `filterAppForUser` run the same + // `filterNav` over every `areas[].navigation`. The sentence that survived + // #4651 told an upgrading author to restructure their navigation tree for + // a gate they can now write in place. Same pin as the schema-side + // prescriptions carry since #5336 (`packages/spec/src/ui/app.test.ts`). + expect(rationale17()).not.toMatch(/does not walk/i); + // The rationale still QUOTES the retired boundary — naming what changed + // is how a reader who remembers the old advice knows to drop it — so the + // pin is on the tense, which is the whole defect: the claim may appear as + // history ("was enforced by the shell only"), never as current fact. + expect(rationale17()).not.toMatch(/is enforced by the shell only/i); + expect(rationale17()).toMatch(/was CLOSED by/); + }); + + it('names the server-side fix in words and the two trees an item gate is now enforced in', () => { + const r = rationale17(); + expect(r).toMatch(/was CLOSED by a server-side fix inside this same 17\.0\.0 window/); + expect(r).toMatch(/`filterAppForUser` now runs the SAME `filterNav` over every/); + expect(r).toMatch(/BOTH trees/); + expect(r).toMatch(/areas\[\]\.navigation/); + }); + + it('does not read as reviving the area-LEVEL keys', () => { + // The retirement verdict is untouched: what #4722 enforces are the ITEMS + // inside an area, never a gate of the area's own. A rationale that merely + // went quiet about `areas[]` would leave the reader with the old boundary; + // one that over-corrects would read as an un-retirement. + const r = rationale17(); + expect(r).toMatch(/stay retired/); + expect(r).toMatch(/no gate of its own/); + }); + + it('keeps `visible` client-side only — the half the server-side item gate did NOT change', () => { + // The newly tempting false belief is "areas are gated now, so `visible` + // is fine". `visible` (CEL) is still evaluated in the browser at every + // level, so it hides an entry that has already been served. + const r = rationale17(); + expect(r).toMatch(/client-side ONLY/); + expect(r).toMatch(/`visible` \(CEL\)/); + expect(r).toMatch(/never in `visible`/); + }); + + it('still carries the area-gate removal history the step exists to explain', () => { + // The first half is a record of the state AT the retirement and of why + // route B (remove) beat route A (enforce). Correcting the caveat must not + // erase it — an upgrading author needs to know the keys were fail-open, + // not merely unread. + const r = rationale17(); + expect(r).toMatch(/areas\[\]\.visible/); + expect(r).toMatch(/FAIL-OPEN access gates/); + expect(r).toMatch(/At the time of the retirement/); + expect(r).toMatch(/must not invent an authorization mechanism/); + }); + }); + + // Same class as the block above, one field over. A `semantic` entry's `reason` + // is projected verbatim into `docs/protocol-upgrade-guide.md` and + // `spec-changes.json` (ADR-0087 D4), so a falsified premise recorded here is + // PUBLISHED advice — not a code comment. This entry (#5015) explained its two + // orphans by pointing one level up at #4610, and repeated #4610's stated + // evidence: the `./ui` notification wrappers were "deleted for zero consumers". + // objectui#3310 disproved that at 17.0.0-rc.1 — `packages/types/src/index.ts` + // re-exported both names with `export … from '@objectstack/spec/ui'` and + // `NotificationProtocol.ts` consumed them through the `@object-ui/types` + // barrel, two hops an import-statement-level scan cannot see (the third miss + // of that class, after #4667 / #4709). The RETIREMENT is untouched; only the + // sentence that justified it moves. + describe('protocol-17 NotificationAction / EmbedConfig entry — stops republishing the falsified zero-consumer claim', () => { + const entry = () => + MIGRATIONS_BY_MAJOR[17]!.semantic.find( + (s) => s.id === 'ui-notification-action-embed-config-retired', + ); + + it('finds the entry, and it still explains the dual-source orphaning (anti-vacuity)', () => { + expect(entry()).toBeDefined(); + // The orphaning is stated in words, not by tracker number: the reason is + // printed to the author by `os migrate meta`. + expect(entry()!.reason).toMatch(/dual-source cleanup removed the `\.\/ui` copies/); + expect(entry()!.reason).toMatch(/NotificationConfigSchema/); + }); + + it('never asserts the wrappers were deleted for having zero consumers', () => { + // Pinned on the ASSERTION, not on the words: the entry may still name the + // claim in order to correct it — going quiet about it would leave a reader + // who remembers the old guide believing the old reason. + expect(entry()!.reason).not.toMatch(/(deleted|removed) for (having )?zero consumers/i); + }); + + it('names the correction and keeps the removal itself standing', () => { + const r = entry()!.reason; + expect(r).toMatch(/falsified/); + expect(r).toMatch(/objectui, which re-exported both names/); + // ⛔ A correction to the evidence is not an un-retirement. + expect(r).toMatch(/removal itself stands/); + }); + }); + + // Third of the same class, one field over again: `acceptanceCriteria` is the + // "Done when" line `gen:upgrade-guide` projects verbatim, so a caveat that has + // been overtaken by an enforcement is a published instruction to hand-audit a + // mistake the engine now refuses on its own. #6746 (#6667) added + // `AutomationEngine.refuseUndeclaredSuspension` + // (`packages/services/service-automation/src/engine.ts`), called from + // `executeNode` on every `result.suspend === true`, which made this entry's + // "warned about by NEITHER channel — check those by hand" false in the + // direction that costs a reader work. #6749 fixed the TSDoc half; this is the + // registry channel it explicitly excluded (#6844). + describe('protocol-17 resumeAuthority default-flip entry — supportsPause is enforced now, so stop asking for a hand-audit', () => { + const entry = () => + MIGRATIONS_BY_MAJOR[17]!.semantic.find( + (s) => s.id === 'action-descriptor-resume-authority-default-flip', + ); + + it('finds the entry, and it still states the resumeAuthority criterion (anti-vacuity)', () => { + // Guards the whole block against passing because the entry vanished: every + // negative below is vacuously true on `undefined`, and a `.find()` that + // stops matching is exactly how that happens. + expect(entry()).toBeDefined(); + expect(entry()!.acceptanceCriteria).toMatch(/resumeAuthority/); + expect(entry()!.acceptanceCriteria).toMatch(/supportsPause/); + }); + + it('no longer tells the reader to hand-check the supportsPause mismatch', () => { + // Pinned on the INSTRUCTION, not on wording: the entry may still name the + // old gap in order to say it closed — going quiet would leave a reader who + // remembers the published guide still doing the audit by hand. + const a = entry()!.acceptanceCriteria; + expect(a).not.toMatch(/check (those|them) by\s+hand/i); + expect(a).not.toMatch(/is a declaration nothing\s+enforces/i); + expect(a).not.toMatch(/warned about by NEITHER channel/i); + }); + + it('names the enforcing mechanism and that a `fault` edge cannot route it', () => { + // Matched by idiom, not by sentence: the reader has to be able to FIND the + // guard, which is what distinguishes this from a bare "it is enforced now". + const a = entry()!.acceptanceCriteria; + expect(a).toMatch(/refuseUndeclaredSuspension/); + expect(a).toMatch(/refuses that suspension/); + expect(a).toMatch(/guard-class/); + expect(a).toMatch(/`fault` edge/); + }); + + it('does not over-correct into "nothing left to check"', () => { + // The gate deliberately does NOT judge a descriptor-less executor + // (engine.ts `refuseUndeclaredSuspension`, "What it does NOT judge → + // Silence"; pinned by `supports-pause-runtime-enforcement.test.ts`'s + // descriptor-less case). Claiming total coverage would be the same defect + // as the old over-claim, pointing the other way. + const a = entry()!.acceptanceCriteria; + expect(a).toMatch(/NO descriptor/); + expect(a).toMatch(/resume route/); + }); + }); + + // Fourth of the #5781 class, on the `replacement` field: a projected ledger + // string that asserted a runtime capability the tree does not deliver. The + // entry told an author displaced by the ETL layer's retirement that + // connector-attached sync "IS parsed and executed" — parsed is true + // (`AutomationEngine.registerConnector` runs `ConnectorSchema.parse`), + // executed never was: `syncConfig` has no reader outside `packages/spec`, the + // same measurement that retired `syncConfig.schedule` under ADR-0049. Because + // `replacement` is projected verbatim into `spec-changes.json` (which ships in + // the `@objectstack/spec` tarball) and into `docs/protocol-upgrade-guide.md`, + // the claim was published advice, not a code comment. ⚠️ Nothing in this repo + // cross-checks a projected ledger string against the tree it describes — the + // mechanism is a per-entry pin like this one, added after each defect is + // found. That gap is the recurring cause; this pin only closes THIS entry. + describe('protocol-17 `etl-pipeline-layer-retired` — states what happens to `syncConfig`, not a sync that never ran', () => { + const entry = () => + MIGRATIONS_BY_MAJOR[17]!.semantic.find((s) => s.id === 'etl-pipeline-layer-retired'); + + it('finds the entry, and it still routes the author layer by layer (anti-vacuity)', () => { + // Guards every negative below against passing on `undefined`, which is + // exactly how a `.find()` that stops matching reads as green. + expect(entry()).toBeDefined(); + expect(entry()!.replacement).toMatch(/Layer by layer/); + expect(entry()!.replacement).toMatch(/ConnectorSchema\.syncConfig/); + }); + + it('⛔ never claims connector-attached sync is executed', () => { + // Pinned on the CLAIM, not on the words: the entry may still name the + // execution question in order to answer it, which is what the corrected + // sentence does — going quiet would leave a reader who remembers the + // published guide still expecting a sync to run. + const r = entry()!.replacement; + expect(r).not.toMatch(/IS parsed and executed/); + expect(r).not.toMatch(/syncConfig[^.]{0,80}\bis executed\b/i); + }); + + it('says what `syncConfig` IS and what actually happens to it', () => { + // A false promise replaced by a vague one would be the same defect wearing + // a fix's clothes: the author has to come away knowing the block is a + // declared shape, and knowing where the parse happens. + const r = entry()!.replacement; + expect(r).toMatch(/PARSED AND VALIDATED but NOT\s+EXECUTED/); + expect(r).toMatch(/declared shape/); + expect(r).toMatch(/AutomationEngine\.registerConnector/); + expect(r).toMatch(/no\s+reader outside `packages\/spec`/); + }); + + it('names the surface that IS executed, so the author has somewhere to go', () => { + // The measurement behind this line: `connector_action` + // (`service-automation/src/builtin/connector-nodes.ts`) resolves the + // registered handler and awaits it. Without this the correction would + // leave the ETL-displaced author with no route at all. + const r = entry()!.replacement; + expect(r).toMatch(/`actions`/); + expect(r).toMatch(/connector_action/); + }); + + it('the retirement itself still stands — this is a correction to the advice', () => { + // ⛔ Correcting a projected sentence is not an un-retirement. + const r = entry()!.replacement; + expect(r).toMatch(/removed — no protocol surface replaces it/); + expect(entry()!.acceptanceCriteria).toMatch(/No source imports `ETLPipeline`/); + }); + }); + // The D3 half of a node-level refusal, and the one class of entry whose // ABSENCE is invisible to every other gate in this family: `check:spec-changes` // and `check:upgrade-guide` pin the registry to its PROJECTIONS, so an entry @@ -356,38 +576,32 @@ describe('migration chain (ADR-0087 D3)', () => { // hop's. Written against the constant rather than the literal 16: the next // floor move should re-point this replay, not delete it. const OLDEST_HOP = MIGRATION_SUPPORT_FLOOR + 1; - // Two protocol-17 shapes the oldest hop (17 → 18) rewrites: a job's - // `timeout` (renamed to `timeoutMs`) and an object's `externalSharingModel` - // (removed). The pair this used to drive (`execute` → `target`, - // `conditionalRequired` → `requiredWhen`) rode the 16 → 17 step, which - // aged out with the 16.x kits (#25767). const oldShape = () => ({ - jobs: [{ - name: 'nightly_health_sweep', - schedule: { type: 'cron', expression: '0 1 * * *' }, - handler: 'sweepProjectHealth', - timeout: 300000, - }], + actions: [{ name: 'convert', label: 'Convert', type: 'script', execute: 'convertHandler' }], objects: [ - { name: 'portal_case', label: 'Case', sharingModel: 'public_read', externalSharingModel: 'private' }, + { + name: 'crm_task', + label: 'Task', + fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, + }, ], }); it('migrates the oldest supported major\'s shapes to canonical', () => { const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); - const job = (result.stack.jobs as any[])[0]; - expect(job).not.toHaveProperty('timeout'); - expect(job.timeoutMs).toBe(300000); - const object = (result.stack.objects as any[])[0]; - expect(object).not.toHaveProperty('externalSharingModel'); - expect(object.sharingModel).toBe('public_read'); + const action = (result.stack.actions as any[])[0]; + expect(action).not.toHaveProperty('execute'); + expect(action.target).toBe('convertHandler'); + const field = (result.stack.objects as any[])[0].fields.due_date; + expect(field).not.toHaveProperty('conditionalRequired'); + expect(field.requiredWhen).toBe('record.stage == "closed"'); // Two mechanical rewrites, named — a count alone would survive one of // them being replaced by an unrelated conversion firing on this shape. expect([...new Set(result.applied.map((a) => a.conversionId))].sort()).toEqual([ - 'job-timeout-to-timeout-ms', - 'object-external-sharing-model-removed', + 'action-execute-to-target', + 'field-conditionalRequired-to-requiredWhen', ]); // Semantic TODOs are advisory per-major and always surfaced for the hop, // whatever the stack contains. @@ -412,7 +626,7 @@ describe('migration chain (ADR-0087 D3)', () => { const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); expect(result.hops).toHaveLength(1); expect(result.hops[0]!.toMajor).toBe(OLDEST_HOP); - expect((result.hops[0]!.stack.jobs as any[])[0].timeoutMs).toBe(300000); + expect((result.hops[0]!.stack.actions as any[])[0].target).toBe('convertHandler'); }); }); diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 37727eb669a..033ef951d94 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -9,17 +9,12 @@ * `semantic` list carries one entry per retirement family, even when a lossless * D2 conversion also exists for it — D2 carries the mechanical data repair only. * - * The chain is a **rolling artifact** (ADR-0087 amendment 2026-10-26, #25767): - * every step back to {@link MIGRATION_SUPPORT_FLOOR} stays replayable, and CI - * replays the full chain from the oldest supported major's fixtures to current - * on every release (`migrations.test.ts`). A retirement kit — the tombstone, its - * `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` entries, its semantic entry - * and its conversion — lives ONE protocol major past the one it retired in and - * is then removed whole: at major 18 the 17.x and 18.x kits stay and the ≤16.x - * ones went. After the removal the key is refused as a strict unknown key and - * the refusal names the upgrade guide; nothing converts it. The support floor is - * the same knob, read the other way — how far back `migrate meta --from N` - * reaches — and it rises with each major so that the two never disagree. + * The chain is a **forever artifact**: every step back to + * {@link MIGRATION_SUPPORT_FLOOR} stays replayable, and CI replays the full chain + * from the oldest supported major's fixtures to current on every release + * (`migrations.test.ts`). The support floor is an explicit release-policy knob — + * how far back `migrate meta --from N` reaches — revisitable per major, never an + * accident of deletion. * * ## ⚠️ `registry.ts` is GENERATED WHOLE and git-ignored (#7297, #22554) * @@ -69,27 +64,1098 @@ import type { MigrationStep } from './types.js'; * * > 升级只需要支持从 16.0版本开始。 * - * Raised 16 → 17 by maintainer ruling, 2026-10-26 (#25767, ADR-0087 amendment, - * D2 = A): a retirement kit lives one protocol major past the one it retired - * in. At major 18 that keeps the 17.x and 18.x kits and removes the ≤16.x ones, - * and the floor follows: `os migrate meta --from 16` (and below) is refused - * with the upgrade-guide link and converts nothing. The next major repeats the - * move — `CURRENT_MAJOR - 1` is the floor, not a number chosen per release. - * * Raising it is what RETIRES the steps below it: `step11`–`step16` went with - * the first move and `step17` with the second, because a step the floor no - * longer reaches is replayed by nothing and CI stops proving it still works — - * dead code that reads as a promise. `applyMetaMigrations(doc, N)` throws - * `MigrationFloorError` for N < 17; those consumers reach 17 by another path - * first (a release that still carried the step, e.g. @objectstack/spec 18.1). + * this move, because a step the floor no longer reaches is replayed by nothing + * and CI stops proving it still works — dead code that reads as a promise. + * `applyMetaMigrations(doc, N)` now throws `MigrationFloorError` for + * N ∈ 10..15; those consumers reach 16 by another path first. + * + * ⚠️ The floor scopes the CHAIN and nothing else. {@link RETIRED_KEYS_BY_MAJOR} + * and {@link RETIRED_DEFS_BY_MAJOR} are keyed by major but are NOT floor-scoped: + * their only consumer (`scripts/build-schemas.ts`) folds every major into one + * set, so a row below the floor is still the live proof that its retirement was + * declared. ⛔ Never drop rows from either table when this constant moves. + */ +export const MIGRATION_SUPPORT_FLOOR = 16; + +/** + * Protocol 17 step. + * + * Mechanical: the three deprecated aliases that a schema transform used to + * fold into a canonical key and drop from the parsed output (`action.execute`, + * `field.conditionalRequired`, `agent.knowledge.topics`) are removed from the + * spec. Each is a pure key rename with an unchanged value, so each replays + * losslessly as a D2 conversion. None of the three carries a D3 entry: this + * step shipped before every retirement family was required to carry one + * (`SemanticMigration`, `./types.ts`), and it was not back-filled when that + * rule landed. The step's `semantic` list is NOT empty — its later retirements + * carry their D3 entries in the generated region below. * - * ⚠️ The floor scopes the CHAIN; {@link RETIRED_KEYS_BY_MAJOR} and - * {@link RETIRED_DEFS_BY_MAJOR} are keyed by major but are not read through it: - * their consumer (`scripts/build-schemas.ts`) folds every major into one set. - * Their rows leave on the same one-major clock as the tombstones they date — - * see each table's "Lifecycle" — never because this constant moved. + * The three conversions are `retiredFromLoadPath` from the day they land: 17 + * gives the aliases no acceptance window at all, and each schema tombstones its + * key with a fix-it error instead. This step is what makes that affordable — + * `os migrate meta --from ` lists the consumer's edits site by site rather + * than leaving them to hand-edit against a changelog. */ -export const MIGRATION_SUPPORT_FLOOR = 17; +const step17: MigrationStep = { + toMajor: 17, + rationale: + [ + 'Protocol 17 removes the last three deprecated authorable aliases: action ', + '`execute` (use `target`), field `conditionalRequired` (use `requiredWhen`), and ', + 'agent `knowledge.topics` (use `knowledge.sources`). Each was already lowered into ', + 'its canonical key at parse time and dropped from the parsed output, so no runtime ', + 'behaviour changes — only the authorable surface shrinks to one spelling per slot. ', + 'All three are pure key renames with unchanged values and replay losslessly; the ', + 'schemas reject the removed spellings with a fix-it error naming the replacement.\n\n', + 'It also removes the sharing-rule access level `full`: declared as ', + '"Full Access (Transfer, Share, Delete)" but never enforced as anything but ', + '`edit` — both gates matched `edit`/`full` alike, so Setup promised admins a ', + 'delete grant it never issued (ADR-0078). Unlike the OWD `sharingModel: \'full\'` ', + 'alias retired at step 13, this one HAS a lossless target precisely because it ', + 'was inert — old and new shapes are behaviourally identical — so it converts ', + 'mechanically and leaves no semantic residue. It is the one protocol-17 ', + 'conversion that keeps a load-path acceptance window: it had no prior ', + 'deprecation, and a removed enum value cannot carry the fix-it error the three ', + 'key renames tombstone theirs with.\n\n', + 'Finally it removes agent `tools`: the legacy inline ', + '`{type,name,description}[]` fallback, which the runtime resolved against the ', + 'FULL tool registry with no surface check — the one seam that broke ADR-0064\'s ', + '"an agent reaches exactly its surface-compatible skills\' tools, nothing falls ', + 'through to the global registry". Unlike the renames above this has NO lossless ', + 'target: each entry has to become a reference inside a skill, which is a human ', + 'decision about which skill. The conversion therefore drops the dead key (the ', + 'cloud runtime had stopped reading it, so it already contributes nothing) and ', + 'emits a notice per agent so the author knows where capability must be ', + 're-declared; the schema tombstones the key with a fix-it error naming `skills`.\n\n', + 'Beyond those spec-surface removals, it graduates the seven flow-node config key ', + 'aliases the executors still ', + 'tolerated: the CRUD nodes\' `object` (use `objectName`) — the last tenant ', + 'of the `readAliasedConfig` executor shim, which is deleted with it — plus the six ', + 'open-coded fallbacks that never went through that shim: notify `to`/`subject`/', + '`body`/`url` (use `recipients`/`title`/`message`/`actionUrl`) and script ', + '`functionName`/`input` (use `function`/`inputs`). All are pure key renames with ', + 'unchanged values and replay losslessly. Like the sharing-rule access level above ', + 'they keep a load-path acceptance window: none carried a prior deprecation ', + 'warning, and `FlowNodeSchema.config` is an unconstrained record, so no schema ', + 'tombstone can reject them — the conversion layer is the only seam that can ', + 'declare, convert, and retire them.\n\n', + 'The same graduation covers `wait`, whose fallback was not a config-to-config ', + 'rename. `wait` keeps its contract in the declared `waitEventConfig` ', + 'block, not in `config` at all — yet the executor also read six loose `config` ', + 'keys, two of them (`duration`, `signal`) spellings the spec never declared. ', + 'The conversion lifts them onto the declared block in the executor\'s own `??` ', + 'precedence, so a value already declared wins and its loose counterpart is left ', + 'shadowed. One wrinkle makes this a rewrite rather than a delete: ', + '`waitEventConfig.eventType` is required once the block exists, and the loader ', + 'parses the CONVERTED flow — so a source carrying only `config: { duration }` ', + 'is stamped with `eventType: \'timer\'`, the exact default the executor applied ', + 'to that shape. Behaviour-preserving in both directions.\n\n', + '`connector_action` gets the same lift for the opposite reason. Its ', + 'contract also lives in a declared sibling block (`connectorConfig`), and the ', + 'executor never read `config` at all — but the node\'s descriptor published a ', + '`configSchema` declaring `connectorId`/`actionId`/`input` as `config` keys, and ', + 'the Studio inspector derives its form from a published schema, so schema-driven ', + 'authoring wrote the trio to the wrong place and produced nodes that refused to ', + 'dispatch. The conversion lifts the trio onto the declared block (declared keys ', + 'win; a lift that cannot complete the required connectorId+actionId pair leaves ', + 'the node untouched rather than turning a step-time refusal into a load ', + 'failure), and the descriptor stops publishing the mis-rooted schema.\n\n', + 'The reconciliation that found those also found `map`, whose executor read a ', + 'bare `cfg.flowName ?? cfg.flow` for an undeclared `flow` spelling no schema ', + 'ever described. A pure rename, graduated the same way, so the ', + 'executor reads only the canonical `flowName`.\n\n', + 'And it removes the RLS-policy key `priority` (found by the liveness ledger\'s security re-verification): promised ', + '"conflict resolution" that cannot exist, because no outcome depends on an order: ', + 'applicable policies OR-combine on reads, and a write\'s check is chosen once per ', + 'operation across the applicable policies, then OR-combined (see ', + '`RowLevelSecurityPolicySchema.check`) — there is never a conflict to order, and nothing ever ', + 'read the key (call graph closed across the collection site, the projection ', + 'round-trip and the compiler). A pure lossless delete: outcomes are identical ', + 'with or without it; the schema tombstones the key with the same prescription.\n\n', + 'The same close-out retires the four inert tool authoring keys (`category`, ', + '`permissions`, `active`, `builtIn`): none is part of AIToolDefinition and no ', + 'execution path read them. Two were misleading in the dangerous direction — ', + '`permissions` promised an invocation gate nothing enforced, and `active: false` ', + 'read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless ', + 'deletes; the strict ToolSchema rejects each with its prescription.\n\n', + 'The AppSchema sheds its seven dead authoring keys (2026-06 liveness audit, ', + 'the unknown-key strictness wave\'s app step): `version` (apps are versioned by manifest.version), `aria`, ', + '`objects`/`apis` (the self-described "config file convenience" — nothing read ', + 'them; the chatbot derives an app\'s objects from its nav items), `sharing`/', + '`embed` (a declared-but-unenforced public surface — the only live path is ', + 'FormView.sharing; ADR-0049), and `mobileNavigation` (fully unimplemented). ', + 'Pure lossless deletes — none ever had a runtime effect; each key is ', + 'tombstoned with its prescription.\n\n', + 'ADR-0113 splits the `required` tri-binding: post-17, `required` is ONLY the ', + 'write-time contract (insert must provide; update may not null out; legacy null ', + 'rows rest), and the physical NOT NULL is the explicit `storage.notNull`. ', + '⚠️ NOTHING converts the column half for you, and nothing tightens a column you ', + 'already have. A `field-required-notnull-explicit` conversion did stamp ', + '`storage.notNull: true` onto every `required: true` field; it was WITHDRAWN ', + '(maintainer ruling 2026-09-08), because stamping the constraint wherever ', + '`required: true` appears is exactly the implication the ADR abolished — and ', + 'because the artifact-ingestion door replays retired conversions, so the LOADER ', + 'applied it to 17-authored sources that deliberately omit it and then told their ', + 'authors to write the same tightening into the source, which on a populated ', + 'database is a destructive `tighten_not_null` migration prescribed as the remedy ', + 'for a deprecation notice. Post-17 a column is NOT NULL because its author wrote ', + '`storage: { notNull: true }`, and for no other reason. If you are upgrading a ', + 'pre-17 source whose columns ARE NOT NULL and you want them to stay that way, ', + 'add `storage: { notNull: true }` to those fields yourself — deliberately, and ', + 'knowing that doing it to a field whose column is currently nullable is a ', + 'destructive migration with a backfill ceremony.\n\n', + 'On the wire contract it also retires the `/analytics/query` request ENVELOPE ', + 'outright: `AnalyticsQueryRequestSchema` used to describe `{ cube, query: {...}, ', + 'format }` — the dialect of the degraded analytics shim (retired for serving unscoped aggregates) that the ', + 'real engine never understood (an envelope body inferred a column-less cube and ', + 'died as an SQL syntax error). The canonical request body is now the BARE ', + 'AnalyticsQuery — `cube` + `measures` at the top level — which is what every ', + 'real caller already sends; the schema tombstones `query`/`format`, and the ', + 'dispatcher entry validates bodies and answers 400 with the prescription. No ', + 'stored metadata carries this shape (it was HTTP-only), so the change is two ', + 'semantic TODOs for API callers rather than a stack conversion.\n\n', + 'The close-out sweep finishes the enforce-or-remove worklist across the ', + 'remaining types: action `shortcut`/`bulkEnabled` (no keydown path; the ', + "multi-select toolbar reads the view's bulkActions), flow `active`/`template`/", + 'node `outputSchema`/errorHandling `fallbackNodeId` (`active: false` never ', + 'stopped a flow — `status` is the enforced lifecycle; faults route via ', + 'per-node fault edges), the inert view keys (list `responsive`/`performance`, ', + 'form `data`/`defaultSort`/`aria` — list aria/data stay live), dashboard and ', + 'widget `aria`/`performance`, `agent.knowledge` (declaring sources never ', + 'scoped retrieval — absorbs the former topics→sources rename), and ', + "`skill.triggerPhrases` (phrases were never matched; routing is ", + 'triggerConditions + the agent allowlist). All pure lossless deletes, each ', + 'tombstoned at its schema with the prescription.\n\n', + 'One flow key changes WITHOUT a lossless target: `errorHandling.maxRetries` ', + '— it carried two defaults: `.default(0)` in FlowSchema and ', + "`maxRetries ?? 3` in the engine's retryExecution — and because `??` fires ", + 'only on `undefined`, an unstated count meant 0 retries for a flow parsed by ', + 'the schema and 3 for a definition handed to the engine directly: the retry ', + "count was a function of the route in, not of the authored flow. The engine's ", + 'copy is deleted (it reads the parsed block, no fallback), which makes an ', + "unstated count unambiguously 0 — and `strategy: 'retry'` that retries zero ", + "times is `strategy: 'fail'` under another name, the declared-not-delivered ", + 'shape ADR-0049 exists to close. The schema therefore requires `maxRetries` ', + "at least 1 under `'retry'`, in both spellings (omitted, and an explicit 0). ", + 'This is the one v17 flow change the chain cannot apply for you: choosing the ', + 'count is a judgment about re-running the WHOLE flow with its side effects, ', + 'so it is a semantic TODO rather than a rewrite.\n\n', + 'It also retires `api.requireAuth`: the deployment-wide opt-out that let a ', + 'stack serve its ENTIRE data plane anonymously with one boolean. Auth is a kernel ', + 'concern, not a deployment posture — anonymous access to object data is now denied ', + 'unconditionally on every HTTP surface. Every surface that legitimately serves a ', + 'session-less caller derives its own narrow authorization from a DECLARATION instead: ', + 'the control-plane allowlist, `publicFormGrant` (public form views), share-link tokens ', + "(read as SYSTEM), and `book.audience: 'public'` (ADR-0046 §6.7). The key is dropped ", + 'with a notice rather than mapped — there is no replacement value, only a different ', + 'way to publish (by declaration). A stack that mounts no auth at all now fails at boot ', + 'when it would serve a data API, instead of receiving an implicit fail-open.\n\n', + 'The same major retires `BatchOptions.validateOnly`: a batch "dry-run" flag that ', + 'was declared but never implemented — every batch surface (`updateManyData` / ', + '`deleteManyData` / `batchData`) persisted regardless, so a caller sending it to PREVIEW a ', + 'mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag ', + 'lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the ', + 'key with the prescription. It is HTTP-only (never stored in stack metadata), so the ', + 'change is one semantic TODO for API callers rather than a stack conversion.\n\n', + 'The batch response rows converge on their declared schema in the same window: ', + 'the per-row results of `/batch`, `/updateMany` and `/deleteMany` used to carry a legacy ', + 'implementation shape — `error: string`, `record`, no `index` — while ', + '`BatchOperationResultSchema`, the published client SDK type and the reference docs all ', + 'declared `errors: ApiError[]` / `data` / `index`. A consumer written against the ', + 'declaration read `row.errors` and got `undefined` at runtime — the exact "photographed ', + 'from the schema, dead on the wire" failure the enhanced-api-error rename above records. ', + 'The wire now delivers the declared shape, and the ADR-0119 D4 rollback marking is ', + 'structured with it: `ROLLED_BACK:` / `NOT_ATTEMPTED:` message prefixes become ', + 'first-class `ApiError.code` values, so clients branch on `errors[0].code` instead of ', + 'regexing message strings. A RESPONSE surface, never stored in stack metadata, so there ', + 'is no source for the chain to rewrite — one semantic TODO for readers of the old row ', + 'keys.\n\n', + 'It also narrows `QueryAST.fields` to field names: the `FieldNode` union carried a ', + 'second `{ field, fields, alias }` nested-select member that nothing produced and nothing ', + 'consumed — every reader on the path treats the list as `string[]`, so the object form was ', + 'dropped by the SQL and memory drivers, projected as a column named "[object Object]" by ', + 'MongoDB, and refused by the REST ingress as an unknown field of that name. `expand` is the ', + 'one spelling for nested selection (ADR-0049 enforce-or-remove; Prime Directive #12: one ', + 'capability, one contract). Like the two above it is a request shape, never stored, so the ', + 'chain has no source to rewrite.\n\n', + 'The `QueryAST` liveness sweep applies the same method to the rest of the request surface: `query.joins` ', + 'and `query.windowFunctions` are tombstoned — no engine or driver ever read either on the ', + 'query path, so every join and OVER clause a caller declared was silently dropped. Joins ', + 'were the second, broken spelling of related-record retrieval (`expand` is the live one; ', + 'the whole JoinNode cluster goes with the key), and window functions only ever ran behind ', + '`SqlDriver.findWithWindowFunctions()`, a driver-level door whose flat input shape the ', + 'spec vocabulary never matched (it declared `field`/`over`/`frame` members the door never ', + 'read — that cluster goes too). Request shapes again: two semantic TODOs, no source ', + 'rewrite.\n\n', + 'That sweep\'s close-out settles the remaining three. `having` is ENFORCED, not removed — ', + 'the engine applies it after aggregation on both paths, so the clause every SQL-literate ', + 'author expects now works (no migration; queries that carried it were silently returning ', + 'every group and now filter as written). `cursor` and `distinct` are tombstoned WITH ', + 'their shipped SDK producers (`QueryBuilder.cursor()` / `.distinct()` are deleted): no ', + 'driver ever implemented keyset pagination or SELECT DISTINCT, `cursor` re-served page 1 ', + "forever, and `distinct`'s only observable effect was mis-wired — it suppressed the REST ", + 'list count, which is now truthful again. Both are request shapes; two more semantic ', + 'TODOs, no source rewrite.\n\n', + 'The same kind of retirement covers `wait`\'s timeout pair. `waitEventConfig.onTimeout` ', + 'had ZERO readers — no path ever inspected it, so neither `fail` nor `continue` ever ', + 'happened, while its `.default(\'fail\')` stamped a decision nothing made onto every wait ', + 'node. `waitEventConfig.timeoutMs` said "maximum wait time before timeout" and its only ', + 'reader used it as the timer DURATION when `timerDuration` was absent: it did something, ', + 'just not what it said. Together they declared a timeout `wait` does not have — the run ', + 'resumes when its timer elapses or its signal arrives, never on a deadline. Rather than ', + 'retrofit an implementation to fit two keys that happened to be declared, the pair is ', + 'retired and real timeout semantics are left to be built to a requirement. `timeoutMs` ', + 'converts to `timerDuration` (stringified — the target is `z.string()` and ', + '`parseIsoDuration` reads a bare numeric string as milliseconds, so the wait is unchanged); ', + 'with `timerDuration` already set it is dropped, having been dead metadata. Like the other ', + 'keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the ', + 'load path: absorbing them silently would let an author keep believing they configured a ', + 'timeout.\n\n', + 'Closing the same audit on the data side, `datasource.readReplicas` is removed. ', + 'It described replica connections nothing ever opened: `ConnectableDatasource` and ', + '`DatasourceConnectionSpec` carry no replicas field, the driver factory never reads the ', + 'key, and no query path distinguishes a read from a write — read/write splitting does not ', + 'exist in the platform, so every statement always went to the primary. A lossless delete ', + 'with no target to move to; front replicas behind one endpoint (pgpool, ProxySQL, an RDS ', + 'reader endpoint) and point `config` at it. Notable as the case that shows how a key gets ', + 'MORE convincing as it stays dead: the change that closed the datasource-config gap taught the ', + 'schema to validate each replica entry against the declared driver\'s config contract, so ', + 'sources written in between carry replica blocks that were genuinely checked — precise ', + 'hosts, correct port types, typos rejected. Precision applied to an inert slot reads as ', + 'evidence the slot is live, which is why ADR-0049 asks for a consumer rather than for ', + 'rigor. Retired from the load path with the rest of the keys that misdescribed themselves.\n\n', + 'The datasource close-out also graduates the four legacy `datasource.config` spellings the ', + 'shared driver factory still tolerated via undeclared read-side `??` fallbacks (a follow-up to that ', + 'validation change): sqlite `file`/`database` (use `filename`), postgres/mysql ', + '`connectionString` (use `url`) and `user` (use `username`), and mongo `uri` (use `url`) ', + 'and `user` (use `username`). That validation change made the authoring gate reject each with a rename ', + 'hint, but a runtime datasource persisted in `sys_metadata` before the gate kept working ', + 'only because the factory read leniently — and deleting that tolerance without a ', + 'conversion would have silently moved data (a stored sqlite `file:` row falls back to ', + '`:memory:`). The `datasource-config-driver-key-aliases` conversion rewrites the stored ', + 'shape to the canonical keys at every rehydration seam, the factory now reads exactly one ', + 'spelling per key, and the four `??` chains are deleted. Driver-aware by construction: ', + '`database` renames only under sqlite, where it aliased the file path — for every other ', + 'driver it is a canonical key and is untouched. Retired from the load path not for lying ', + 'but because the authoring gate already rejects the spellings loudly; the chain and the ', + 'stored-row replay are the seams that accept them.\n\n', + 'Finishing the same datasource surface, the canonical driver id `mongo` is renamed to ', + '`mongodb`. The two spellings have both been accepted since `datasource.config` was first ', + 'parsed against its driver\'s own contract, and both still ', + 'are, so no boot breaks and no data moves — what changed is which one is CANONICAL, and ', + 'that string is published as `DRIVER_CATALOG.id` and is what the Studio connection form ', + 'writes into `datasource.driver`. Every row written before the rename therefore carries ', + '`mongo` while the form now emits `mongodb`, leaving one deployment with two spellings of ', + 'one driver and any reader that matches a stored driver against the published catalog id ', + 'silently missing the older rows. The `datasource-driver-mongo-to-mongodb` conversion ', + 'converges the stored value at every rehydration seam; it stays on the LIVE load path ', + '(unlike the config-key aliases beside it) precisely because `mongo` is still legal — ', + 'there is no loud rejection for it to pre-empt, and nothing to lose by converging early. ', + 'The rename is what let the driver-selection id and the config-contract id become one ', + 'string: `packages/spec`\'s driver vocabulary is now a single table both boot hosts read, ', + 'which closed the last fork where `OS_DATABASE_DRIVER=pg` booted under `os start` and was ', + 'refused by `os migrate`. `turso`/libSQL joins the same table with a real config contract, ', + 'so a libSQL `config` is validated instead of waved through.\n\n', + 'The `script` flow node converges on its one real path. It had four ways to name ', + 'what it ran and only one of them ran anything: `config.actionType: \'email\' | \'slack\'` ', + 'were logger-backed stubs that wrote a line, reported success and delivered nothing under ', + 'any configuration — with `config.template` / `.recipients` / `.variables` feeding a ', + 'message no channel ever sent; inline `config.script` was recognized and never executed ', + '(the built-in runtime has no server-side JS sandbox), so the node warned and no-op\'d; and ', + 'every other `actionType` value was shorthand for a registered-function name, a second ', + 'spelling of `config.function`. All five keys are retired and `function` becomes required, ', + 'which is also what finally made the contract PARSEABLE: while the legal key set depended ', + 'on `actionType`, a flat parse would either reject valid shapes or wave everything through, ', + 'so `script` (with `subflow`) now runs through the same execute-time config `parse()` the executor wiring ', + 'gave the flat builtins. A shorthand `actionType` CONVERTS into `function` — that is what ', + 'it meant — unless `function` is already set, in which case it was dead metadata the ', + 'executor never reached. The other four are dropped outright: nothing read them, so there ', + 'is no value to preserve, and rebuilding the intent is an authoring decision the tombstones ', + 'prescribe per branch (a `notify` node for mail — it delivers through the messaging ', + 'service, the in-app inbox by default and real email once `@objectstack/plugin-email` is ', + 'installed; a `connector_action` with the Slack connector, or an `http` node posting to a ', + 'webhook, for Slack; a registered function for an inline body). Retired from the load path ', + 'for the same reason as the rest: absorbing `actionType: \'email\'` silently would let an ', + 'author keep believing the flow sends mail.\n\n', + 'The same audit reaches the driver contract itself: `IDataDriver.findStream` is removed ', + 'from the contract. It was REQUIRED — every driver and every test double had to implement it — and ', + 'documented as the read "optimized for large datasets to avoid memory overflow", while ', + 'two of its three implementations awaited `find()` for the whole result set and then ', + 'yielded it row by row, reaching exactly the peak it promised to avoid; the third ', + 'streamed for real but was the one read in that driver that skipped `buildFindOptions`, ', + 'so it dropped `query.fields`. Nothing anywhere called it, which is why a contract ', + 'method could carry an inverted guarantee for this long and why ~20 test doubles could ', + 'satisfy it by throwing `not implemented`. Paged `find()` is the read that exists and is ', + 'enforced (its total-order guarantee is checked by the shared pagination-conformance ', + 'cases); a cursor-based read is worth building when a caller asks for one, which is the ', + 'honest order. A TS/API surface, never stored — one semantic TODO for driver authors, no ', + 'source rewrite, and no tombstone: `DriverInterfaceSchema` describes a contract that ', + 'code IMPLEMENTS and nothing ever `.parse()`d a driver, so tsc is the only channel that ', + 'could carry the prescription, and it carries it where it matters — at a call site.\n\n', + 'Separately, `object.managedBy: \'system\'` is retired in favour of `\'system-data\'`, ', + 'finishing the split ADR-0103 began in v16. That split was deliberately ADDITIVE: the 20 ', + 'engine-owned objects moved to the new explicit `engine-owned`, and the 8 admin/user-', + 'writable ones — the RBAC link tables, `sys_user_preference`, the three messaging config ', + 'grids — stayed behind on `system`. What was left is a value whose name describes the half ', + 'that had already moved out: "system" sitting on precisely the objects a user writes. That ', + 'is not a cosmetic complaint. An author choosing between `system` and `engine-owned` had ', + 'nothing in the vocabulary to choose on, so the bucket was re-overloadable by anyone ', + 'reading the name in good faith — a model author most of all. `system-data` states both ', + 'boundaries: the SCHEMA is the platform\'s (versus `platform`, which is tenant-modelled), ', + 'the DATA is the admin\'s or the user\'s (versus `engine-owned`, where the engine owns both). ', + 'Reusing `config` was considered and rejected — `sys_user_preference` is user-owned rather ', + 'than admin-authored, and `config` suppresses CSV import — as was `platform-data`, which ', + 'sits one word away from the unrelated `platform` in the same closed enum and would ', + 'reintroduce the confusion at the point of choosing. Because v16 already drained the ', + 'engine side, the conversion is a ONE-TO-ONE mechanical value rename with no judgement ', + 'call. One deliberate consequence: `system` defaulted LOCKED and each object re-opened its ', + 'writes through `userActions`, while `system-data` defaults WRITABLE on create, edit, ', + 'delete and exportCsv, so those blocks become redundant and are deleted (keep ', + '`userActions` only to NARROW). CSV `import` is the one verb that default deliberately ', + 'withholds (maintainer ruling 2026-08-03): it stays opt-in per object via `userActions: { import: true }`, so a ', + 'v16 `system` object — which resolved `import: false`, because the re-open blocks only ', + 'ever named create/edit/delete — keeps resolving `import: false` after the rename. The ', + 'reason is leverage, not authorization: three of the eight members are the RBAC link ', + 'tables, and a bulk-grant entry point on the permission model\'s grant surface should be ', + 'a per-object declaration rather than something inherited by being filed in the right ', + 'bucket. No enforcement ', + 'moves — the engine write guard, the DelegatedAdminGate, RLS and permission sets all ', + 'adjudicate off resolved affordances and the principal, never off the bucket name; ', + '`system-data` simply joins `platform`/`config` as a bucket the guard does not cover, ', + 'because a writable default has nothing to fail closed on. Retired from the load path: ', + 'the enum rejection is what teaches the new spelling, and absorbing `\'system\'` silently at ', + 'load would leave every author writing the name this rename exists to retire.\n\n', + 'Finally, five keys retire because the advisory lint could never have warned about them ', + '— mapping `extractQuery` / `errorPolicy` / `batchSize`, and app ', + '`contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, ', + 'and a default materialises at parse time — so the liveness lint cannot tell a value the ', + 'author wrote from one the schema supplied, and marking them would have warned on every ', + 'mapping and every selector in existence. For a key in that state removal is not the ', + 'escalation after a warning; it is the only channel that ever reaches the author, which ', + 'is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What ', + 'they claimed: `extractQuery` promised an export path no exporter implements (exports go ', + 'through the ordinary query API); `errorPolicy` offered skip/abort/retry where error ', + 'handling belongs to the import REQUEST; `batchSize` sized batches the write path sizes ', + 'itself; `placement` offered a topbar that places nothing. `includeAll` is the one worth ', + 'reading twice — it was not unread but deliberately DISOBEYED, because context selectors ', + 'are mandatory-scope and an "All" row would clear the scope: on Studio\'s package selector ', + 'that means listing the platform\'s own system/cloud kernel packages to a developer who ', + 'scoped to their package. `STUDIO_APP` authored `includeAll: true` against a renderer that ', + 'ignored it. The mapping prescription for `batchSize` deliberately offers no rename: ', + 'bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor `batchSize` are all ', + 'live, but each is a different key sizing its own path — the same trap `datasource.', + 'retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier.\n\n', + 'The sharpest removal in this step is two keys wide: `app.areas[].visible` and ', + '`app.areas[].requiredPermissions`. Read the class before the count — these were ', + 'not inert authoring keys but FAIL-OPEN access gates. At the time of the retirement the ', + 'server-side authority (`filterAppForUser`) checked the app\'s `requiredPermissions` and ', + 'then walked ONLY the top-level `navigation` tree; it never read `item.areas` at all, and ', + 'the client rendered every area in the switcher. So an author writing ', + '`requiredPermissions: [\'sales.admin\']` on an area got a clean parse, a stored value, and ', + 'an area visible to everybody — and had every reason to believe otherwise, because the SAME ', + 'key names are genuinely enforced one ', + 'level up and one level down: app-level `requiredPermissions` drops the whole app ', + 'server-side, and a navigation ITEM\'s `requiredPermissions` / `requiresService` are ', + 'stripped server-side and re-checked in the shell, whose item-level `visible` is a real ', + 'CEL gate. Three layers, of which the middle one was theatre. Enforcing instead was ', + 'weighed and deliberately not taken here: it needs semantics decided first (does ', + 'filtering an area remove its items everywhere? does the server bind `user` for area ', + 'CEL?), and a retirement must not invent an authorization mechanism — while shipping a ', + 'major with the gate still declared would have kept authors writing it for all of 17.x. ', + 'The rewrite is lossless in outcome (the keys changed nothing), so what an upgrading ', + 'author has to re-decide is only where the gate really goes: onto the items inside the ', + 'area, or onto the app — and BOTH of those destinations are server-enforced. The caveat ', + 'this prescription used to carry, that per-item gating INSIDE an area was enforced by the ', + 'shell only because the server did not walk `areas`, was CLOSED by a server-side fix inside this same ', + '17.0.0 window: `filterAppForUser` now runs the SAME `filterNav` over every ', + '`areas[].navigation`, so an ITEM\'s `requiredPermissions` / `requiresService` is stripped ', + 'server-side in BOTH trees and a gated entry never ships in the `/meta` body at all. Read ', + 'that as the boundary closing, NOT as the area-LEVEL keys coming back: those stay retired ', + 'and that fix gave an area no gate of its own — what it enforces are the items inside one. ', + 'The other half of the asymmetry is unchanged and is why `requiredPermissions` is the key ', + 'to reach for: `visible` (CEL) and `requiresObject` are still evaluated client-side ONLY ', + 'at every level, because server-side CEL needs a bound `user` context the read layer does ', + 'not have — so anything that must never reach the browser goes in `requiredPermissions`, ', + 'never in `visible`.\n\n', + 'The same window converges the retry policy. `@objectstack/spec/automation` and ', + '`@objectstack/spec/system` each exported a `RetryPolicy`/`RetryPolicySchema` resolving ', + 'to a DIFFERENT declaration, so which shape a consumer got depended only on the import ', + 'path — yet both computed `delay = base * multiplier^(retry-1)` and both ', + 'executors implemented that same formula. One declaration now serves both entries with ', + 'the union of their capabilities, so `job.retryPolicy` gains the `maxRetryDelayMs` ceiling ', + 'and `jitter` (both enforced in `runWithPolicy`, not merely declared — jitter is what stops ', + 'a fleet of jobs that failed on one outage from retrying in lockstep). The single ', + 'authorable casualty is the automation spelling of the base delay: `retryDelayMs` → ', + '`backoffMs`, a pure rename that replays losslessly and is what the already-enforced ', + 'retry policies (`job.retryPolicy`, `hook.retryPolicy`) call it.\n\n', + 'The subtle half is the defaults, and it is worth stating because no gate can see it: ', + '`job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` while the automation ', + 'shape defaulted 0 / 1, and the authorable-surface gate compares KEY SETS — a changed ', + 'default is invisible to it, to the tombstone mechanism and to `spec_changes` alike. The ', + 'merged declaration takes 0 / 1 (retry replays side effects, so it is opt-in — the same ', + 'reading already recorded in `flow-retry-max-retries-required`), and the conversion writes ', + 'the pre-17 numbers into every existing `job.retryPolicy` that omitted them. Deployed ', + 'stacks therefore keep their exact behaviour; what changes is only what a NEWLY authored ', + 'omission means.\n\n', + 'That convergence then had to be finished twice more, and WHY it was incomplete is the ', + 'part worth carrying forward. It was driven by the dual-source instrument, ', + 'which asks "how many declarations publish the same exported NAME?" — so it could not see ', + 'the two encodings of the identical policy that have no exported name at all, being ', + 'anonymous inline blocks nested in a bigger schema: `flow.errorHandling` and ', + '`ETLPipeline.retry`. The instrument was not broken and answered its own question exactly; ', + 'that question was simply not "how many shapes does this ONE concept have?", which is what ', + 'everybody read off it. The cost of the gap is concrete and falls on the author who did the ', + 'right thing: `shared/retry-policy.zod.ts` tombstoned `retryDelayMs` and told them to write ', + '`backoffMs`, and `flow.errorHandling` then rejected `backoffMs` and demanded `retryDelayMs` ', + '— reading the newer file was punished. Both blocks now build from one shared shape. ', + '`flow.errorHandling` costs nothing beyond the same `retryDelayMs` → `backoffMs` rename ', + '(every other key, bound and default already matched, which is exactly why it looked ', + 'reviewed), and the conversion covers it. `ETLPipeline.retry` costs a rename of the COUNT ', + '— `maxAttempts` → `maxRetries`, same number, do NOT subtract one: that adjustment belongs ', + "to `integration/connector.zod.ts`'s identically-spelled `RetryConfig.maxAttempts`, which ", + 'INCLUDES the first attempt — plus the same default flip (3 → 0) and three keys it never ', + 'had (`backoffMultiplier` / `maxRetryDelayMs` / `jitter`, so a nightly warehouse pipeline ', + 'can stop retrying flat, uncapped and unjittered every 60s). The ETL half gets a tombstone ', + 'and no conversion step, deliberately: an ETL pipeline is not a `defineStack` collection ', + 'and `etl.zod.ts` has no parse site in any of the three repos, so there is no stored ', + 'document to walk and a step for it would advertise coverage it does not have. Nothing ', + 'deployed moves; the migration surface is empty and this is the cheapest this convergence ', + 'will ever be.\n\n', + 'The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` drops ', + '`data.field.changed`. It had no producer anywhere — the engine emits ', + '`data.record.{created,updated,deleted}` and, since predicate writes got their own bulk event, `data.records.{updated,', + 'deleted}` — so a subscriber switching on it held a branch that could never run, and ', + 'the `switch` still compiled, which is why an empty member could sit in a public enum ', + 'this long. It could not have been implemented against this contract as written: ', + '`DataEventSchema` is record-shaped and has no `field` / `oldValue` / `newValue` slot, ', + 'so the member advertised a granularity the payload has no room for. Nothing is lost — ', + 'per-field detail already rides on `data.record.updated` as `changes` (with `before` / ', + '`after`), one event per write instead of N on a wide table. Like the driver contract ', + 'above it is a runtime surface, never stored in stack metadata, so it is one semantic ', + 'TODO for event consumers rather than a source rewrite, and it carries no tombstone: a ', + 'removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` ', + 'retirement noted. Should a real per-field stream ever be wanted, it earns its own ', + 'contract, as the bulk `data.records.*` events did, rather than reclaiming this slot.\n\n', + 'The object capability block closes out the same ADR-0049 pass: `enable.trash` and ', + '`enable.mru` left the schema in the 16.x line (the close-out of the 11.0 dead-property removals — every ', + 'delete has always been a hard delete and MRU tracking was never implemented, so both ', + 'default-true flags gated nothing), and the `.strict()` capabilities block rejects them ', + 'with the prescription. This step registers the migration surface that removal was ', + 'missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, ', + 'and `os migrate meta --from 16` lists the mechanical edits for authored sources. Soft delete stays parked at ', + 'the proposal stage; if built it returns as a live enforced flag rather than by reviving these keys.\n\n', + 'The same enforce-or-remove pass retires the `RestServerConfig.openApi31` block: ', + '`OpenApi31ExtensionsSchema` (`webhooks` / `callbacks` / `jsonSchemaDialect` / ', + '`pathItemReferences`) with `OpenApiWebhookEventSchema` and `CallbackSchema` under it. ', + "Declared-but-unenforced end to end: the REST server's `normalizeConfig` forwards only ", + '`api`/`crud`/`metadata`/`batch`/`routes`, the served /openapi.json is the pre-generated ', + 'contract enriched with the live server URL and registered objects, and `gen:openapi` ', + 'never read a webhook or callback — so a definition authored under `openApi31.webhooks` ', + 'never appeared in any served document, and zero import-level consumers existed across ', + 'objectstack / cloud / objectui. `RestServerConfig` is plugin TS configuration (the REST ', + 'plugin constructor / `plugin-hono-server` `restConfig`), never a stored metadata shape: ', + "the stack tree's own `api` block declares only its four scoping/auth knobs, so no ", + '`sys_metadata` row can carry `openApi31` and there is no source for the chain to ', + 'rewrite — one semantic TODO for config authors rather than a stack conversion, the ', + '`validateOnly` shape. The key itself is tombstoned (the schema is not `.strict()`; a ', + 'plain delete would strip it silently), and a config-driven webhooks/callbacks synthesis, ', + 'if ever wanted, returns via the enforce route of ADR-0049 through a new ADR.\n\n', + 'The same pass closes `activationEvents`: both keys that carried it — ', + '`DynamicLoadRequest.activationEvents` on the kernel side and ', + '`StudioPluginManifest.activationEvents` on the studio side — declared lazy plugin ', + 'activation ("plugins remain dormant until an activation event fires") that no runtime ', + 'in any repo ever implemented: every plugin has always activated immediately on ', + "load/registration, and cloud-v1's own ROADMAP recorded the capability as ", + 'unimplemented, planned for v0.4.0. A dual-source cleanup had just converged the two ', + '`ActivationEventSchema` declarations onto one structured `{ type, pattern }` ', + "vocabulary in this same unreleased major; with the maintainer's enforce-or-remove ", + 'ruling landing on REMOVE, that converged vocabulary retires before ever shipping — ', + 'composed across the two changes, a v16 author simply deletes the key in whichever ', + 'form they carried. Neither parent is stored metadata (`StudioPluginManifest` is TS ', + 'configuration parsed by `defineStudioPlugin`; `DynamicLoadRequest` is a runtime ', + 'request shape with no caller in any repo), so there is no source for the chain to ', + 'rewrite — one semantic TODO, the `validateOnly` shape. The kernel key is tombstoned ', + '(its schema is not `.strict()`; a plain delete would strip it silently), the studio ', + 'key is rejected by the strict manifest parse with its own guidance prescription, and ', + 'the orphaned `ActivationEventSchema` def is removed with them. Behaviour is ', + 'byte-identical: eager activation was always the only behaviour.\n\n', + 'That kernel-side tombstone was then SUPERSEDED inside the same unreleased major by ', + 'a removal that finished the enforce-or-remove pass one level up: the entire ', + '`plugin-runtime.zod` family — `DynamicLoadRequest`, `DynamicUnloadRequest`, ', + '`DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation` — is removed, because ', + 'the "Dynamic Loading" capability it described (runtime load / unload / reload without ', + 'a kernel restart, with sandboxing, integrity hashes, drain strategies and ', + 'dependent-cascade policy) has no server anywhere: no runtime in objectstack, cloud or ', + 'objectui ever received one of these requests or produced one of these results. An earlier removal from that module ', + 'had suspended the call on these five deliberately — "operation contracts, not security ', + 'promises" — in a changeset paragraph no issue carried; this removal is that decision, ', + 'answered REMOVE. So a v16 author who wrote `activationEvents` inside a ', + '`DynamicLoadRequest` value does not delete a key: the whole value has no shape and no ', + 'recipient, and importing `DynamicLoadRequestSchema` at all is TS2305 in v17. The ', + 'studio half of the `activationEvents` retirement is untouched and still rejects the ', + 'key with its own prescription — `defineStudioPlugin` remains a live authoring surface. ', + 'Behaviour is again byte-identical: nothing ever executed a dynamic plugin operation.\n\n', + "Finally it removes the script-body capability token 'crypto.hash'. Four layers ", + 'declared it — the `HookBodyCapability` enum, the doc table beside it, the CLI extractor ', + 'and `ScriptContext.crypto.hash` — and none implemented it: `installCtx` wired only ', + '`randomUUID`, so the one call the token authorised threw inside the VM every time. The ', + 'build-time inference made it worse than an ordinary declared-but-unenforced key: writing ', + '`ctx.crypto.hash(...)` made the CLI ADD the capability for you, so `os build` went green ', + 'on the body that was guaranteed to fail at the first record write. Removed rather than ', + 'implemented (ADR-0049) — hashing inside the sandbox widens its capability and ', + 'security-review surface, and a capability that throws on every use yet drew zero ', + 'complaints in its whole life is its own liveness verdict. This is an enum VALUE, not a ', + 'key, so there is no `retiredKey()` tombstone: the enum error map carries the ', + 'prescription, keyed on the received value so that only the spelling which used to be ', + 'legal is told it "was removed". The conversion strips the dead token from ', + '`body.capabilities` on hooks and actions; it deliberately does NOT touch the ', + '`ctx.crypto.hash(...)` call the body made under it, which never returned a value and ', + 'which the author must delete. Hashing returns only WITH an implementation, through the ', + 'capability admission process.\n\n', + "It also removes `connector.rateLimitConfig` and its whole shape. This one is not ", + '"declared but unread" — it is declared but UNIMPLEMENTED, one step worse. The only token ', + "bucket the platform owns (runtime `security/rate-limit.ts`) is INBOUND: the dispatcher ", + 'calls `consume(key)` on a request fingerprint and answers 429. Nothing anywhere throttles ', + 'the calls a connector makes OUT, and no provider — `connector-rest`, `connector-openapi`, ', + '`connector-mcp`, `connector-slack` — reads the key or has a seam that could. So ', + '`strategy`, `maxRequests`, `windowSeconds`, `burstCapacity`, `respectUpstreamLimits` and ', + '`rateLimitHeaders` parsed cleanly and capped nothing, on a surface where the author ', + "believed they had bounded their spend against a third party's quota. `ConnectorRateLimit", + 'Config` and the `RateLimitStrategy` enum it embedded had no other consumer and are removed ', + 'with the key, so importing either is TS2305 in v17 — the `plugin-runtime.zod` shape, and the same ', + 'implementation-first ruling: the vocabulary comes back WITH the engine, in one change. ', + 'It is deliberately NOT converted to `shared` `RateLimitConfig`, which limits the calls ', + 'others make to US; the dual-source cleanup split their names for precisely this confusion, and rewriting an ', + 'outbound cap into an inbound one would throttle the wrong direction. Delete the key and ', + 'rate-limit where the calls are actually made — the connector provider or upstream gateway.\n\n', + 'Last, it removes `dashboard.widgets[].responsive` — the straggler of the close-out ', + 'sweep above, which retired the literally same-named `view.responsive` on the same ', + 'evidence four days earlier. Re-measured before removal: no objectui code reads ', + '`widget.responsive` (DashboardRenderer, DashboardEditor and plugin-designer name it only ', + 'in comments), and there are zero authored instances repo-wide, so the conversion is ', + 'expected to be a no-op on every real source — it exists so that a stored dashboard ', + 'carrying the key is cleaned deterministically rather than meeting the tombstone at load. ', + 'What kept it alive was not evidence but a hole in the instrument: the liveness ledger ', + 'declares no `children` on `dashboard.widgets`, and the walk only drills one level through ', + 'an explicit `children`, so no widget-level key has ever been classified at all (filed as ', + 'its own finding, fixed separately). The removal was deliberately narrow — it took the widget EMBED, ', + 'not the shape, which at the time was believed live on `page.components[].responsive` via ', + 'objectui `useResponsiveConfig`. CORRECTED in 2026-08: that belief measured false ', + '— the hook had zero callers, nothing read the page key either — so protocol 18 retires ', + '`page.components[].responsive` and the `ResponsiveConfig` shape with it (see the ', + '`page-component-responsive-removed` step-18 entry). Authors who need breakpoint behaviour ', + 'use `responsiveStyles` (ADR-0065), the channel objectui really compiles. Per-widget ', + 'responsive layout returns if and when a renderer implements it.\n\n', + 'Finally it CONVERGES `dashboard.widgets[].compareTo` — the one entry in this step ', + 'that is not a removal but a vocabulary merge, and the one whose defect was worst-shaped. ', + 'The widget declared three arms with confident TSDoc; the analytics executor implements one ', + 'contract, `DatasetSelection.compareTo` = `{ kind, dimension? }`, which has no `offset` in ', + 'it. On the ADR-0021 dataset path the two string arms were DROPPED by the renderer (a ', + 'comparison silently absent from a widget whose author asked for one) and `{ offset }` was ', + 'forwarded into that contract with no dimension, so the executor threw ', + '`compareTo requires a timeDimension "undefined"` and errored the whole widget. All three ', + 'arms worked on the legacy inline chart path. Same key, two fates — and the failing one was ', + 'the path the spec itself calls canonical, which is why this ranks above an ordinary ', + 'declared-but-unread key: the documentation was actively teaching a shape that crashes. ', + 'The widget now declares the executor\'s own words, so `declared = enforced` holds by ', + 'construction with no second vocabulary left to drift. `dimension` is optional and resolved ', + 'by the EXECUTOR (one dated time dimension → that one; zero or several → a loud error ', + 'naming the candidates), which is a producer-side resolution rule, not the consumer-side ', + 'tolerance PD #12 forbids. The bare strings and `{ offset: \'1y\' }` replay mechanically; ', + 'every other `{ offset }` duration is a semantic TODO below, because `previousPeriod` ', + 'shifts by the resolved window\'s own length and rewriting `7d` into it would change which ', + 'rows the comparison counts. The converged slot is also union-free, which is not cosmetic: ', + 'zod collapses a failed union into one bare `Invalid input`, and a top-level-only error mapper showed that curated ', + 'guidance inside a union arm never reaches the author at all.\n\n', + 'The same widget drill retires four more keys: the action trio ', + '`actionUrl`/`actionType`/`actionIcon`, and `aria`. The trio described a per-widget action ', + 'BUTTON that no renderer in either repo has ever drawn — all 14 `actionUrl` reads in ', + 'DashboardRenderer are scoped to `header.actions[]`, a different schema — and `actionIcon` ', + 'had zero references anywhere outside its own declaration. `aria` is the dashboard-level ', + '`aria` removed by the close-out sweep, one level down: declared ARIA attributes that never ', + 'reached the DOM, i.e. an accessibility guarantee an author could state and nothing ', + 'honoured. It survived that sweep for the same reason `responsive` did — `widgets` had no ', + 'ledger drill until the instrument hole above was fixed — not on evidence. This removal also settles a second-order cost ', + 'the trio was carrying: `packages/lint`\'s dashboard action-ref rule enforced ', + 'ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key ', + '"the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so ', + 'an author could FAIL A BUILD because a control that cannot render pointed at an action ', + 'that also did not. That widget branch is deleted with the keys. Lossless deletes in every ', + 'case — the keys contributed nothing to any rendered output — and the shared `AriaProps` ', + 'shape is untouched, staying live on `page.aria` / `page.components[].aria` and the list ', + 'view `aria` — not on `app.aria`, which this same major retires (see ', + '`app-dead-authoring-keys-removed`, which strips it). Move a ', + 'dashboard-wide affordance to `header.actions[]` (where `icon` is the header spelling of ', + '`actionIcon`); for per-row click-through use a dataset-bound `table`/`pivot`, whose rows ', + 'drill through the semantic layer already.\n\n', + '⚠️ One protocol-17 change turns metadata ON rather than off, and it is the one to read ', + 'first: declarative `apis:` endpoints EXECUTE from 17. The surface used to be inert ', + 'end to end — no route mounted, no matcher, every key including `authRequired` parsed and ', + 'enforced nothing — which is why publish at first refused a non-empty `apis:` outright. 17 ships the ', + 'executor and narrows that refusal to a per-endpoint publish gate, so an endpoint that ', + 'passes the gate is MOUNTED and serves traffic the moment it is published. Any historical ', + '`apis:` block therefore changes meaning without changing a byte. Review every entry before ', + 'upgrading, and pay particular attention to an explicit `authRequired: false`: the schema ', + 'default is `true`, so an omission is safe, and only that explicit `false` opens anonymous ', + 'access — which ADR-0121 D6 now pairs with a mandatory armed `rateLimit` ', + '(`enabled: true`; the key defaults to `false`, so a budget written without it meters ', + 'nothing). Paths also move under the namespace carve-out `/api/v1/apps//', + '` (ADR-0121 D1/D2). The full checklist is the `declarative-apis-endpoints-live` ', + 'semantic entry below; it is a security review, not a rename, so nothing about it is ', + 'applied for you.\n\n', + 'Finally, the theme token scales retire (ADR-0049): `typography.fontSize`, ', + '`typography.fontWeight`, `typography.lineHeight`, `typography.letterSpacing`, ', + '`typography.fontFamily.heading`, `typography.fontFamily.mono`, `animation` and `zIndex`. ', + 'These are the reverse of the usual inert key and the distinction is the point: the theme ', + 'engine DID emit them — `--font-size-*`, `--font-weight-*`, `--line-height-*`, ', + '`--letter-spacing-*`, `--duration-*`, `--timing-*`, `--z-*`, `--font-heading`, ', + '`--font-mono` all reached the document exactly as authored — and no first-party component ', + 'or stylesheet has ever read one, so a declared type scale was real CSS that styled ', + 'nothing. That is why the earlier theme sweep left them standing: its criterion was ', + '"never emitted", and these are emitted. `colors`, `borderRadius`, `shadows` and ', + '`typography.fontFamily.base` have live consumers and are untouched. The prescription is ', + '`customVars`, which emits `--: ` verbatim — so a tenant stylesheet that really ', + 'was reading `--z-modal` reproduces it byte for byte and loses no capability. The ', + 'conversion DELETES the keys and emits a notice per key rather than auto-populating ', + '`customVars`: a rewrite would hand back two dozen variables that still nothing reads, ', + 'turning a dead semantic slot into a dead literal one. Deciding which of them you actually ', + 'consume is yours to make; the notice names each one. Retired from the load path with the ', + 'other keys that misdescribed themselves.\n\n', + 'It closes the enforce-or-remove line with two `./ui` vocabulary shapes that never ', + 'had a key to be written into: `NotificationActionSchema` / `NotificationAction` ', + 'and `EmbedConfigSchema` / `EmbedConfig`. This is the class BELOW a declared-but-unread ', + 'key — there was no key at all. No schema anywhere declared a carrier, a BFS from all 24 ', + 'metadata-type roots plus `ObjectStackSchema` reached neither (with `Page` / `Action` / ', + '`DashboardWidget` / `Webhook` / `SharingConfig` as positive controls in the same run, and ', + 'a synthetic carrier flipping both), and no repo parsed either outside its own unit test. ', + 'Each was left behind by an earlier retirement one level up: the notification action by ', + 'the dual-source cleanup that deleted the two wrapper shapes that could have carried it, and the embed ', + 'config by 17.0.0\'s own `App.embed` tombstone. The strictness wave\'s 批 14 measured both and deliberately ', + 'declined to close them with `.strict()`, because strictness on a shape nothing parses ', + 'enforces nothing and only makes a dead slot look load-bearing; this removal is that ', + 'deferred call, answered REMOVE. Nothing is applied for you and nothing needs to be — ', + 'there is no key in any source to rewrite; the change is visible only as TS2305 on an ', + 'import. ⚠️ Read the scope precisely, because one of the two modules SPLITS: ', + '`ui/sharing.zod` keeps `SharingConfigSchema` and it stays LIVE — `FormView.sharing` ', + 'carries it and `rest-server.ts` mounts the anonymous form routes on ', + '`allowAnonymous` + `publicLink`, so public form sharing is untouched — and ', + '`ui/notification.zod` keeps `NotificationType` / `NotificationSeverity` / ', + '`NotificationPosition`. Only the two named shapes go.\n\n', + 'The same is true of the protocol-17 retirement that closes this list, and the pair is ', + 'worth reading together (ADR-0049): the five `@objectstack/spec/ui` interaction-config ', + 'modules — `touch.zod.ts`, `dnd.zod.ts`, `keyboard.zod.ts`, `animation.zod.ts` and ', + '`offline.zod.ts`, 22 `z.object` sites and 64 exported names — are deleted whole, with ', + 'their reference docs. They were never reachable: no schema in the protocol declared a ', + '`touch:` / `dnd:` / `keyboard:` / `animation:` / `offline:` key, so no metadata document ', + 'could carry one and none needs rewriting now. The defect was on the DOCUMENTATION side, ', + 'which is the half that made it urgent — `authorable-surface.json` carried 109 keys under ', + 'these defs and the generated `references/ui/*` pages rendered them as authoring tables, ', + 'so an AI author reading `dnd.mdx` wrote a `dnd:` block that `PageComponentSchema` then ', + 'rejected as an unknown key. That is a published capability the runtime does not deliver ', + '(Prime Directive #10), not a strictness gap: closing the shapes would have validated a ', + 'slot nobody can reach. Business reading behind the ruling: these five are RENDERER ', + 'BUILT-IN behaviour, decided by the component library rather than authored per page; ', + 'offline is a platform capability whose vocabulary belongs on a sync engine that has not ', + 'been built. ⚠️ The `animation` here is `ui/animation.zod.ts` ', + '(`ComponentAnimation` / `MotionConfig` / `PageTransition` / `AnimationTrigger`), a ', + 'DIFFERENT surface from the theme `animation` block retired above with the token scales — that one had ', + 'a carrier key and got a tombstone; this one had none and gets deletion. The one name ', + 'worth checking on upgrade is the bare `ConflictResolution` type: it left with ', + '`ui/offline.zod.ts` and is now published by nobody. `ConnectorConflictResolution` ', + '(`@objectstack/spec/integration`, connector sync) and `ConflictResolutionStrategy` ', + '(`@objectstack/spec/api`, route merge policy) are different concepts under their own ', + 'names and are untouched.\n\n', + 'The last enforce-or-remove entry of this step is on the RUNTIME context rather than on ', + 'anything authorable: `HookContext.session.roles`. It was declared in ', + '`data/hook.zod.ts`, read by exactly two consumers — the approvals record lock and the ', + 'delegation write guard, each opening with `session.roles?.includes(\'admin\')` — and ', + 'produced by nobody on the hook path: ObjectQL\'s `buildSession()` writes the session ', + 'field by field (`userId`, `organizationId`, `accessToken`, `isSystem`, `actor`, the skip ', + 'flags) and has no `roles` write, here or in `cloud`, whose hook consumers read ', + '`hookContext?.session?.userId` and nothing else (an ACTION body\'s `ctx.session` is a ', + 'different untyped object that does carry one, tracked apart). So both branches were dead ', + 'on every real ', + 'engine path: an authorization decision in shape only, and — worse for a reader — a SECOND ', + 'admin dialect competing with the one ADR-0095 D3 sanctions. The approvals plugin deleted the ', + 'two readers on the maintainer\'s ruling; this step removes the declaration that outlived ', + 'them, which is what ADR-0049 asks for once a key has neither end. Nothing observable ', + 'changes: a key nobody wrote and nothing read cannot alter a single decision. It is ', + 'tombstoned rather than deleted because `HookContextSchema` is deliberately NOT `.strict()` ', + '(strictness there would make an engine-internal enrichment a breaking change for anyone ', + 'parsing a context they were handed, as `provenance` was for schedule-triggered runs), so a plain delete would ', + 'strip the key in silence — the ADR-0104 failure this whole pass exists to end. ', + 'There is NO conversion and no source rewrite: a HookContext is built per operation by the ', + 'engine and never stored, so no `sys_metadata` row, example or template can carry the key ', + '— the `openApi31` / `activationEvents` shape, one semantic TODO for hook authors. The ', + 'live vocabulary is untouched and deliberately elsewhere: gate on `session.userId` / ', + '`session.isSystem` in the hook, and judge PRIVILEGE through the security service, which ', + 'reads capability grants (`permissions`), placements (`positions`) and the derived posture ', + 'off the execution context.\n\n', + 'The same enforce-or-remove reading reaches the storage contract: ', + '`IStorageService.list(prefix)` is removed. It had no ', + 'consumer — the only in-repo call site was a proxy pass-through — and the two shipped ', + 'adapters answered it with two different, silently incomplete semantics: the local ', + 'adapter listed a single level and reported directories as files, the S3 adapter ', + 'recursed and stopped at 1000 objects without reading `IsTruncated` / ', + '`ContinuationToken`. Enumerating a prefix without a cursor is the wrong signature to ', + 'inherit, so nothing replaces it in place: query the file records you wrote, and let a ', + 'real caller bring back a cursor-shaped `list(prefix, { cursor, limit })` with ', + 'adapter-conformance cases behind it. Same shape and same disposition as the ', + '`findStream` retirement above — a TS/API contract, no stored source, no tombstone, ', + 'tsc at the call site.\n\n', + 'Finally it retires the two inert `IndexSchema` keys, `indexes[].type` and ', + '`indexes[].partial`. Neither ever had a DDL consumer: ', + '`SqlDriver.syncDeclaredIndexes` creates declared indexes through knex\'s `table.index()` / ', + '`table.unique()`, and the drift differ\'s `DeclaredIndexInput` carries only ', + '`name`/`fields`/`unique`/`nullSafeColumns` — so an authored `type` selected no access ', + 'method and an authored `partial` produced a FULL index with its predicate discarded. ', + '`partial` was the more damaging of the two because it read as a correctness control: the ', + 'platform\'s own `sys_metadata` declared it for overlay uniqueness, and what the ', + 'declaration alone materialized was an unrestricted unique index (the active-row scoping ', + 'is delivered by a runtime migration, `metadata-protocol`\'s `ensureOverlayIndex`, not by ', + 'the key). `type` was the louder: its `.default(\'btree\')` put an inert knob into every ', + 'parse output, so it read as live configuration — the ADR-0078 no-silently-inert shape. ', + 'Remove was chosen over enforce (maintainer ruling, 2026-08-06): enforcing needs ', + 'per-dialect algorithm mapping (`gin`/`gist` Postgres-only, `fulltext` MySQL-family), ', + 'raw-SQL `CREATE INDEX … WHERE` on the dialects that have partial indexes at all (MySQL ', + 'does not), and a redesign of how `isSyncReproducibleIndex` excludes partial indexes from ', + 'incremental sync — design cost for a capability nothing has asked for. Both are lossless ', + 'deletes: no DDL changes, because no DDL ever depended on them. Drift detection is ', + 'untouched — the `partial` flag it consumes is parsed back out of the database\'s OWN ', + '`CREATE INDEX` DDL and never came from this key.\n\n', + 'It also retires the field-mapping `transform` key and the whole five-member ', + '`FieldMappingTransform` union behind it: `constant` / `cast` / `lookup` / ', + '`javascript` / `map`, declared on `shared/FieldMapping` and inherited by ', + '`integration/ConnectorFieldMapping` and `data/ExternalFieldMapping`. Nothing ever ', + 'executed one. `fieldMappings` is spelled only inside `packages/spec` itself — the ', + 'connector packages, the automation engine, REST and objectui never read it, and no ', + 'code anywhere switches on `transform.type` — so all five members were ', + 'declared-but-unenforced together, not just the one that got the bug filed. That one ', + 'is the sharpest evidence though: `javascript`\'s `.describe()` recommended the ', + 'dialect `js`, which `ExpressionDialect` had already retired (ADR-0058 addendum), so the ', + 'envelope the documentation taught was rejected by the enum; the only spelling that ', + 'parsed was the bare string, which `ExpressionInputSchema` wraps as `cel`; and the CEL ', + 'that resulted could not evaluate the `value.toUpperCase()` the same line offered as ', + 'its example. Three surfaces disagreeing about a capability with no implementation ', + 'under any of them. Fixing the sentence alone was rejected (maintainer, 2026-08-06) as ', + 'gilding a member that cannot run. The key is tombstoned rather than deleted because ', + 'the schema and both extenders are plain `z.object`s and `ConnectorSchema.parse` is a ', + 'live receiver, so a bare deletion would strip silently. What is NOT affected, despite ', + 'the shared word: the import mapping\'s `mapping.fieldMapping[].transform`, a flat ', + 'string enum applied row by row by the REST import path and live in the liveness ', + 'ledger — including its own `javascript` value, which that path rejects with a 400 ', + 'rather than pretending to run.\n\n', + 'The last of the strictness wave\'s enforce-or-remove batches lands on two more `ui/` files (', + 'ADR-0049 — read it next to the interaction-config modules above, it is the same shape one batch later). ', + '`ui/widget.zod.ts` published a whole widget-REGISTRATION vocabulary — `WidgetManifest` ', + 'with `WidgetLifecycle` hooks, `WidgetEvent`s, `WidgetProperty` knobs and a ', + '`WidgetSource` npm/remote/inline implementation union — and `ui/i18n.zod.ts` published ', + '`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat` and `LocaleConfig`. Ten defs, ', + 'twenty exported names, and not one carrier key between them: nothing under ', + '`packages/spec/src` imported `widget.zod` at all, the only live imports of `i18n.zod` ', + 'name `I18nLabelSchema` / `AriaPropsSchema`, the BFS from all 24 metadata-type roots ', + 'plus `defineStack` reached none of them, and no repo ever parsed one. So again nothing ', + 'is applied for you and nothing needs to be — the change is TS2305 on an import, and a ', + '`field.widget: "my_picker"` string is untouched, because that key names a component the ', + 'RENDERER registered and never referenced `WidgetManifest`. ⚠️ Read this scope precisely ', + 'too, because BOTH files split. `ui/i18n.zod.ts` keeps `I18nLabelSchema` (the label ', + 'primitive the whole `ui/` tree imports) and `AriaPropsSchema` — a REAL door, carried as ', + '`aria:` on ~30 live shapes and closed by 批 16, untouched here. And `ui/widget.zod.ts` ', + 'keeps `FieldWidgetPropsSchema`, the one site of the nine whose evidence differs: it is ', + 'a React props contract rather than authorable metadata (it never appeared in the ', + 'authorable surface or the schema manifest — `onChange` is a `z.function()`), so having ', + 'no parse is its design; and an objectui change (2026-08-03) made it a live compile-time ', + 'consumer, renaming `@object-ui/fields`\' validation slot onto this contract\'s `error` ', + 'with no alias and pinning it as a deliberate tripwire. Retiring it would have broken ', + 'the one consumer the batch had, one day after it appeared. The measurement that decides ', + 'a site is the CURRENT one, not the one in the issue body.\n\n', + 'Last, it reconciles the SDUI component-props surface with the renderers that serve it ', + '— wiring the first parse gate `ComponentPropsMap` ever had found that the corpus it ', + 'landed on diverged in BOTH directions: keys objectui honours that the schema never ', + 'declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. ', + 'The maintainer ruled direction A (2026-08-06), the `record:details` sections rule again: the delivered and ', + 'authorized shape is the contract. So the honoured keys are declared ', + '(`element:record_picker` `labelField`/`valueField`/`label`/`emptyText`, `record:path` ', + '`stages[].terminal`, `page:tabs` `items[].value`/`items[].count`, `page:card` `children`, ', + 'and `children` on `page:section`/`page:footer`/`page:sidebar`, which were declared ', + '`EmptyProps` while their renderers rendered a child list), and four keys retire. Two are ', + 'synonym renames: `element:record_picker.displayField` → `labelField` (the required key no ', + 'renderer read, while `labelField ?? \'name\'` is what actually renders the row — so an ', + 'author who followed the schema got a picker listing `name` with no diagnostic, the ', + 'ADR-0078 shape), and `page:card.body` → `children` (one composition key across every ', + 'container; the card renderer already reads both, and the showcase authors `children`). ', + 'Two are enforce-or-remove deletions: `element:record_picker.searchFields` and ', + '`.multiple` — the control is a shadcn single-select with no search input, binding ONE ', + 'record id into a page variable, so `searchFields` narrowed nothing and `multiple: true` ', + 'selected nothing extra while reporting success. Either returns the day the capability ', + 'is implemented. Not in scope, and deliberately: `page:card.visible` is a ', + 'component-level visibility predicate written into `properties` and hoisted by the ', + 'renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare.\n\n', + 'That count turned out to be incomplete, and a second pass finishes it: five more keys the ', + 'renderers read were still undeclared. Four are plain additions with no behaviour change ', + '(`page:header` `recordChrome`/`showStar`/`showCopyId`, which select between the ', + 'record-chip header and the bare heading a dashboard wants, and `page:accordion.variant`, ', + 'which decides whether the accordion draws its own dividers or leaves the border to each ', + 'panel). The fifth is a rename, and the only one in the family whose defect is structural ', + 'rather than an oversight: the tab strip\'s visual style was declared as ', + '`page:tabs.type`, which collides with the page component\'s OWN dispatch key. objectui\'s ', + '`SchemaRenderer` refuses to hoist `properties.type` for exactly that reason, ', + '`sdui-parser`\'s `BASE_PROPS` contains `type` and skips it before any validation runs, ', + 'and in a flat or JSX carrier the node reads `{ type: \'page:tabs\', … }` so the name is ', + 'already taken. The key was therefore unauthorable in every carrier but the nested ', + '`properties` object, and unvalidated even there. It becomes `tabStyle` — the spelling ', + 'objectui publishes and the renderer already reads first in the flat carriers — which is ', + '`displayField` → `labelField` again: converge on the spelling that works, not the one ', + 'that declares well, and keep one spelling rather than two (Prime Directive #12).\n\n', + 'A third pass closes that reconciliation from the other side, on three keys the two earlier ', + 'passes left standing (maintainer ruling 2026-08-09, decision-inbox round: retire the header icon and ', + 'card actions upstream, and the details layout). Two are the plain B class — declared here, read NOWHERE. ', + '`page:header.icon` is resolved by objectui only per header ACTION (`action.icon`); the ', + "header's own props bag is never asked for one, and `@object-ui/layout`'s `` ", + 'takes an `icon` React prop from a host with no schema fallback beside the ', + '`schema?.actions ?? schema?.properties?.actions` fallback four lines away. ', + '`page:card.actions` has no actions area to render into at all: the card renderer builds ', + 'its `` from `title`, `bordered`, `children` and `footer`, full stop. Both sat in ', + "objectui's own unpublished-exemption map as \"spec declares it, NO renderer read point\", ", + 'which is what put the contract decision — wire it, publish it with a KNOWN GAP marker, or ', + 'retire it — in front of the maintainer; the ruling retired it. Neither has a lossless ', + 'rewrite target (a header has no second icon slot, and moving a card\'s action ids into ', + '`children` as components is a page rewrite, not a mechanical one), so both are pure ', + 'strips. ⚠️ `page:header.actions` is LIVE and untouched — the strip is scoped by component ', + 'type, never by key name.\n\n', + 'The third, `record:details.layout`, is a sharper shape and the one worth reading twice: ', + 'it IS read. The renderer computes ', + "`schema.layout === 'inline' || schema.layout === 'compact' ? 'horizontal' : 'vertical'`, ", + 'while the declared enum is `auto | custom` — so neither legal value can match, both take ', + 'the same branch, and a key that was accepted and read still selected nothing, under a ', + '`.describe()` promising "auto uses object highlightFields, custom uses explicit sections". ', + 'The behaviour that prose describes is real, but the renderer keys it off whether ', + '`sections` was authored, never off this flag. Every gate stayed green because ', + '`check:react-declaration-parity` compares two DECLARATIONS and objectui declared the same ', + '`auto | custom` enum — perfect agreement over a key nothing honoured — while a THIRD ', + "spelling (`stacked | inline | compact`) sat in `@object-ui/types`' mirror. A pure strip ", + 'for the same reason: `auto`, `custom` and omission were behaviourally identical, so there ', + 'is no value to carry. ⚠️ `record:highlights.layout` is a different, live, honoured key ', + 'and is untouched. objectui\'s side of both rulings drops the exemptions, the input and the ', + 'dead branch on the next pin bump.\n\n', + 'Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave ', + '`AggregationFunction` (ADR-0049). The enum declared eight functions and the SQL ', + 'family compiles five — `SqlDriver.mapAggregateFunc` and the Turso ', + '`RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest ', + 'to one refusal — so three were declared-but-unenforced against the backends this platform ', + 'targets. What makes these two worse than an ordinary inert declaration is that another ', + 'package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and ', + '`string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they ', + "reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of ", + 'the requested value, with no error and no log. The maintainer SPLIT the three rather than ', + 'retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS ', + 'and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard ', + 'staple, already lowered by `service-analytics` — with its SQL implementation following on ', + 'its own card, so that declaration leads its implementation by decision rather than by ', + 'drift. These two take the remove leg: display conveniences with no measured pull, and ', + '`string_agg` never had one shape to lower to (the delimiter is a second argument in ', + 'PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). ', + 'This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no ', + '`retiredKey()` tombstone: the enum error map carries the prescription, keyed on the ', + 'received value so only the two spellings that used to be legal are told they "were ', + 'removed". Of the two authoring surfaces only one is stored metadata: the conversion ', + 'rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with ', + 'neither `aggregate` nor `derived` fails the dataset\'s own refinement, so stripping just ', + 'the key would emit an item that cannot parse) plus any derived measure the drop strands, ', + 'with a notice each. Nothing is lost: `compileDataset` refused both by name already, so ', + 'such a measure never produced a number. `QueryAST.aggregations[].function` is a request ', + 'surface with no stored source — one semantic TODO below. The mongodb and in-memory ', + 'backends that implemented these two were inside the maintainer\'s driver freeze when this was decided ', + '(it was lifted on 2026-08-11) and are untouched; their code is simply no longer reachable ', + 'through a spec-valid request.\n\n', + 'The same aggregation node loses one more member, and it is the sharper class of the two: ', + '`aggregations[].distinct` is removed (ADR-0049, maintainer ruling 2026-08-09). ', + 'The functions above were declared and UNLOWERED — a caller on a SQL datasource got a ', + 'refusal. This flag was declared and lowered by exactly ONE of the six faces that read an ', + 'aggregation: the engine\'s in-memory fallback deduplicated the values before applying the ', + 'function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, ', + '`driver-mongodb`\'s `buildAggregationStage`, `driver-memory`\'s `computeAggregate` and ', + 'service-analytics\' `AGGREGATE_SQL` all ignored it. So the same query answered a ', + 'deduplicated `sum` on the fallback path and an ordinary `sum` on every SQL datasource, ', + 'with the engine choosing between the two per query — by driver, by a non-UTC date bucket, ', + 'by whether the driver aggregates natively at all. That is the divergence class closed on this axis when ', + 'every SQL face got one aggregate spelling and one refusal, still open on this key, and it is worse to sit on because ', + 'the wrong answer is a PLAUSIBLE NUMBER rather than a refusal: no error, no log, nothing ', + 'for a dashboard author to notice. It survived the `QueryAST` sweep of this very schema because ', + 'that sweep asked which members no executor reads, and this one had a reader — the wrong ', + 'question for a key whose defect is WHICH executor reads it. Remove rather than enforce, ', + 'per the ruling: `count_distinct` (which just took the enforce leg above, and whose SQL ', + 'lowering has since landed) already covers the only deduplicating spelling with measured ', + 'demand, while `SUM(DISTINCT …)` / `AVG(DISTINCT …)` are near-universally a modelling ', + 'mistake and would have to be lowered across five faces, two of them then frozen under ', + 'the driver freeze (lifted 2026-08-11, after this ruling), to buy it. The blast radius inside the ', + 'fallback is narrower than the key suggests and was ', + 'measured rather than assumed: only `sum` and `avg` ever changed answer — `count` returned ', + 'from its own branch before reaching the dedupe, `count_distinct` fed a Set, and dedupe ', + 'does not move `min`/`max`. `AggregationNodeSchema` is non-strict, so the key is ', + '`retiredKey()`-tombstoned rather than bare-deleted: a plain deletion would have made zod ', + 'silently STRIP what callers still send, trading a divergent flag for an ignored one ', + '(ADR-0104). One tombstone covers every aggregation door, because ', + '`QuerySchema.aggregations` and `EngineAggregateOptionsSchema.aggregations` reuse that one ', + 'schema by reference. No conversion: a request surface with no stored source — one ', + 'semantic TODO below, the disposition every other `data.query.*` retirement in this major ', + 'already takes.\n\n', + 'One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape ', + "protocol 12 last used for `api.requireAuth`: an omitted `ActionDescriptor.resumeAuthority` ", + "resolves to `'service'` instead of `'any'`, so a pausing node type that never states who ", + 'may continue its pauses is refused on the generic resume route rather than open to it ', + '(ADR-0044\'s 2026-07-28 amendment). Nothing is removed and no metadata shape ', + 'changes — the field has been optional since step one of the same issue — so tsc reports ', + 'nothing and only the MEANING of silence moved. That is exactly why it needs a ledger ', + 'entry: a third-party plugin author has no compile error to discover it with, and the ', + 'one-line prescription (declare `resumeAuthority` on the descriptor) has to arrive before ', + 'a user meets a run that will not continue.\n\n', + 'The same descriptor loses a key in this step, and the pairing is the point (', + 'ADR-0049). `ActionDescriptor.isAsync` and `ActionDescriptor.supportsPause` were two ', + 'spellings of one capability — "this node type can suspend the run" — and enforcing pauses split ', + 'them by evidence rather than by preference: `supportsPause` took the ENFORCE leg (the ', + 'engine now refuses a suspension the descriptor never declared, at the one seam every ', + 'suspension passes through), and `isAsync` takes the REMOVE leg, because a fresh ', + 'three-repo measurement found zero readers and no consumer it could grow into. What ', + 'makes the duplicate worse than an ordinary inert key is that five shipped descriptors ', + 'WROTE it, so the platform itself modelled a declaration that decided nothing — and a ', + 'plugin author copying `screen` (which declared BOTH) had no way to tell which of the ', + 'two the runtime honoured. It is tombstoned rather than deleted, so the answer arrives ', + 'as a rejection carrying the fix; and because a descriptor lives in executor TypeScript ', + 'rather than in stored metadata, its prescription is a semantic entry below rather than ', + 'a conversion `os migrate meta` could replay.\n\n', + 'The plugin manifest loses its whole `loading` block in this step (ADR-0049, ', + 'maintainer ruling 2026-08-04) — the same enforce-or-remove question asked of a block ', + 'rather than a key, and answered REMOVE on measurement: every reference to ', + '`manifest.loading.*` in objectstack, cloud and objectui lived inside `packages/spec` ', + 'itself, so a full loading policy parsed, entered the manifest, and configured nothing. ', + 'The reason it outranked ordinary inert-key cleanup is that one of its members was ', + '`sandboxing`, declaring process / vm / iframe / web-worker isolation and a service ACL: ', + 'an inert SECURITY control is worse than an absent one, because an author (very often an ', + 'AI, ADR-0033) reads the vocabulary as proof the isolation exists and stops looking. Hot ', + 'reload was a two-source defect on top of that — the retired `PluginHotReloadSchema` was ', + 'the dead one of two vocabularies, and the ruling converges on the live one, ', + '`HotReloadConfigSchema`, which `HotReloadManager` actually reads and which is KEPT ', + 'unenforced as the starting point for a separate future decision. Like `isAsync`, its ', + 'prescription is a semantic entry rather than a conversion: a manifest is not a stack ', + 'collection, so `os migrate meta` has no seam at which to rewrite one.\n\n', + 'The action LOCATION vocabulary loses `global_nav` in this step (ADR-0049, ', + 'maintainer ruling 2026-08-09). It was declared from the day `ACTION_LOCATIONS` was ', + 'written and no product surface ever served it: the console command palette composes its ', + 'groups from nav items, objects, dashboards, pages, reports, recent items and record ', + 'search, and reads no action metadata at all — so an action declaring this location never ', + 'reached a user. What lifts it above ordinary inert-declaration cleanup is that the ', + 'authoring tool PROMISED the surface: the Studio designer previewed a mock ', + '`⌘K · Command palette` frame for exactly this value, so an author (very often an AI, ', + 'ADR-0033) declared it, watched it "render", shipped it, and got nothing — the ADR-0078 ', + 'shape arriving through a location vocabulary rather than through a missing key. It was ', + 'retired rather than implemented because the demand evidence is empty: no user has asked ', + 'for command-palette actions and the only two declarers were our own showcase corpus, so ', + 'wiring the palette would have been capability expansion with no pull. This is an enum ', + 'VALUE, not a key, so — as with `crypto.hash` and the two aggregate functions above — ', + 'there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed ', + 'on the received value so only the spelling that used to be legal is told it "was ', + 'removed". The conversion strips the value from `action.locations` and KEEPS the key even ', + 'when the array empties, because on this surface `locations: []` and an absent ', + '`locations` are different declarations: the empty array is the documented headless shape ', + '(callable over REST/MCP/AI, capability gate and audit trail intact), while an absent key ', + "means nobody placed the action — which is what `packages/lint`'s `action-no-placement` ", + 'warns about. An object-less action, whose only reason for declaring `global_nav` was that ', + 'it has no row and no record header to render on, is therefore migrated to the ', + 'declaration it always meant.\n\n', + 'It also removes the three pass-through-only list-view display keys `striped` / ', + '`bordered` / `virtualScroll` (ADR-0049 enforce-or-remove, maintainer ruling ', + '2026-08-10). All three were graded live on reads that turned out to be forwarding ', + 'copies: the react spec-bridge, plugin-list and plugin-view/app-shell each copy the ', + 'key onto the next node, and the chain ends at ObjectGrid, which never spells any of ', + 'the three — so an author who wrote `striped: true` got a parse-clean no-op, the exact ', + 'silent-no-op shape enforce-or-remove exists to end. Copy-without-apply is dead in ', + 'effect; per the ruling, if objectui wants one of these as real behavior, that is an ', + 'implementation card filed first, and the key stays retired pending it.\n\n', + "Finally it removes the 'pdf' member of `view.exportOptions` formats (maintainer ", + 'ruling 2026-08-12). PDF export was declined platform-side as not planned, so the ', + "member was declared-but-unrenderable: ObjectGrid dropped the format from the export menu ", + "with only a runtime console.warn, so `exportOptions: ['xlsx', 'pdf']` type-checked, ", + 'validated, and silently rendered a menu without PDF. The same ruling adopted the OBJECT ', + 'form for `exportOptions` — `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, ', + 'streaming? }`, exactly the key set the renderer reads, ending the state where no ', + 'declaration was both type-legal and functional — with the legacy bare array still ', + 'accepted and lifted to `{ formats: [...] }` at parse, which is why the conversion strips ', + "only 'pdf' and does not rewrite the array spelling. This is an enum VALUE, not a key, so ", + '— as with `crypto.hash` above — there is no `retiredKey()` tombstone: the format enum\'s ', + 'error map carries the prescription, keyed on the received value so only the spelling ', + 'that used to be legal is told it "was removed", plus a union-level dispatch so the ', + 'refusal is the top-level message in either authored form. The strip keeps an emptied ', + '`formats` array rather than deleting the declaration.', + ].join(''), + conversionIds: [ + 'action-execute-to-target', + 'field-conditionalRequired-to-requiredWhen', + 'agent-tools-to-skills', + 'sharing-rule-access-level-full-to-edit', + 'flow-node-crud-object-alias', + 'flow-node-notify-config-aliases', + 'flow-node-wait-event-config-lift', + 'flow-node-connector-config-lift', + 'flow-node-map-flow-alias', + 'flow-node-subflow-flow-alias', + 'flow-node-script-config-aliases', + 'permission-rls-priority-removed', + 'tool-inert-authoring-keys-removed', + 'app-dead-authoring-keys-removed', + 'app-area-fail-open-gates-removed', + 'action-inert-keys-removed', + 'flow-inert-keys-removed', + 'view-inert-keys-removed', + 'view-list-passthrough-keys-removed', + 'view-export-options-pdf-removed', + 'dashboard-inert-keys-removed', + 'agent-knowledge-removed', + 'skill-trigger-phrases-removed', + 'stack-api-require-auth-removed', + 'datasource-capabilities-removed', + 'datasource-inert-blocks-removed', + 'mapping-inert-keys-removed', + 'book-translations-removed', + 'job-id-removed', + 'translation-validation-messages-removed', + 'flow-node-wait-timeout-keys-removed', + 'datasource-read-replicas-removed', + 'datasource-config-driver-key-aliases', + 'datasource-driver-mongo-to-mongodb', + 'flow-node-script-branch-keys-removed', + 'object-managed-by-system-to-system-data', + 'retry-policy-converged', + 'object-enable-trash-mru-removed', + 'hook-body-crypto-hash-removed', + 'connector-rate-limit-config-removed', + 'dashboard-widget-responsive-removed', + 'dashboard-widget-action-aria-removed', + 'dashboard-widget-compareto-converged', + 'theme-inert-token-scales-removed', + 'page-header-subtitle-alias', + 'object-index-type-partial-removed', + 'field-mapping-transform-removed', + 'record-picker-display-field-to-label-field', + 'record-picker-inert-keys-removed', + 'page-card-body-to-children', + 'dataset-measure-array-string-agg-removed', + 'inline-action-api-params-to-body-extra', + 'page-tabs-type-to-tab-style', + 'page-structure-inert-keys-removed', + 'record-details-layout-removed', + 'app-hidden-to-unpublished', + 'action-global-nav-location-removed', + ], + semantic: [ + // One file per entry under `entries/semantic/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // + ], +}; /** * One retirement's sentences in a step's `rationale` (#20535). @@ -2431,8 +3497,8 @@ const step18: MigrationStep = { // the order `CONVERSIONS_BY_MAJOR[18]` applies them — the order that list // already curates. A hand-kept copy took an append from every retirement (its // tail conflicted like the rationale's) and can fall out of step with the - // list it copies: the retired step 17's named the same ids in a different - // order. Add a conversion there, never here. + // list it copies: step 17's names the same ids in a different order. Add a + // conversion there, never here. conversionIds: CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id), semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -2445,6 +3511,7 @@ const step18: MigrationStep = { /** All migration steps, keyed by the major they migrate into. */ export const MIGRATIONS_BY_MAJOR: Readonly> = { + 17: step17, 18: step18, }; @@ -2475,15 +3542,10 @@ export const MIGRATION_MAJORS: readonly number[] = Object.keys(MIGRATIONS_BY_MAJ * not (#17969). See "Lifecycle" below for what each of them means for a row * already in the table. * - **Check (c)**, the baseline-deletion gate (#4650): the major recorded here - * is what starts a tombstone's one-major aging clock (two until #25767), so - * it is also what lets its `authorable-surface/` line be deleted. Since #5898 - * — before that, (c) dated the clock by matching the key's LEAF against the - * conversion registry, and took the `Math.min` of the matches. Since #25767 - * the clock has a second, independent source: a `[RETIRED]` line that the - * authorable surface already carried at the release tag that cut the - * previous major (`@objectstack/spec@.0.0`) is aged out - * whether or not a row here names it — the tag is the cut, and presence at - * the cut is the fact the clock keys on. + * is what starts a tombstone's ~two-major aging clock, so it is also what + * eventually lets its `authorable-surface/` line be deleted. Since #5898 — + * before that, (c) dated the clock by matching the key's LEAF against the + * conversion registry, and took the `Math.min` of the matches. * * ## Why exact keys, and not the conversion `surface` * @@ -2512,47 +3574,43 @@ export const MIGRATION_MAJORS: readonly number[] = Object.keys(MIGRATIONS_BY_MAJ * table. An entry here is the *proof the retirement was declared*; the * conversion is the *prescription a consumer follows*. A retirement needs both. * - * ## Historical tombstones: dated by the release tag, not backfilled + * ## Historical tombstones: absent, and therefore not deletable * * Check (b) fires only on a NEW live → retired transition — measured against the - * committed `authorable-surface/` baseline, which already records every older - * tombstone as `[RETIRED]`. The retirements that landed before this table - * existed (#5902, 2026-08-06, while 16.x was current) therefore never - * re-triggered it and were never rows here: this reads "retirements registered - * under the exact-key gate", not "every retirement ever". + * committed `authorable-surface.json` baseline, which already records every + * older tombstone as `[RETIRED]`. The 97 retirements that landed before this + * table existed therefore never re-trigger it and are deliberately absent: this + * reads "retirements registered under the exact-key gate", not "every retirement + * ever". * - * #5898 measured what it would take to backfill them by major and found no - * honest source for an EXACT major (leaf-matching the conversion registry is - * the inference #4659 removed; the surface file's own git history begins at - * 17.0.0-rc.0). Under the one-major rule (#25767) an exact major is no longer - * the question — "was this retired before the previous major was cut?" is, and - * the release tag answers it: a `[RETIRED]` line the surface carried at - * `@objectstack/spec@17.0.0` was retired while 16.x was current, which is the - * same cohort this table's former major-17 region registered (pre-cut - * retirements register at the major that first refused them). Check (c) reads - * the tag through git, so the historical rows were never written here: they - * aged out and left with the ≤16.x kits in #25767, and an estimate was never - * written down as a fact. ⛔ Do NOT add a row you cannot date. + * #5898 measured what it would take to backfill them, and neither available + * source can date a row honestly: * - * ## Lifecycle + * - **Leaf-matching the conversion registry** is precisely the inference #4659 + * removed. It is also demonstrably wrong in both directions on this very data: + * of the two rows it dated as aged-out, `data/Index:type` was dated by + * `flow.node.type` (an unrelated cluster) and `api/RestApiConfig:requireAuth` + * by `api.requireAuth` at major 12 — which is the secure-default FLIP, a + * different kind of change to the same surface, not its retirement. + * - **The git history of `authorable-surface.json`** begins at 17.0.0-rc.0, so + * every one of the 97 rows first carries `[RETIRED]` at major 17. That is an + * artifact of the baseline file's birth, not archaeology. + * + * So the rows stay undeclared, and check (c) is fail-closed about it: a + * tombstone with no entry here cannot prove its age and its baseline line may + * not be deleted. Deleting one is a deliberate, reviewable act — establish the + * true major for that key, add it below, and check (b2) verifies the entry still + * names a key this build tombstones. Do NOT add a row you cannot date; an + * estimate written down here reads as a fact to every later gate. * - * A row lives ONE protocol major past the major it is registered under - * (ADR-0087 amendment 2026-10-26, #25767; two majors until then, with rows - * kept forever). A declared TOP-LEVEL tombstone ages out when - * `CURRENT_MAJOR - major ≥ 1` and its line may then leave the authorable - * surface (check (c)); the row is removed in the same move, as part of the - * retirement kit (tombstone, this row, the semantic entry, the conversion), and - * so is the whole region: at major 18 the `17` region went, at 19 the `18` - * region goes. Until that move a row naming a key the build no longer emits is - * the expected steady state, not an error. A NESTED row never reaches the - * surface file at all (0 dotted entries on the shipped surface), so it has no - * aging clock of its own and leaves with its region. + * ## Lifecycle * - * ⚠️ The order of the move is load-bearing: the tombstone's line is dated by - * the row at the merge base OR by the release tag, so a row may leave in the - * same PR as its tombstone, but a row must never leave AHEAD of its tombstone — - * a tombstone with no row and no tag presence cannot prove its age and its line - * stays (check (c) is fail-closed about it). + * A TRUE entry is permanent. A declared TOP-LEVEL tombstone ages out after ~two + * majors and its line may then leave `authorable-surface.json` (check (c)); its + * entry here stays, and then names a key the build no longer emits — the + * expected steady state, not an error. A NESTED row never reaches that file at + * all (0 dotted entries on the shipped surface), so it has no aging clock and + * "the build no longer emits it" is never that steady state for one. * * Two states are rejected. A row is read as a PATH only when its `name` half * carries a dot AND this build emits no top-level property of that exact name, @@ -2570,18 +3628,34 @@ export const MIGRATION_MAJORS: readonly number[] = Object.keys(MIGRATIONS_BY_MAJ * whose DEF this build does not emit is NOT this state: that is the whole-def * removal steady state, registered in {@link RETIRED_DEFS_BY_MAJOR}. * - * ⛔ "Delete the entry" above and the one-major removal are the only two ways a - * row leaves. A row check (b2) or (b3) refuses was never true of any build, so - * deleting it removes a false claim rather than a record; a row that was TRUE - * of some build leaves only with its major's region, never alone. Check (b3) - * can also not yet tell a wrong row apart from every truthful one — its refusal - * text enumerates the shapes it knows it cannot see — and for those the remedy - * is to teach the check the shape, ⛔ never to delete a row that is telling the - * truth. + * ⛔ "Delete the entry" and "entries are permanent" do not conflict, and reading + * one as licence to drop the other is the way to lose a retirement. A row that + * was ever TRUE of some build is history and is never deleted; a row check (b2) + * or (b3) refuses was never true of any build, so deleting it removes a false + * claim rather than a record. Check (b3) can also not yet tell a wrong row apart + * from every truthful one — its refusal text enumerates the shapes it knows it + * cannot see — and for those the remedy is to teach the check the shape, ⛔ never + * to delete a row that is telling the truth. * * @see scripts/build-schemas.ts — checks (b)/(b2)/(b3)/(c), the only consumers */ export const RETIRED_KEYS_BY_MAJOR: Readonly> = { + // The first entries since #4659 built this table (#5552). ONE tombstone + // produced THREE keys: `transform` is declared on `shared/FieldMapping`, and + // every `.extend()` of the base copies the retired property into its own + // walked shape, which `authorable-surface/` marks `[RETIRED]` separately. + // Registered per key, as the gate reads them — nothing radiates from the + // base. Two of the three remain: `data/ExternalFieldMapping:transform` left + // when #8075 retired the whole external-lookup family (the def retirement + // subsumes the key entry, the WidgetManifest.performance way — see + // RETIRED_DEFS_BY_MAJOR below). + 17: [ + // One file per entry under `entries/retired-keys/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // + ], // Post-cut retirements: v17.0.0 was cut before these landed, so the // tombstone ships on the 17.x line (launch-window convention) and the // prescription registers at the major boundary where `migrate meta` users @@ -2660,18 +3734,56 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> * * ## Lifecycle * - * An entry lives ONE protocol major past the major it is registered under - * (ADR-0087 amendment 2026-10-26, #25767; permanent until then). After the - * removal merges it names a def no build emits — the expected steady state — - * and it leaves with its region when the next major is cut, as the rest of the - * retirement kit does: at major 18 the `17` region went, at 19 the `18` region - * goes. The one state the gate rejects is an entry naming a def this build - * STILL publishes: a registration nothing consumed, pre-approving a removal - * that has not happened. + * Entries are permanent, and after the removal merges they name a def no build + * emits — the expected steady state. The one state the gate rejects is an entry + * naming a def this build STILL publishes: a registration nothing consumed, + * pre-approving a removal that has not happened. * * @see scripts/build-schemas.ts — the manifest deletion gate, the only consumer */ export const RETIRED_DEFS_BY_MAJOR: Readonly> = { + // The first entry since #4725 built this table (#5552). The `transform` key's + // value schema had no other consumer, so it goes with the key rather than + // surviving as an exported union nothing references — an exported schema with + // no consumer reads as a capability to whoever finds it (#3950). + // + // The ten that follow are #5055 (ADR-0049 enforce-or-remove, maintainer ruling + // 2026-08-06, window moved to protocol 17 on 2026-08-07): `ui/widget.zod.ts`'s + // widget-registration vocabulary and the five doorless shapes of + // `ui/i18n.zod.ts`. None had a carrier key, none was reachable from the + // metadata-type roots, and none was ever parsed in objectstack / objectui / + // cloud — so there is no tombstone and no D2 conversion (route 3 of the + // retirement playbook), and this table plus the D3 `SemanticMigration` + // `ui-widget-i18n-family-retired` IS the declaration. Same shape as #4988. + // + // ⚠️ `ui/FieldWidgetProps` is deliberately NOT here — it was never in the + // manifest to begin with (its `onChange` is a `z.function()`, so no JSON + // Schema is emitted) and it survives the retirement: it is a React props + // contract, not authorable metadata, and it has a live compile-time consumer + // in objectui (`packages/fields/src/__tests__/spec-symbol-batch7.test.ts`, + // landed by objectui PR #3289). + // + // The 2026-08-08 ADR-0049 sweep (#6486) adds twenty-three more across three + // members (4 + 10 + 9), all route 3 and all whole-def: `system/http-server.zod.ts`'s + // runtime vocabulary (#5295, D3 `http-server-runtime-vocabulary-retired`), + // `api/protocol.zod.ts`'s viewId-addressed view CRUD (commit f549a0d4a, D3 + // `view-management-protocol-retired`) and the whole L2 ETL layer (#6414, D3 + // `etl-pipeline-layer-retired`). None had a carrier key and none was ever + // parsed outside its own unit tests, so again there is no tombstone and no D2 + // conversion — this table plus those three entries ARE the declaration. + // + // ⚠️ `system/ServerRateLimitConfig` is deliberately NOT here. It sits four + // lines from the retired `system/ServerCapabilities` in the same manifest and + // shares its prefix, but it belongs to `StackServerSecurity.rateLimit` — the + // LIVE server surface #5006 admitted, with an executor. Prefix adjacency is + // not evidence. + 17: [ + // One file per entry under `entries/retired-defs/`, concatenated here sorted by + // entry id by `gen:migration-registry` (#7297). Add an entry by adding a + // FILE — never by editing between the markers, which is generated. + // + // + ], // Post-cut retirements: v17.0.0 was cut before these landed, so the // removal ships on the 17.x line (launch-window convention) and the // prescription registers at the major boundary where `migrate meta` users diff --git a/packages/spec/src/migrations/semantic-relevance.test.ts b/packages/spec/src/migrations/semantic-relevance.test.ts index ff796419b19..75f76088ce7 100644 --- a/packages/spec/src/migrations/semantic-relevance.test.ts +++ b/packages/spec/src/migrations/semantic-relevance.test.ts @@ -35,6 +35,11 @@ import type { MigrationTodo, SemanticRelevance } from './types.js'; * its question names. */ const EXPECTED_RELEVANCE: Readonly> = { + '17:dashboard-widget-compareto-offset': ['dashboards'], + '17:declarative-apis-endpoints-live': ['apis'], + '17:job-retry-policy-constraints-tightened': ['jobs'], + '17:sharing-rule-recipient-reconcile': ['sharingRules'], + '17:tool-requires-confirmation-retired': ['tools'], '18:agent-memory-store-retired-and-limits-required': ['agents'], '18:agent-structured-output-refused-members-retired': ['agents'], '18:analytics-cube-public-default-visible-enforced': ['analyticsCubes'], @@ -78,10 +83,10 @@ describe('which entries carry a relevance question (ENUMERATION)', () => { expect(actual).toEqual(EXPECTED_RELEVANCE); }); - it('is 21 entries, all of protocol 18 (the 5 protocol-17 rows left with step 17 on #25767)', () => { + it('is 26 entries: 5 of protocol 17 and 21 of protocol 18', () => { const keys = Object.keys(EXPECTED_RELEVANCE); - expect(keys).toHaveLength(21); - expect(keys.filter((k) => k.startsWith('17:'))).toHaveLength(0); + expect(keys).toHaveLength(26); + expect(keys.filter((k) => k.startsWith('17:'))).toHaveLength(5); expect(keys.filter((k) => k.startsWith('18:'))).toHaveLength(21); }); diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts index a569f2497a3..0571671e98f 100644 --- a/packages/spec/src/security/sharing.zod.ts +++ b/packages/spec/src/security/sharing.zod.ts @@ -76,7 +76,7 @@ export const SharingLevel = enumWithRetiredValues([ + "to `edit` until this release. Use `accessLevel: 'edit'`. Change the value; the access it grants is " + 'unchanged. A sharing rule grants read or edit; delete and transfer come from object permissions and ' + 'ownership. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', }); /** diff --git a/packages/spec/src/shared/mapping.test.ts b/packages/spec/src/shared/mapping.test.ts index 7e347e39921..e80d2655a60 100644 --- a/packages/spec/src/shared/mapping.test.ts +++ b/packages/spec/src/shared/mapping.test.ts @@ -89,11 +89,11 @@ describe('FieldMapping.transform is retired, and says so', () => { // It must name the live mechanism, not merely refuse: an author who wrote a // transform wants to know where transforms actually run. expect(result.error!.issues[0]!.message).toMatch(/mapping\.fieldMapping\[\]\.transform/s); - // The ADR-0087 conversion that covered this key rode the protocol-17 step, - // which aged out with the 16.x kits (#25767); the tombstone itself stays - // because this base is a plain `z.object` (a delete would strip in silence), - // and its house sentence now says the step is gone and links the guide. - expect(result.error!.issues[0]!.message).toMatch(/support floor 17/s); + // The ADR-0087 conversion that covered this key aged out with the 16.x + // kits (#25767); the tombstone itself stays because this base is a plain + // `z.object` (a delete would strip in silence), and its house sentence now + // says the conversion is gone and links the guide. + expect(result.error!.issues[0]!.message).toMatch(/protocol-16\.x conversions/s); expect(result.error!.issues[0]!.message).not.toMatch(/os migrate meta --from 16/s); }); diff --git a/packages/spec/src/shared/mapping.zod.ts b/packages/spec/src/shared/mapping.zod.ts index 13cd5117c59..46b928a4ddf 100644 --- a/packages/spec/src/shared/mapping.zod.ts +++ b/packages/spec/src/shared/mapping.zod.ts @@ -101,7 +101,7 @@ export const FieldMappingSchema = lazySchema(() => z.object({ + '`none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), ' + 'applied by the REST import path, which rejects `javascript` with a 400 rather than ' + 'pretending to run it. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', ), /** diff --git a/packages/spec/src/shared/protocol-upgrade-guide.ts b/packages/spec/src/shared/protocol-upgrade-guide.ts index 452cf741332..4286e9719c8 100644 --- a/packages/spec/src/shared/protocol-upgrade-guide.ts +++ b/packages/spec/src/shared/protocol-upgrade-guide.ts @@ -10,13 +10,15 @@ * an ordinary unknown key: the `strictObject` refusal names it, offers the * nearest declared spelling, and points HERE, because the prescription that * used to ride on the tombstone is now only in the generated upgrade guide - * (`scripts/build-upgrade-guide.ts`, one page per migrated major). The - * migration chain's floor rises on the same clock, so `os migrate meta --from - * N` below the floor refuses with the same link instead of converting. + * (`scripts/build-upgrade-guide.ts`, one page per migrated major). The kit + * ages out; the migration chain's STEP stays: `os migrate meta --from N` still + * replays down to the ruled support floor (`MIGRATION_SUPPORT_FLOOR`, 16), and + * moving that floor is its own release-policy ruling (#25767 ruling + * 6434038284, ADR-0087 D3). * - * One spelling, shared by the strict refusal (`suggestions.zod.ts`), the floor - * error (`migrations/chain.ts`) and the CLI's own `--from` examples, so the - * three doors an author can hit all send them to the same page. + * One spelling, shared by the strict refusal (`suggestions.zod.ts`) and the + * console's own aged-out refusals (`@object-ui/types`), so every door an author + * can hit sends them to the same page. */ export const PROTOCOL_UPGRADE_GUIDE_URL = 'https://objectstack.ai/docs/protocol-upgrade'; diff --git a/packages/spec/src/shared/retired-key-migrate-sentence.test.ts b/packages/spec/src/shared/retired-key-migrate-sentence.test.ts index 76346adc855..e67237741a9 100644 --- a/packages/spec/src/shared/retired-key-migrate-sentence.test.ts +++ b/packages/spec/src/shared/retired-key-migrate-sentence.test.ts @@ -156,19 +156,20 @@ const CORPORA: Corpus[] = [ }, ]; -const MARKER = /(?:Run )?`os migrate meta --from \d+`|This edit left `os migrate meta` with the protocol-\d+\.x step/g; +const MARKER = /(?:Run )?`os migrate meta --from \d+`|This edit left `os migrate meta` with the protocol-\d+\.x conversions/g; /** - * [#25767] The POST-FLOOR house form. A 17.x retirement's prescription used to - * name `os migrate meta --from 16`; the support floor is 17 since the 2026-10-26 - * ruling (ADR-0087 D2 = A), so that command refuses, and the sentence says what - * is still true: the step exists on a release that carries it, the edit is - * applied by hand otherwise, and the protocol upgrade guide is where the - * prescription lives from here. Same anchoring as the `--from` house form: last - * sentence of its literal, nothing after the closing period. + * [#25767] The AGED-OUT house form. A tombstone whose conversion aged out + * with its 16.x kit used to name `os migrate meta --from 16`; that command + * still replays the chain (the support floor stays 16, ruling 6434038284) but + * no longer carries this edit, so the sentence says what is still true: the + * edit is applied by hand, or by the tool on a release that still carries the + * conversion, and the protocol upgrade guide is where the prescription lives + * from here. Same anchoring as the `--from` house form: last sentence of its + * literal, nothing after the closing period. */ const POST_FLOOR_AT_MARKER = - /^This edit left `os migrate meta` with the protocol-\d+\.x step \(support floor \d+\): apply it by hand, or run the tool on a release that still carries the step — https:\/\/objectstack\.ai\/docs\/protocol-upgrade\.['"]/; + /^This edit left `os migrate meta` with the protocol-\d+\.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https:\/\/objectstack\.ai\/docs\/protocol-upgrade\.['"]/; /** * House form, anchored at the marker: the sentence must be the LAST sentence diff --git a/packages/spec/src/shared/retired-key.ts b/packages/spec/src/shared/retired-key.ts index adc9bdec5d9..5a9f5b8d8ca 100644 --- a/packages/spec/src/shared/retired-key.ts +++ b/packages/spec/src/shared/retired-key.ts @@ -96,9 +96,9 @@ * guide sentence (after its fix channels, ahead of its history) points at the * protocol upgrade guide * (`shared/protocol-upgrade-guide.ts`), where the history lives alongside - * CHANGELOG.md. `os migrate meta --from ` rises with it: the support floor - * (`MIGRATION_SUPPORT_FLOOR`) is one major below the current one, so a source - * older than that gets the refusal and the link, not a conversion. + * CHANGELOG.md. Its D2 conversion leaves with it; the migration chain's step + * does not — `os migrate meta --from ` still replays down to the support + * floor (`MIGRATION_SUPPORT_FLOOR`), which only a release-policy ruling moves. */ import { z } from 'zod'; @@ -185,7 +185,7 @@ type Tombstone = z.ZodOptional>; * conditionalRequired: retiredKey( * '`conditionalRequired` was removed in @objectstack/spec 17.0.0 (#3855). ' + * 'Rename the key to `requiredWhen` — the value (a CEL predicate) is unchanged. ' + - * 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; ' + + * 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; ' + * '`--write` applies the ones it can prove, and you apply the rest by hand.', * ), * ``` diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index bf460d65c78..8ac7e029c6f 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -881,7 +881,7 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { + 'is now always denied. Delete the key; publish public surfaces by declaration instead — a public ' + "form view, a share link, or `book.audience: 'public'`. A stack that mounts no auth at all now " + 'fails at boot rather than silently serving object data to anonymous callers. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', ), /** Enable environment-scoped routing for data/meta/AI APIs. */ enableProjectScoping: z.boolean().optional(), diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index 0d293d3bbb2..16f47667ea0 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -76,7 +76,7 @@ const BOOK_TRANSLATIONS_RETIRED = + 'near neighbour that DOES work: `doc.translations` is live and read on every doc render ' + 'path — localize the docs themselves, and the portal picks the reader\'s locale up from ' + 'there. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; export const BookGroupSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index 577736ddc00..c2833afb80e 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -171,7 +171,7 @@ const JOB_ID_RETIRED = + 'key, the `sys_job` row key, and the `JobExecution.jobId` stamp. Two jobs differing only ' + 'in `id` were the same job. Delete the key; rename the job via `name` if you need a ' + 'different identity. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * `job.timeout` → `job.timeoutMs` (#14478). The unit lived only in the @@ -277,7 +277,8 @@ const JOB_RETRY_POLICY_NO_ATTEMPTS = + 'Until @objectstack/spec 18 the loader wrote `maxRetries: 3` into a block that omitted it (the pre-17 ' + 'default); it no longer does, and a retry is a decision the job states, because a retry re-runs ' + 'whatever the failed attempt already did. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies ' + + 'the ones it can prove, and you apply the rest by hand.'; export const JobSchema = lazySchema(() => strictObject({ surface: 'this job', diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 9cf756bff98..71262767c64 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -676,7 +676,7 @@ const TRANSLATION_KEY_GUIDANCE: Record._validations..message', which the write " - + 'path resolves. Delete this key. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'path resolves. Delete this key. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', o: "`o` is the retired object-first dialect, which no resolver reads — use 'objects.'", app: "`app` is the retired object-first dialect, which no resolver reads — use 'apps.'", nav: "`nav` is the retired object-first dialect, which no resolver reads — use 'apps..navigation..label'", diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index 85607c93e5c..52550d68737 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -1547,14 +1547,14 @@ describe('ACTION_LOCATIONS — canonical source of truth', () => { // The prescription: names the value, says it was removed, points at the // live alternatives (a served location, or the headless `locations: []`), // and closes with the house `os migrate meta` sentence (#6856 route D) — - // in its post-floor form since #25767: the protocol-17 step that carried - // the rewrite aged out, so the sentence says so and links the guide. + // in its aged-out form since #25767: the 16.x conversion that carried + // the rewrite aged out with its kit, so the sentence says so and links the guide. const prescribes = (fn: () => unknown) => { expect(fn).toThrow(/`global_nav` was removed from `ACTION_LOCATIONS`/s); expect(fn).toThrow(/ADR-0049 enforce-or-remove/s); expect(fn).not.toThrow(/#\d{3,5}\b/); expect(fn).toThrow(/locations: \[\]/s); - expect(fn).toThrow(/support floor 17/s); + expect(fn).toThrow(/protocol-16\.x conversions/s); expect(fn).not.toThrow(/os migrate meta --from 16/s); }; diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index df1659a497c..245acf8f37c 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -615,7 +615,7 @@ const TARGET_REQUIRED_TYPES: ReadonlySet = new Set( * only handler slot, so no consumer has a second slot to disagree about. An * authored `execute` is rejected with the rename prescription rather than * silently stripped; `os migrate meta --from 16` listed the edit until the - * protocol-16.x step aged out with the support floor (#25767). + * protocol-16.x conversion aged out with its kit (#25767). * * @example Good action names * - 'on_close_deal' @@ -649,7 +649,7 @@ const GLOBAL_NAV_RETIRED = + '`record_related`, `record_section`), or — for an action that deliberately has no UI home, ' + 'such as an object-less one invoked over REST/MCP/AI — declare it headless with ' + '`locations: []`, which keeps its capability gate, param contract and audit trail. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * Action Location — where an action is allowed to surface in the UI. @@ -1329,7 +1329,7 @@ const actionObject = () => strictObject({ + "(`${param.X}` interpolates a value collected by the params dialog, `${ctx.X}` one from the action context), and open a new tab with " + "`openIn: 'new-tab'`. The url-side readings of an object `params` — a static `${param.X}` scope, and `params.newTab` — are RETIRED, not renamed. " + 'Expected an array of ActionParam, received an object. ' - + 'The inline `element:button` case left `os migrate meta` with the protocol-16.x step (support floor 17): move it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade; a registered action keeps its object form until you move it by hand.' + + 'Run `os migrate meta --from 16` to list the mechanical edits for the inline `element:button` action case; `--write` applies the ones it can prove, and a registered action keeps its object form until you move it by hand.' : undefined ), }).optional().describe('Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`).'), diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 14e9b06bc4b..2a4b27508b2 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1233,7 +1233,7 @@ const AREA_ORDER_RETIRED = + 'ever sorted areas; both the sidebar and the schema renderer iterate the array as ' + 'authored, so declaration order already IS display order. Delete the key and reorder the ' + '`areas` array itself. NOTE the neighbour that behaves differently: a navigation ITEM\'s ' - + '`order` is genuinely sorted — this removal does not touch it. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + '`order` is genuinely sorted — this removal does not touch it. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * `app.areas[].visible` and `app.areas[].requiredPermissions`, retired in @@ -1283,7 +1283,7 @@ const AREA_VISIBLE_RETIRED = + 'tree — the app\'s top-level `navigation` AND every `areas[].navigation`, both stripped ' + 'server-side. The distinction survives at every level: `visible` is CEL ' + 'evaluated in the browser, so it hides an entry that has already been sent, while ' - + '`requiredPermissions` stops that entry from being served at all. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + '`requiredPermissions` stops that entry from being served at all. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; const AREA_REQUIRED_PERMISSIONS_RETIRED = '`areas[].requiredPermissions` was removed in @objectstack/spec 17.0.0 (ADR-0049) — ' @@ -1298,7 +1298,7 @@ const AREA_REQUIRED_PERMISSIONS_RETIRED = + 'area-level key is not revived. Still evaluated client-side ONLY, at every level: ' + '`visible` (CEL) and `requiresObject` — so anything that must never reach the browser ' + 'goes in `requiredPermissions`, never in `visible`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * Navigation Area Schema @@ -1438,7 +1438,7 @@ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> = + 'offered an All row regardless of this flag, so `includeAll: false` hardened nothing ' + 'and `includeAll: true` unlocked nothing. Delete the key. To widen what a selector ' + 'offers, widen `optionsSource.filter` instead. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.', showall: '`contextSelectors[].includeAll` (which `showall` aliased) was removed in ' + '@objectstack/spec 17.0.0 — selectors are mandatory-scope and never render an ' @@ -1446,7 +1446,7 @@ const CONTEXT_SELECTOR_RETIRED_KEY_GUIDANCE: Readonly> = placement: '`contextSelectors[].placement` was removed in @objectstack/spec 17.0.0 (' + 'ADR-0049) — no renderer ever read it. Selectors always render in the sidebar header ' - + "block, and `'topbar'` placed nothing in the topbar. Delete the key. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.", + + "block, and `'topbar'` placed nothing in the topbar. Delete the key. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.", location: '`contextSelectors[].placement` (which `location` aliased) was removed in ' + '@objectstack/spec 17.0.0 — selectors always render in the sidebar header. ' @@ -1629,7 +1629,7 @@ const HOME_PAGE_ID_RETIRED = + 'follows `isDefault` routing. Delete the key; to change where an app opens, ' + 'reorder `navigation` so the intended entry is first, and set `isDefault` on the app that ' + 'should own the root landing. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * The prescription for every author-shaped spelling of the ADR-0045 publish diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index aaa0166338d..5b0fc7b7383 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -628,7 +628,7 @@ export const PageHeaderProps = strictObject({ + 'off-spec second spelling of the header\'s second line, which the loader rewrote to `subtitle` until ' + 'this release; `subtitle` is the declared key. Use `subtitle`. Rename the key; the value ' + '(the second line\'s text) is unchanged. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.', + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', }, }, { /** diff --git a/packages/spec/src/ui/dashboard-compareto.test.ts b/packages/spec/src/ui/dashboard-compareto.test.ts index b32a4d3118c..1ff5d08999f 100644 --- a/packages/spec/src/ui/dashboard-compareto.test.ts +++ b/packages/spec/src/ui/dashboard-compareto.test.ts @@ -122,14 +122,14 @@ describe('compareTo — every retired spelling is rejected WITH its upgrade', () expect(period).not.toMatch(/#\d{3,5}\b/); expect(period).toContain("kind: "); expect(period).toContain('previousPeriod'); - // The protocol-16.x step aged out on #25767 (support floor 17): the sentence - // says so instead of naming a `--from 16` run the tool refuses. - expect(period).toContain('support floor 17'); + // The protocol-16.x conversion aged out with its kit on #25767: the sentence + // says so instead of naming a `--from 16` run that no longer lists the edit. + expect(period).toContain('protocol-16.x conversions'); expect(period).not.toContain('--from 16'); const year = reject('previousYear'); expect(year).toContain('previousYear'); - expect(year).toContain('support floor 17'); + expect(year).toContain('protocol-16.x conversions'); }); /** diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 596a21c6f75..c0105c45bd9 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -392,8 +392,8 @@ const COMPARE_TO_OFFSET_RETIRED = + "(`'7d'`, `'1M'`, …) there is no faithful one-key rewrite: state the window you want on the " + "widget's own `filter` and compare it with `previousPeriod`, which shifts by whatever length " + 'that window resolves to. ' - + 'The `1y` case left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool ' - + 'on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade; the other durations you re-state yourself.'; + + 'The `1y` case left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool ' + + 'on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade; the other durations you re-state yourself.'; // The two string arms. They parsed, and on a dataset widget they then did // NOTHING — DatasetWidget dropped them deliberately, because forwarding one made @@ -407,7 +407,7 @@ const COMPARE_TO_STRING_RETIRED = (kind: 'previousPeriod' | 'previousYear') => + 'analytics executor actually reads it (`DatasetSelection.compareTo`). Add `dimension` only ' + 'when the selection has more than one dated time dimension; with one, the executor resolves ' + 'it. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * The one widget `type` that reads `options.stageOrder`. diff --git a/packages/spec/src/ui/list-view-export-options.ts b/packages/spec/src/ui/list-view-export-options.ts index 30c15846fae..b8cd66cef62 100644 --- a/packages/spec/src/ui/list-view-export-options.ts +++ b/packages/spec/src/ui/list-view-export-options.ts @@ -49,7 +49,7 @@ export const LIST_VIEW_EXPORT_PDF_RETIRED = + "not produce documents: to let users print a list as shown, set the view's `allowPrinting`; " + 'a printable document (an invoice, a delivery order, a letter) is a page that declares ' + '`print` — its paper, margins and running header and footer — with its blocks in `regions`. ' - + 'This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade.'; + + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; /** * Export formats the platform actually delivers (#8010): `csv`/`json` on both diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 0c6024e4ee2..08662c86393 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -4035,9 +4035,9 @@ describe('ListViewSchema.exportOptions — object form + array lift + pdf retire expect(message).toMatch(/a printable document \(an invoice, a delivery order, a letter\) is a page that declares\s+`print`/); expect(message).not.toMatch(/#\d{3,5}\b/); expect(message).toMatch(/'csv', 'xlsx' and 'json'/); - // [#25767] The protocol-16.x step aged out (support floor 17): the sentence - // says so instead of naming a `--from 16` run the tool refuses. - expect(message).toMatch(/left `os migrate meta` with the protocol-16\.x step \(support floor 17\)/); + // [#25767] The protocol-16.x conversion aged out with its kit: the sentence + // says so instead of naming a `--from 16` run that no longer lists the edit. + expect(message).toMatch(/left `os migrate meta` with the protocol-16\.x conversions/); expect(message).not.toMatch(/--from 16/); }); From 002710c1dcd5483b3cb0f3a62bf8fba57fdad117 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:16:47 +0000 Subject: [PATCH 19/22] docs(spec): regenerate the reference pages for the re-pointed retirement sentences Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- content/docs/references/api/rest-server.mdx | 4 ++-- content/docs/references/integration/connector.mdx | 4 ++-- content/docs/references/shared/mapping.mdx | 2 +- content/docs/references/system/book.mdx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index 9d4b7e53413..4a946f656a2 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -249,7 +249,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (server-wide search opt-out; embedder-only, not settable from `os serve`) | | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | -| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | +| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version | | **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. | @@ -297,7 +297,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableSearch** | `boolean` | optional (default: `true`) | Enable structured search endpoints (server-wide search opt-out; embedder-only, not settable from `os serve`) | | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | -| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | +| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version | | **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. | diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 596b15c56c6..29ada7d1831 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -194,7 +194,7 @@ const result = ConnectorSchema.parse(data); | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **fieldMappings** | `never` | optional | [REMOVED] `connector.fieldMappings` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever moved a value through a connector field mapping: nothing read `source`, `target`, `defaultValue`, `dataType`, `required` or `syncMode`. Delete the key; the `ConnectorFieldMapping` shape leaves with it. Map fields on the sync's TARGET instead: a `mapping`'s `fieldMapping` (`source` → `target`, with a `transform` the import path executes), which its `connectorSource` pulls through. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | +| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | @@ -491,7 +491,7 @@ Connector type | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **fieldMappings** | `never` | optional | [REMOVED] `connector.fieldMappings` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever moved a value through a connector field mapping: nothing read `source`, `target`, `defaultValue`, `dataType`, `required` or `syncMode`. Delete the key; the `ConnectorFieldMapping` shape leaves with it. Map fields on the sync's TARGET instead: a `mapping`'s `fieldMapping` (`source` → `target`, with a `transform` the import path executes), which its `connectorSource` pulls through. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | +| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | diff --git a/content/docs/references/shared/mapping.mdx b/content/docs/references/shared/mapping.mdx index 620b2408952..d109579ad50 100644 --- a/content/docs/references/shared/mapping.mdx +++ b/content/docs/references/shared/mapping.mdx @@ -76,7 +76,7 @@ const result = FieldMappingSchema.parse(data); | :--- | :--- | :--- | :--- | | **source** | `string` | ✅ | Source field name | | **target** | `string` | ✅ | Target field name | -| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | +| **transform** | `never` | optional | [REMOVED] `FieldMapping.transform` — authored as `connector.fieldMappings[].transform` and `externalLookup.fieldMappings[].transform` — was removed in @objectstack/spec 17.0.0 (ADR-0049), and the whole `FieldMappingTransform` union went with it (`constant` / `cast` / `lookup` / `javascript` / `map`) — no runtime ever executed any of the five, and the `javascript` member advertised `dialect: "js"`, a dialect retired. Delete the key. The transform pipeline that IS enforced is the import mapping's: `mapping.fieldMapping[].transform` (a string enum — `none`/`constant`/`map`/`split`/`join`/`lookup` — with its settings in `params`), applied by the REST import path, which rejects `javascript` with a 400 rather than pretending to run it. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **defaultValue** | `any` | optional | Default if source is null/undefined | diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx index 033d30194c2..48e785108dc 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -68,7 +68,7 @@ const result = BookSchema.parse(data); | :--- | :--- | :--- | :--- | | **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) | | **label** | `string` | ✅ | Section title — first-class, i18n-homed | -| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | @@ -116,7 +116,7 @@ Type: `'public'` | :--- | :--- | :--- | :--- | | **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) | | **label** | `string` | ✅ | Section title — first-class, i18n-homed | -| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. This edit left `os migrate meta` with the protocol-16.x step (support floor 17): apply it by hand, or run the tool on a release that still carries the step — https://objectstack.ai/docs/protocol-upgrade. | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | From 76dad1ceebf5ad6e3b34a8aa02b5647046779d01 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:49:34 +0000 Subject: [PATCH 20/22] fix(spec,cli): step 17 drops the ids of the 45 aged-out conversions; re-point the pins that replayed them Restoring registry.ts.template to main brought back step 17's graduated conversionIds list, which named the 45 conversions retired at 16.1.0 that left the registry with their kits; the chain's integrity pins refused the dangling ids. Those ids leave the list (the step and its semantic entries stay, the floor stays 16). The replay pin in migrations.test.ts, the cli cross-hop pin, the --from 16 protocol-range shape and the --write fixture now replay conversions step 17 still graduates (sharing-rule full to edit, the CRUD flow-node object alias) or protocol-18 ones. The three repo-project pins on RETIRED_KEYS_BY_MAJOR[17] go back to main with the restored rows. ADR-0087's addendum and the changeset say the step-17 list lost those ids. Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- .../25767-retire-16x-retirement-kits.md | 2 +- ...0087-metadata-protocol-upgrade-contract.md | 8 +- .../migrate/meta.report-order.test.ts | 4 +- .../test/migrate-meta-protocol-range.test.ts | 10 +- packages/cli/test/migrate-meta-write.test.ts | 119 ++++++++---------- .../connector-sync-retirement.test.ts | 11 +- .../spec/src/migrations/migrations.test.ts | 44 ++++--- .../spec/src/migrations/registry.ts.template | 50 +------- .../page-header-breadcrumb-retirement.test.ts | 4 +- .../page-header-show-star-retirement.test.ts | 6 +- 10 files changed, 110 insertions(+), 148 deletions(-) diff --git a/.changeset/25767-retire-16x-retirement-kits.md b/.changeset/25767-retire-16x-retirement-kits.md index 41a6c5675a9..ba12561c794 100644 --- a/.changeset/25767-retire-16x-retirement-kits.md +++ b/.changeset/25767-retire-16x-retirement-kits.md @@ -14,7 +14,7 @@ Metadata-protocol: narrowed - **The rule (ADR-0087, amended 2026-10-26, #25767 ruling D2 = A as scoped by ruling 6434038284).** A retirement kit — the `retiredKey()` tombstone and its D2 conversion with fixtures — lives through the protocol major after the one it retired in and is removed when the next major is cut. At 18 that removes the kits whose conversion retired at or below 16.x; 19 repeats the move one major on. The kit ages out; the migration chain's step stays. - **Before.** Such a key met a `retiredKey()` tombstone: the parse refused it with the key's own prescription in the error, `os migrate meta --from 16` listed and applied the mechanical edit, and a stored row in that shape was rewritten at read by the ≤16.x conversions. - **Now.** Where the tombstone sat in a strict container, the key is gone from the shape, so a `strictObject` refuses it as an unknown key. The refusal carries one more sentence, after its fix channels and ahead of its history sentence (`RETIRED_KEY_UPGRADE_GUIDE_SENTENCE`, exported with `PROTOCOL_UPGRADE_GUIDE_URL` from `@objectstack/spec`): it says that keys retired before the previous major are no longer prescribed in the error and names the protocol upgrade guide, `https://objectstack.ai/docs/protocol-upgrade`, where the prescription lives; the CHANGELOG of the release that retired the key carries it too. A tombstone that stays (its container strips unknown keys) ends its error with a sentence that says the edit left `os migrate meta` with the 16.x conversions, to be applied by hand or on a release that still carries the conversion, and links the guide. -- **The chain is unchanged.** `MIGRATION_SUPPORT_FLOOR` stays 16; step 17 and its entry files stay, and `os migrate meta --from 16` still replays 16 → 18, listing every surviving conversion and semantic to-do. Only the edits of the removed conversions are no longer applied for you. +- **The chain stays.** `MIGRATION_SUPPORT_FLOOR` stays 16; step 17 and its entry files stay, and `os migrate meta --from 16` still replays 16 → 18, listing every surviving conversion and semantic to-do. Step 17's graduated list drops the ids of the 45 conversions that aged out, so their edits are no longer applied for you. - **Stored rows.** A `sys_metadata` row still carrying such a spelling is no longer rewritten at read; canonicalize it once with `os migrate meta --stored --apply` on 18.2 or earlier, before moving to 18.3. `app-hidden-to-unpublished` is among the removed conversions, so a pre-split `hidden: true` app row is no longer rewritten to `_unpublished` by any seam, and the artifact door's refusal of it by id is removed as moot (`@objectstack/metadata-core`). - **What left.** 50 D2 conversions with `retiredAfter` at or below 16.x (5 at 15.0.0, 45 at 16.1.0) and their fixtures; 55 tombstones already `[RETIRED]` at `@objectstack/spec@17.0.0` whose container is strict; 61 `[RETIRED]` lines of the authorable surface and their liveness-ledger rows. 58 same-age tombstones whose container strips unknown keys stay, because deleting one would turn a refusal into a silent strip; the four inline-credential refusals are not retirements and are untouched. - **The deletion gate.** `check:authorable-surface` proof 1 ages a tombstone one major (was two). A deleted `[RETIRED]` line is dated by an exact `RETIRED_KEYS_BY_MAJOR` row or, new, by the previous major's release tag: a line already `[RETIRED]` on the surface at `@objectstack/spec@.0.0` was retired before that cut. The tag is immutable, so neither a row a PR writes nor one it deletes moves the clock; where the tag cannot be read the gate dates by the registry alone and says so. No gate was added. diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index 341aa1c535d..4b77bc91afb 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -1372,14 +1372,16 @@ conversions, so it is applied by hand or by a release that still carries it. 17 and its entry files stay, and `os migrate meta --from 16` keeps replaying 16 → 18. Each step still lists every surviving conversion it graduated — the 12 conversions with `toMajor: 17` and `retiredAfter: 17.7.0` stay listed under step 17 — and its -semantic to-dos. Moving the floor is its own release-policy decision (D3, "revisitable +semantic to-dos; only the edits of the conversions that aged out are no longer +applied by the replay. Moving the floor is its own release-policy decision (D3, "revisitable per major"), not a by-product of kit aging. **What was removed at 18 (this amendment's own diff).** - 50 D2 conversions with `retiredAfter` at or below 16.x (5 at 15.0.0, 45 at 16.1.0), - with their fixture pairs and the comments that named them. No surviving chain - entry linked one of them (`conversionIds` ∩ the 50 = ∅). + with their fixture pairs and the comments that named them. No semantic entry + linked one of them, but step 17's graduated list named the 45 at 16.1.0; those + ids left the list with their conversions (the step and its entries stay). - 55 tombstones whose key was already `[RETIRED]` at `@objectstack/spec@17.0.0` and whose container is `strictObject` — the ones whose deletion yields a refusal. 58 tombstones in the same age class sit in containers that **strip** unknown keys diff --git a/packages/cli/src/commands/migrate/meta.report-order.test.ts b/packages/cli/src/commands/migrate/meta.report-order.test.ts index 407fda198dc..6738b335429 100644 --- a/packages/cli/src/commands/migrate/meta.report-order.test.ts +++ b/packages/cli/src/commands/migrate/meta.report-order.test.ts @@ -515,7 +515,9 @@ describe('an applied edit a semantic entry judges prints that entry beside it, m // No authored link crosses a hop yet, though `SemanticMigration.conversionIds` // allows one, so this pin adds it: a protocol-18 entry made to judge a // protocol-17 conversion. Everything else is the chain's own result. - const earlier = 'action-execute-to-target'; + // (`action-execute-to-target`, the conversion this replayed until #25767, + // aged out with its 16.x kit; this one is a step-17 member that stays.) + const earlier = 'sharing-rule-access-level-full-to-edit'; const conversion = ALL_CONVERSIONS.find((c) => c.id === earlier)!; const judgeId = DECISION_JUDGE; const { result, lines } = run( diff --git a/packages/cli/test/migrate-meta-protocol-range.test.ts b/packages/cli/test/migrate-meta-protocol-range.test.ts index 8635680412a..80023a8812c 100644 --- a/packages/cli/test/migrate-meta-protocol-range.test.ts +++ b/packages/cli/test/migrate-meta-protocol-range.test.ts @@ -95,10 +95,12 @@ const RUN_TIMEOUT = 120_000; const SHAPES = [ { from: 16, - member: "views: [{ object: 'rg_item', list: { type: 'grid', columns: ['title'], striped: true } }]", - converted: ['view-list-passthrough-keys-removed'], - before: ', striped: true', - after: '', + // A step-17 conversion that survives the 16.x kits' age-out (#25767): the + // row used to ride `view-list-passthrough-keys-removed`, which left with them. + member: "sharingRules: [{ name: 'rg_share', type: 'criteria', object: 'rg_item', accessLevel: 'full', condition: 'record.title != null', sharedWith: { type: 'business_unit', value: 'bu_sales' } }]", + converted: ['sharing-rule-access-level-full-to-edit'], + before: "accessLevel: 'full'", + after: "accessLevel: 'edit'", }, { from: 17, member: "dashboards: [{ name: 'ops', label: 'Ops', widgets: [], refreshInterval: 300 }]", converted: ['dashboard-refresh-interval-to-refresh-interval-seconds'], before: 'refreshInterval: 300', after: 'refreshIntervalSeconds: 300' }, ]; diff --git a/packages/cli/test/migrate-meta-write.test.ts b/packages/cli/test/migrate-meta-write.test.ts index 14bbf710e9f..e23638aa19d 100644 --- a/packages/cli/test/migrate-meta-write.test.ts +++ b/packages/cli/test/migrate-meta-write.test.ts @@ -168,7 +168,7 @@ beforeAll(() => { writeFileSync(join(root, 'node_modules', 'fake-kit', 'package.json'), JSON.stringify({ name: 'fake-kit', main: 'index.js' })); writeFileSync( join(root, 'node_modules', 'fake-kit', 'index.js'), - "exports.KitView = { object: 'rf_ticket', list: { type: 'grid', columns: ['title'], striped: true } };\n", + "exports.KitView = { object: 'rf_ticket', list: { type: 'grid', columns: ['title'], filterableFields: ['title'] } };\n", ); }); @@ -192,8 +192,6 @@ import { allFlows } from './src/flows/index.js'; export default defineStack({ manifest: { id: 'com.example.write', name: 'Write fixture', version: '1.0.0', type: 'app' }, objects: [TicketObject], - // The document store, spelled the old way. - datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: {} }], dashboards: Object.values(dashboards), views: Object.values(views), agents: [SupportAgent], @@ -212,6 +210,11 @@ export const OpsDashboard = Dashboard.create({ refreshInterval: 300, // seconds }); `, + // [#25767] The three `compareTo` spellings below were rewritten by + // `dashboard-widget-compareto-converged`, a 16.x conversion that aged out with + // its kit: `--write` leaves them as written now, and the schema still refuses + // them — the control that an aged-out spelling is not silently rewritten by + // a conversion that no longer exists, though step 17 itself still replays. 'src/dashboards/kpi.dashboard.ts': `import { Dashboard } from '@objectstack/spec/ui'; export const KpiDashboard = Dashboard.create({ @@ -233,9 +236,8 @@ export const TicketView = defineView({ list: { type: 'grid', columns: ['title'], - // Zebra rows. - striped: true, - bordered: false, // no borders + // Filter-panel whitelist. + filterableFields: ['title'], }, }); `, @@ -246,9 +248,10 @@ export const SupportAgent = defineAgent({ label: 'Support', role: 'Support assistant', instructions: 'Help customers.', - knowledge: { - sources: ['faq'], - indexes: ['docs'], + lifecycle: { + id: 'support', + initial: 'open', + states: { open: {} }, }, }); `, @@ -262,7 +265,6 @@ export const EscalateFlow = defineFlow({ name: 'escalate', label: 'Escalate', type: 'autolaunched', - active: false, nodes: [ { id: 'start', type: 'start', label: 'Start' }, { id: 'done', type: 'end', label: 'Done' }, @@ -276,7 +278,7 @@ export const TicketObject = ObjectSchema.create({ name: 'wr_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } }, - tenancy: { enabled: true, organizationField: 'organization_id' }, + externalSharingModel: 'private', }); `, }; @@ -286,42 +288,22 @@ function expectedAfterWrite(): Record { const p = PROJECT; return { ...p, - 'objectstack.config.ts': edit(p['objectstack.config.ts']!, "driver: 'mongo'", "driver: 'mongodb'"), 'src/dashboards/ops.dashboard.ts': edit(p['src/dashboards/ops.dashboard.ts']!, 'refreshInterval: 300,', 'refreshIntervalSeconds: 300,'), - 'src/dashboards/kpi.dashboard.ts': edit( - edit(p['src/dashboards/kpi.dashboard.ts']!, "compareTo: 'previousPeriod'", "compareTo: { kind: 'previousPeriod' }"), - "compareTo: { offset: '1y' }", - "compareTo: { kind: 'previousYear' }", - ), - 'src/views/ticket.view.ts': edit( - edit(p['src/views/ticket.view.ts']!, ' striped: true,\n', ''), - ' bordered: false, // no borders\n', - '', - ), + 'src/views/ticket.view.ts': edit(p['src/views/ticket.view.ts']!, " filterableFields: ['title'],\n", ''), 'src/agents/support.agent.ts': edit( p['src/agents/support.agent.ts']!, - " knowledge: {\n sources: ['faq'],\n indexes: ['docs'],\n },\n", + " lifecycle: {\n id: 'support',\n initial: 'open',\n states: { open: {} },\n },\n", '', ), - 'src/flows/escalate.flow.ts': edit(p['src/flows/escalate.flow.ts']!, ' active: false,\n', ''), - 'src/objects/ticket.object.ts': edit( - p['src/objects/ticket.object.ts']!, - "tenancy: { enabled: true, organizationField: 'organization_id' }", - 'tenancy: { enabled: true }', - ), + 'src/objects/ticket.object.ts': edit(p['src/objects/ticket.object.ts']!, " externalSharingModel: 'private',\n", ''), }; } const EXPECTED_SITES = [ - 'agents[0].knowledge (agent-knowledge-removed)', - 'dashboards[0].widgets[0].compareTo (dashboard-widget-compareto-converged)', - 'dashboards[0].widgets[1].compareTo (dashboard-widget-compareto-converged)', + 'agents[0].lifecycle (agent-lifecycle-removed)', 'dashboards[1].refreshIntervalSeconds (dashboard-refresh-interval-to-refresh-interval-seconds)', - 'datasources[0].driver (datasource-driver-mongo-to-mongodb)', - 'flows[0].active (flow-inert-keys-removed)', - 'objects[0].tenancy.organizationField (object-tenancy-organization-field-removed)', - 'views[0].list.bordered (view-list-passthrough-keys-removed)', - 'views[0].list.striped (view-list-passthrough-keys-removed)', + 'objects[0].externalSharingModel (object-external-sharing-model-removed)', + 'views[0].list.filterableFields (view-list-filterable-fields-removed)', ]; describe('os migrate meta --write over per-artifact modules', () => { @@ -352,11 +334,8 @@ describe('os migrate meta --write over per-artifact modules', () => { // Byte-for-byte, every file: the barrels and `index.ts` files included. expect(snapshot(dir)).toEqual(expectedAfterWrite()); expect(firstJson.write.files.map((f: any) => f.file).sort()).toEqual([ - 'objectstack.config.ts', 'src/agents/support.agent.ts', - 'src/dashboards/kpi.dashboard.ts', 'src/dashboards/ops.dashboard.ts', - 'src/flows/escalate.flow.ts', 'src/objects/ticket.object.ts', 'src/views/ticket.view.ts', ]); @@ -364,10 +343,10 @@ describe('os migrate meta --write over per-artifact modules', () => { it('reports each written site at its file and line as read', () => { const at = Object.fromEntries(firstJson.write.written.map((w: any) => [w.path, `${w.file}:${w.line}`])); - expect(at['datasources[0].driver']).toBe('objectstack.config.ts:12'); + expect(at['agents[0].lifecycle']).toBe('src/agents/support.agent.ts:8'); expect(at['dashboards[1].refreshIntervalSeconds']).toBe('src/dashboards/ops.dashboard.ts:7'); - expect(at['views[0].list.striped']).toBe('src/views/ticket.view.ts:9'); - expect(at['views[0].list.bordered']).toBe('src/views/ticket.view.ts:10'); + expect(at['objects[0].externalSharingModel']).toBe('src/objects/ticket.object.ts:7'); + expect(at['views[0].list.filterableFields']).toBe('src/views/ticket.view.ts:9'); }); it('writes the applied set and nothing else: every site it reports is an applied entry', () => { @@ -380,9 +359,17 @@ describe('os migrate meta --write over per-artifact modules', () => { // Relative to the dry run of the same build, not a pinned listing: which // notices the default list carries is the chain's business, not --write's. expect(firstJson.todos.map((t: any) => t.id)).toEqual(dry.todos.map((t: any) => t.id)); - // The `compareTo` arm the conversion declines has no mechanical change, so - // its bytes stay — and the schema still refuses it, as before the write. - expect(readFileSync(join(dir, 'src/dashboards/kpi.dashboard.ts'), 'utf8')).toContain("compareTo: { offset: '7d' }"); + }); + + it('leaves an aged-out spelling as written (#25767): no step rewrites it, and the schema still refuses it', () => { + // The `compareTo` spellings rode a 16.x conversion that aged out with its + // kit: nothing is attributed to them, their bytes stay, and the + // stack is still schema-invalid after the write — the refusal, not a + // silent rewrite, is the upgrade channel for a kit one major past its own. + expect(firstJson.applied.map((a: any) => a.conversionId)).not.toContain('dashboard-widget-compareto-converged'); + const kpi = readFileSync(join(dir, 'src/dashboards/kpi.dashboard.ts'), 'utf8'); + expect(kpi).toContain("compareTo: 'previousPeriod'"); + expect(kpi).toContain("compareTo: { offset: '7d' }"); expect(firstJson.schemaValid).toBe(false); }); @@ -405,7 +392,7 @@ describe('os migrate meta without --write (controls)', () => { const run = await runMeta(dir, ['--json', '--out', out]); expect(run.exitCode, run.stderr).toBeUndefined(); expect(snapshot(dir)).toEqual(before); - expect(JSON.parse(readFileSync(out, 'utf8')).datasources[0].driver).toBe('mongodb'); + expect(JSON.parse(readFileSync(out, 'utf8')).dashboards[1].refreshIntervalSeconds).toBe(300); }, RUN_TIMEOUT); it('prints the human report with --write, naming no capability it lacks', async () => { @@ -413,7 +400,7 @@ describe('os migrate meta without --write (controls)', () => { const run = await runMeta(dir, ['--write']); expect(run.exitCode, run.stderr).toBeUndefined(); const group = run.stdout.slice(run.stdout.indexOf('Wrote ')); - expect(group).toMatch(/^Wrote 9 of 9 mechanical change\(s\) into 7 file\(s\):/); + expect(group).toMatch(/^Wrote 4 of 4 mechanical change\(s\) into 4 file\(s\):/); expect(group).toContain('Re-ran the chain over the written sources: no mechanical change remains.'); // The group's own words; the spec's notices above it are the spec's. expect(group).not.toMatch(/automatic/i); @@ -536,17 +523,20 @@ const REFUSALS: Record = { 'objectstack.config.ts': `import { defineStack } from '@objectstack/spec'; import { KitView } from 'fake-kit'; -const MONGO = 'mongo'; -const sharedList = { type: 'grid', columns: ['title'], striped: true }; -const listBase = { bordered: true }; +const START = '10:00Z'; +const sharedList = { type: 'grid', columns: ['title'], filterableFields: ['title'] }; +const listBase = { filterableFields: ['title'] }; function makeAgent(name: string) { - return { name, label: name, role: 'r', instructions: 'i', knowledge: { sources: ['faq'] } }; + return { name, label: name, role: 'r', instructions: 'i', lifecycle: 'draft' }; } export default defineStack({ manifest: { id: 'com.example.refusals', name: 'Refusals', version: '1.0.0', type: 'app' }, - objects: [{ name: 'rf_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }], - datasources: [{ name: 'docs', label: 'Docs', driver: MONGO, config: {} }], + objects: [{ + name: 'rf_ticket', label: 'Ticket', + externalSharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' }, starts_at: { type: 'time', label: 'Starts', defaultValue: START } }, + }], views: [ { object: 'rf_ticket', list: sharedList }, { object: 'rf_ticket', list: sharedList }, @@ -554,14 +544,13 @@ export default defineStack({ { object: 'rf_ticket', list: { - type: 'grid', virtualScroll: true, + type: 'grid', filterableFields: ['title'], columns: ['title'], }, }, KitView, ], agents: [makeAgent('helper_agent')], - flows: [{ name: 'plain', label: 'Plain', type: 'autolaunched', active: true, nodes: [], edges: [] }], }); `, }; @@ -581,21 +570,21 @@ describe('os migrate meta --write refuses what it cannot prove, and says why', ( payload.write.manual.find((m: any) => m.path === path)?.kind; it('names the refusal kind of every site it leaves', () => { - expect(kindOf('datasources[0].driver')).toBe('computed'); - expect(kindOf('views[0].list.striped')).toBe('shared'); - expect(kindOf('views[1].list.striped')).toBe('shared'); - expect(kindOf('views[2].list.bordered')).toBe('spread'); - expect(kindOf('views[3].list.virtualScroll')).toBe('layout'); - expect(kindOf('views[4].list.striped')).toBe('outside-project'); - expect(kindOf('agents[0].knowledge')).toBe('helper'); + expect(kindOf('objects[0].fields.starts_at.defaultValue')).toBe('computed'); + expect(kindOf('views[0].list.filterableFields')).toBe('shared'); + expect(kindOf('views[1].list.filterableFields')).toBe('shared'); + expect(kindOf('views[2].list.filterableFields')).toBe('spread'); + expect(kindOf('views[3].list.filterableFields')).toBe('layout'); + expect(kindOf('views[4].list.filterableFields')).toBe('outside-project'); + expect(kindOf('agents[0].lifecycle')).toBe('helper'); expect(payload.write.manual).toHaveLength(7); for (const m of payload.write.manual) expect(m.reason.length).toBeGreaterThan(0); }); it('still writes the site it can prove, beside them — and nothing else', () => { - expect(sites(payload.write.written)).toEqual(['flows[0].active (flow-inert-keys-removed)']); + expect(sites(payload.write.written)).toEqual(['objects[0].externalSharingModel (object-external-sharing-model-removed)']); expect(snapshot(dir)).toEqual({ - 'objectstack.config.ts': edit(REFUSALS['objectstack.config.ts']!, ' active: true,', ''), + 'objectstack.config.ts': edit(REFUSALS['objectstack.config.ts']!, " externalSharingModel: 'private',\n", ''), }); expect(payload.write.verification.ok).toBe(true); }); diff --git a/packages/spec/src/integration/connector-sync-retirement.test.ts b/packages/spec/src/integration/connector-sync-retirement.test.ts index ca72d945521..e21e1a27a85 100644 --- a/packages/spec/src/integration/connector-sync-retirement.test.ts +++ b/packages/spec/src/integration/connector-sync-retirement.test.ts @@ -289,13 +289,10 @@ describe('connector sync retirement — ADR-0087 registration', () => { ]) { expect(RETIRED_DEFS_BY_MAJOR[18], `${def} must be declared`).toContain(def); } - // The earlier `transform` retirement (17.0.0) was a key-grain row under - // major 17. That whole table aged out at 18.2 (objectstack#25767, ADR-0087 - // amended 2026-10-26: a row lives one major past its tombstone, and the - // record that the key was ever retired is the previous major's release - // tag, not this registry). The row must not come back by itself. - expect(RETIRED_KEYS_BY_MAJOR[17]).toBeUndefined(); - expect(Object.values(RETIRED_KEYS_BY_MAJOR).flat()).not.toContain('integration/ConnectorFieldMapping:transform'); + // The earlier `transform` retirement's key-grain row stays: its def left + // whole, which is the steady state gate (b3) exempts — deleting the row + // would erase the record that the key was ever retired. + expect(RETIRED_KEYS_BY_MAJOR[17]).toContain('integration/ConnectorFieldMapping:transform'); }); it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { diff --git a/packages/spec/src/migrations/migrations.test.ts b/packages/spec/src/migrations/migrations.test.ts index 5b79a2413ef..d9316c94ffa 100644 --- a/packages/spec/src/migrations/migrations.test.ts +++ b/packages/spec/src/migrations/migrations.test.ts @@ -574,15 +574,30 @@ describe('migration chain (ADR-0087 D3)', () => { // floor to 16 and retired `step11`–`step16` with it, so the oldest hop the // chain still guarantees is FLOOR → FLOOR + 1 and the shapes are that // hop's. Written against the constant rather than the literal 16: the next - // floor move should re-point this replay, not delete it. + // floor move should re-point this replay, not delete it. #25767 re-pointed + // it once already: the two conversions it used to replay + // (`action-execute-to-target`, `field-conditionalRequired-to-requiredWhen`) + // aged out with their 16.x kits, so it replays two that step 17 still + // graduates. const OLDEST_HOP = MIGRATION_SUPPORT_FLOOR + 1; const oldShape = () => ({ - actions: [{ name: 'convert', label: 'Convert', type: 'script', execute: 'convertHandler' }], - objects: [ + sharingRules: [ { - name: 'crm_task', - label: 'Task', - fields: { due_date: { type: 'date', conditionalRequired: 'record.stage == "closed"' } }, + name: 'share_open_deals', + type: 'criteria', + object: 'crm_deal', + accessLevel: 'full', + condition: 'record.status == "open"', + sharedWith: { type: 'business_unit', value: 'bu_sales' }, + }, + ], + flows: [ + { + name: 'lead_lookup', + nodes: [ + { id: 'n1', type: 'start' }, + { id: 'n2', type: 'get_record', config: { object: 'lead', recordId: '{leadId}' } }, + ], }, ], }); @@ -590,18 +605,17 @@ describe('migration chain (ADR-0087 D3)', () => { it('migrates the oldest supported major\'s shapes to canonical', () => { const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); - const action = (result.stack.actions as any[])[0]; - expect(action).not.toHaveProperty('execute'); - expect(action.target).toBe('convertHandler'); - const field = (result.stack.objects as any[])[0].fields.due_date; - expect(field).not.toHaveProperty('conditionalRequired'); - expect(field.requiredWhen).toBe('record.stage == "closed"'); + const rule = (result.stack.sharingRules as any[])[0]; + expect(rule.accessLevel).toBe('edit'); + const config = (result.stack.flows as any[])[0].nodes[1].config; + expect(config).not.toHaveProperty('object'); + expect(config.objectName).toBe('lead'); // Two mechanical rewrites, named — a count alone would survive one of // them being replaced by an unrelated conversion firing on this shape. expect([...new Set(result.applied.map((a) => a.conversionId))].sort()).toEqual([ - 'action-execute-to-target', - 'field-conditionalRequired-to-requiredWhen', + 'flow-node-crud-object-alias', + 'sharing-rule-access-level-full-to-edit', ]); // Semantic TODOs are advisory per-major and always surfaced for the hop, // whatever the stack contains. @@ -626,7 +640,7 @@ describe('migration chain (ADR-0087 D3)', () => { const result = applyMetaMigrations(oldShape(), MIGRATION_SUPPORT_FLOOR, OLDEST_HOP); expect(result.hops).toHaveLength(1); expect(result.hops[0]!.toMajor).toBe(OLDEST_HOP); - expect((result.hops[0]!.stack.actions as any[])[0].target).toBe('convertHandler'); + expect((result.hops[0]!.stack.sharingRules as any[])[0].accessLevel).toBe('edit'); }); }); diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 033ef951d94..5a9781ada93 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1090,9 +1090,11 @@ const step17: MigrationStep = { '`formats` array rather than deleting the declaration.', ].join(''), conversionIds: [ - 'action-execute-to-target', - 'field-conditionalRequired-to-requiredWhen', - 'agent-tools-to-skills', + // #25767 (ADR-0087 2026-10-26 amendment, scoped by ruling 6434038284): the 45 + // conversions this step graduated with `retiredAfter: 16.1.0` aged out with + // their kits and left the registry, so their ids left this list. The step + // stays (the floor is 16); its semantic entries are unchanged, and the + // edits those conversions applied are prescribed by hand from the guide. 'sharing-rule-access-level-full-to-edit', 'flow-node-crud-object-alias', 'flow-node-notify-config-aliases', @@ -1101,52 +1103,10 @@ const step17: MigrationStep = { 'flow-node-map-flow-alias', 'flow-node-subflow-flow-alias', 'flow-node-script-config-aliases', - 'permission-rls-priority-removed', - 'tool-inert-authoring-keys-removed', - 'app-dead-authoring-keys-removed', - 'app-area-fail-open-gates-removed', - 'action-inert-keys-removed', - 'flow-inert-keys-removed', - 'view-inert-keys-removed', - 'view-list-passthrough-keys-removed', - 'view-export-options-pdf-removed', - 'dashboard-inert-keys-removed', - 'agent-knowledge-removed', - 'skill-trigger-phrases-removed', - 'stack-api-require-auth-removed', - 'datasource-capabilities-removed', - 'datasource-inert-blocks-removed', - 'mapping-inert-keys-removed', - 'book-translations-removed', - 'job-id-removed', - 'translation-validation-messages-removed', - 'flow-node-wait-timeout-keys-removed', - 'datasource-read-replicas-removed', - 'datasource-config-driver-key-aliases', 'datasource-driver-mongo-to-mongodb', - 'flow-node-script-branch-keys-removed', - 'object-managed-by-system-to-system-data', 'retry-policy-converged', - 'object-enable-trash-mru-removed', - 'hook-body-crypto-hash-removed', - 'connector-rate-limit-config-removed', - 'dashboard-widget-responsive-removed', - 'dashboard-widget-action-aria-removed', - 'dashboard-widget-compareto-converged', - 'theme-inert-token-scales-removed', 'page-header-subtitle-alias', - 'object-index-type-partial-removed', - 'field-mapping-transform-removed', - 'record-picker-display-field-to-label-field', - 'record-picker-inert-keys-removed', - 'page-card-body-to-children', - 'dataset-measure-array-string-agg-removed', 'inline-action-api-params-to-body-extra', - 'page-tabs-type-to-tab-style', - 'page-structure-inert-keys-removed', - 'record-details-layout-removed', - 'app-hidden-to-unpublished', - 'action-global-nav-location-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by diff --git a/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts b/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts index 057b0ae17a4..6b95f1ebaba 100644 --- a/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts +++ b/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts @@ -275,10 +275,8 @@ describe('page:header breadcrumb retirement — the ADR-0087 ledger row', () => it('declares ONE key under major 18', () => { expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(REGISTERED_KEY); const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); - // `ui/PageHeaderProps:icon` (17.0.0) stood first in this list until spec - // 18.2: the major-17 table aged out one major on (objectstack#25767, - // ADR-0087), so the row's surviving keys are the two 18 rows. expect(all.filter((k) => k.startsWith('ui/PageHeaderProps:'))).toEqual([ + 'ui/PageHeaderProps:icon', REGISTERED_KEY, // #23650 retired the row's third key the same way. 'ui/PageHeaderProps:showStar', diff --git a/packages/spec/src/ui/page-header-show-star-retirement.test.ts b/packages/spec/src/ui/page-header-show-star-retirement.test.ts index a0f90f5cfe8..4f754bf96cb 100644 --- a/packages/spec/src/ui/page-header-show-star-retirement.test.ts +++ b/packages/spec/src/ui/page-header-show-star-retirement.test.ts @@ -278,14 +278,12 @@ describe('page:header showStar retirement — the D2 conversion', () => { }); describe('page:header showStar retirement — the ADR-0087 ledger row', () => { - it('declares ONE key under major 18, beside the one this row lost before it', () => { + it('declares ONE key under major 18, beside the two this row lost before it', () => { expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(REGISTERED_KEY); const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); - // `ui/PageHeaderProps:icon` (17.0.0) was the third key here until spec - // 18.2: the major-17 table aged out one major on (objectstack#25767, - // ADR-0087), leaving the two 18 rows. expect(all.filter((k) => k.startsWith('ui/PageHeaderProps:')).sort()).toEqual([ 'ui/PageHeaderProps:breadcrumb', + 'ui/PageHeaderProps:icon', REGISTERED_KEY, ]); }); From 393c76858283e2efb23fe9d45436f5aa67b8a9af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:52:53 +0000 Subject: [PATCH 21/22] chore(spec): regenerate the merge's generated artifacts over main's side; the branch's five aged-out api.json lines stay deleted Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- content/docs/references/ui/app.mdx | 10 +++++----- packages/spec/authorable-surface/api.json | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index f5f23868d3c..0c6db4aceb2 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -278,7 +278,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'group'` | ✅ | | -| **expanded** | `boolean` | optional (default: `false`) | Default expansion state in sidebar | +| **expanded** | `boolean` | optional (default: `true`) | Whether the group starts open in the sidebar. Omitted, it opens; write `false` to keep it closed until the user opens it. A group holding the current route opens whatever it declares. | | **children** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | ✅ | Child navigation items | ### Nested Shape: `App.areas[number]` @@ -467,7 +467,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'group'` | ✅ | | -| **expanded** | `boolean` | optional (default: `false`) | Default expansion state in sidebar | +| **expanded** | `boolean` | optional (default: `true`) | Whether the group starts open in the sidebar. Omitted, it opens; write `false` to keep it closed until the user opens it. A group holding the current route opens whatever it declares. | ### Nested Shape: `GroupNavItem.dataSource` @@ -672,7 +672,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'group'` | ✅ | | -| **expanded** | `boolean` | optional (default: `false`) | Default expansion state in sidebar | +| **expanded** | `boolean` | optional (default: `true`) | Whether the group starts open in the sidebar. Omitted, it opens; write `false` to keep it closed until the user opens it. A group holding the current route opens whatever it declares. | | **children** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | ✅ | Child navigation items | @@ -871,7 +871,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'group'` | ✅ | | -| **expanded** | `boolean` | optional (default: `false`) | Default expansion state in sidebar | +| **expanded** | `boolean` | optional (default: `true`) | Whether the group starts open in the sidebar. Omitted, it opens; write `false` to keep it closed until the user opens it. A group holding the current route opens whatever it declares. | | **children** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | ✅ | Child navigation items | @@ -1192,7 +1192,7 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc` | **requiresObject** | `string` | optional | Hide/disable this entry unless the named object is registered in the runtime | | **requiresService** | `string` | optional | Hide/disable this entry unless the named kernel service is registered | | **type** | `'group'` | ✅ | | -| **expanded** | `boolean` | optional (default: `false`) | Default expansion state in sidebar | +| **expanded** | `boolean` | optional (default: `true`) | Whether the group starts open in the sidebar. Omitted, it opens; write `false` to keep it closed until the user opens it. A group holding the current route opens whatever it declares. | | **children** | `[NavigationItem](#navigationitem)[]` | ✅ | Child navigation items | ### Nested Shape: `NavigationItem[type='group'].dataSource` diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 494e66face2..c9023d7c766 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -945,6 +945,7 @@ "api/ImportRowResult:code", "api/ImportRowResult:droppedFields", "api/ImportRowResult:error", + "api/ImportRowResult:errors", "api/ImportRowResult:field", "api/ImportRowResult:id", "api/ImportRowResult:ok", From a0e848fb6e69a29cbe48b2820b2c7a9ffd3d3bb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 10:35:35 +0000 Subject: [PATCH 22/22] test(cli): the unreachable-archive fixture spells its postgres user as `username` again The rework restored this file to main and lost #26138's c61ede6f91. Test Core 4/8 failed audit-mail-bodies.archive case 7 at cd60de518a: the fixture's postgres archive carried `user`, the pre-17 spelling of `username`. The `datasource-config-driver-key-aliases` conversion that rewrote it at artifact load aged out with its kit, so the strict config door refuses the key and the command never reaches the not-connected archive the case pins. The fixture spells the live key. A census of packages/cli for the other spellings that conversion and the removed datasource conversions carried (sqlite file/database, postgres and mysql connectionString, mongo uri, inert blocks, capabilities, readReplicas, connector rateLimit) finds no other datasource declaration that uses one. Claude-Session: https://claude.ai/code/session_018wdyGVjfpxCUwq2UMrZkwi Co-authored-by: Claude --- .../migrate/audit-mail-bodies.archive.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts b/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts index 801592cecb3..c6f44db17f4 100644 --- a/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts +++ b/packages/cli/src/commands/migrate/audit-mail-bodies.archive.integration.test.ts @@ -288,7 +288,7 @@ beforeAll(async () => { // engine instead of failing. unreachableDir = mkdtempSync(join(tmpdir(), 'os-24899-unreachable-')); writeArtifact(unreachableDir, [ - { name: 'archive', driver: 'postgres', config: { host: '127.0.0.1', port: 1, database: 'archive', user: 'os24899' }, autoConnect: true }, + { name: 'archive', driver: 'postgres', config: { host: '127.0.0.1', port: 1, database: 'archive', username: 'os24899' }, autoConnect: true }, ]); unreachableRun = await runCommand(unreachableDir, join(unreachableDir, 'main.db'), ['--apply', '--yes', '--json']); }, HOOK_TIMEOUT_MS);