diff --git a/.changeset/11509-element-flat-binding-retired.md b/.changeset/pre/11509-element-flat-binding-retired.md similarity index 100% rename from .changeset/11509-element-flat-binding-retired.md rename to .changeset/pre/11509-element-flat-binding-retired.md diff --git a/.changeset/13458-manifest-permissions-string-list-retired.md b/.changeset/pre/13458-manifest-permissions-string-list-retired.md similarity index 100% rename from .changeset/13458-manifest-permissions-string-list-retired.md rename to .changeset/pre/13458-manifest-permissions-string-list-retired.md diff --git a/.changeset/15195-metadata-protocol-seed-organization-stamp.md b/.changeset/pre/15195-metadata-protocol-seed-organization-stamp.md similarity index 100% rename from .changeset/15195-metadata-protocol-seed-organization-stamp.md rename to .changeset/pre/15195-metadata-protocol-seed-organization-stamp.md diff --git a/.changeset/15195-objectql-no-organization-refused.md b/.changeset/pre/15195-objectql-no-organization-refused.md similarity index 100% rename from .changeset/15195-objectql-no-organization-refused.md rename to .changeset/pre/15195-objectql-no-organization-refused.md diff --git a/.changeset/15195-plugin-auth-default-organization-boot-invariant.md b/.changeset/pre/15195-plugin-auth-default-organization-boot-invariant.md similarity index 100% rename from .changeset/15195-plugin-auth-default-organization-boot-invariant.md rename to .changeset/pre/15195-plugin-auth-default-organization-boot-invariant.md diff --git a/.changeset/15195-runtime-app-plugin-after-auth.md b/.changeset/pre/15195-runtime-app-plugin-after-auth.md similarity index 100% rename from .changeset/15195-runtime-app-plugin-after-auth.md rename to .changeset/pre/15195-runtime-app-plugin-after-auth.md diff --git a/.changeset/15195-verify-bootstack-production-single.md b/.changeset/pre/15195-verify-bootstack-production-single.md similarity index 100% rename from .changeset/15195-verify-bootstack-production-single.md rename to .changeset/pre/15195-verify-bootstack-production-single.md diff --git a/.changeset/15196-builtin-positions-declared-metadata.md b/.changeset/pre/15196-builtin-positions-declared-metadata.md similarity index 100% rename from .changeset/15196-builtin-positions-declared-metadata.md rename to .changeset/pre/15196-builtin-positions-declared-metadata.md diff --git a/.changeset/15196-core-security-catalog-read.md b/.changeset/pre/15196-core-security-catalog-read.md similarity index 100% rename from .changeset/15196-core-security-catalog-read.md rename to .changeset/pre/15196-core-security-catalog-read.md diff --git a/.changeset/15196-declared-positions-catalog-read.md b/.changeset/pre/15196-declared-positions-catalog-read.md similarity index 100% rename from .changeset/15196-declared-positions-catalog-read.md rename to .changeset/pre/15196-declared-positions-catalog-read.md diff --git a/.changeset/15196-grant-permission-set-name-backfill.md b/.changeset/pre/15196-grant-permission-set-name-backfill.md similarity index 100% rename from .changeset/15196-grant-permission-set-name-backfill.md rename to .changeset/pre/15196-grant-permission-set-name-backfill.md diff --git a/.changeset/15196-grant-permission-set-name-column.md b/.changeset/pre/15196-grant-permission-set-name-column.md similarity index 100% rename from .changeset/15196-grant-permission-set-name-column.md rename to .changeset/pre/15196-grant-permission-set-name-column.md diff --git a/.changeset/15196-grant-readers-by-name-plugin-auth.md b/.changeset/pre/15196-grant-readers-by-name-plugin-auth.md similarity index 100% rename from .changeset/15196-grant-readers-by-name-plugin-auth.md rename to .changeset/pre/15196-grant-readers-by-name-plugin-auth.md diff --git a/.changeset/15196-grant-readers-by-name-plugin-security.md b/.changeset/pre/15196-grant-readers-by-name-plugin-security.md similarity index 100% rename from .changeset/15196-grant-readers-by-name-plugin-security.md rename to .changeset/pre/15196-grant-readers-by-name-plugin-security.md diff --git a/.changeset/15196-position-environment-write-through.md b/.changeset/pre/15196-position-environment-write-through.md similarity index 100% rename from .changeset/15196-position-environment-write-through.md rename to .changeset/pre/15196-position-environment-write-through.md diff --git a/.changeset/15196-s10-position-namespace.md b/.changeset/pre/15196-s10-position-namespace.md similarity index 100% rename from .changeset/15196-s10-position-namespace.md rename to .changeset/pre/15196-s10-position-namespace.md diff --git a/.changeset/15196-s5a-resolver-grant-name.md b/.changeset/pre/15196-s5a-resolver-grant-name.md similarity index 100% rename from .changeset/15196-s5a-resolver-grant-name.md rename to .changeset/pre/15196-s5a-resolver-grant-name.md diff --git a/.changeset/15204-catalog-activation-environment-authored.md b/.changeset/pre/15204-catalog-activation-environment-authored.md similarity index 100% rename from .changeset/15204-catalog-activation-environment-authored.md rename to .changeset/pre/15204-catalog-activation-environment-authored.md diff --git a/.changeset/15204-grant-by-name-security.md b/.changeset/pre/15204-grant-by-name-security.md similarity index 100% rename from .changeset/15204-grant-by-name-security.md rename to .changeset/pre/15204-grant-by-name-security.md diff --git a/.changeset/15204-grant-by-name-spec-tsdoc.md b/.changeset/pre/15204-grant-by-name-spec-tsdoc.md similarity index 100% rename from .changeset/15204-grant-by-name-spec-tsdoc.md rename to .changeset/pre/15204-grant-by-name-spec-tsdoc.md diff --git a/.changeset/15204-grant-by-name-writers.md b/.changeset/pre/15204-grant-by-name-writers.md similarity index 100% rename from .changeset/15204-grant-by-name-writers.md rename to .changeset/pre/15204-grant-by-name-writers.md diff --git a/.changeset/15204-grant-name-pickers.md b/.changeset/pre/15204-grant-name-pickers.md similarity index 100% rename from .changeset/15204-grant-name-pickers.md rename to .changeset/pre/15204-grant-name-pickers.md diff --git a/.changeset/15204-permission-set-clone-door.md b/.changeset/pre/15204-permission-set-clone-door.md similarity index 100% rename from .changeset/15204-permission-set-clone-door.md rename to .changeset/pre/15204-permission-set-clone-door.md diff --git a/.changeset/15204-s1-position-permission-sets.md b/.changeset/pre/15204-s1-position-permission-sets.md similarity index 100% rename from .changeset/15204-s1-position-permission-sets.md rename to .changeset/pre/15204-s1-position-permission-sets.md diff --git a/.changeset/15204-s2a-security-readers.md b/.changeset/pre/15204-s2a-security-readers.md similarity index 100% rename from .changeset/15204-s2a-security-readers.md rename to .changeset/pre/15204-s2a-security-readers.md diff --git a/.changeset/15204-s2b-plugin-auth-catalog-readers.md b/.changeset/pre/15204-s2b-plugin-auth-catalog-readers.md similarity index 100% rename from .changeset/15204-s2b-plugin-auth-catalog-readers.md rename to .changeset/pre/15204-s2b-plugin-auth-catalog-readers.md diff --git a/.changeset/15204-s2b-plugin-sharing-ledger-deactivation.md b/.changeset/pre/15204-s2b-plugin-sharing-ledger-deactivation.md similarity index 100% rename from .changeset/15204-s2b-plugin-sharing-ledger-deactivation.md rename to .changeset/pre/15204-s2b-plugin-sharing-ledger-deactivation.md diff --git a/.changeset/15204-s2c-catalog-activation-door.md b/.changeset/pre/15204-s2c-catalog-activation-door.md similarity index 100% rename from .changeset/15204-s2c-catalog-activation-door.md rename to .changeset/pre/15204-s2c-catalog-activation-door.md diff --git a/.changeset/15204-s3-boot-report.md b/.changeset/pre/15204-s3-boot-report.md similarity index 100% rename from .changeset/15204-s3-boot-report.md rename to .changeset/pre/15204-s3-boot-report.md diff --git a/.changeset/15204-s6a-position-seeders.md b/.changeset/pre/15204-s6a-position-seeders.md similarity index 100% rename from .changeset/15204-s6a-position-seeders.md rename to .changeset/pre/15204-s6a-position-seeders.md diff --git a/.changeset/15204-s6b1a-capability-declarations.md b/.changeset/pre/15204-s6b1a-capability-declarations.md similarity index 100% rename from .changeset/15204-s6b1a-capability-declarations.md rename to .changeset/pre/15204-s6b1a-capability-declarations.md diff --git a/.changeset/15204-s6b1b-system-capability-seeder.md b/.changeset/pre/15204-s6b1b-system-capability-seeder.md similarity index 100% rename from .changeset/15204-s6b1b-system-capability-seeder.md rename to .changeset/pre/15204-s6b1b-system-capability-seeder.md diff --git a/.changeset/15204-s6b1c-declared-capability-seeder.md b/.changeset/pre/15204-s6b1c-declared-capability-seeder.md similarity index 100% rename from .changeset/15204-s6b1c-declared-capability-seeder.md rename to .changeset/pre/15204-s6b1c-declared-capability-seeder.md diff --git a/.changeset/15204-s6b2-declared-permission-seeders.md b/.changeset/pre/15204-s6b2-declared-permission-seeders.md similarity index 100% rename from .changeset/15204-s6b2-declared-permission-seeders.md rename to .changeset/pre/15204-s6b2-declared-permission-seeders.md diff --git a/.changeset/15204-s6b2a-meta-resync-example-binders.md b/.changeset/pre/15204-s6b2a-meta-resync-example-binders.md similarity index 100% rename from .changeset/15204-s6b2a-meta-resync-example-binders.md rename to .changeset/pre/15204-s6b2a-meta-resync-example-binders.md diff --git a/.changeset/15204-s6c-prep-organization-scope.md b/.changeset/pre/15204-s6c-prep-organization-scope.md similarity index 100% rename from .changeset/15204-s6c-prep-organization-scope.md rename to .changeset/pre/15204-s6c-prep-organization-scope.md diff --git a/.changeset/15204-s7pre-overlay-discard-retired.md b/.changeset/pre/15204-s7pre-overlay-discard-retired.md similarity index 100% rename from .changeset/15204-s7pre-overlay-discard-retired.md rename to .changeset/pre/15204-s7pre-overlay-discard-retired.md diff --git a/.changeset/15204-su2-contract-text.md b/.changeset/pre/15204-su2-contract-text.md similarity index 100% rename from .changeset/15204-su2-contract-text.md rename to .changeset/pre/15204-su2-contract-text.md diff --git a/.changeset/15204-su2-suggestion-acceptance.md b/.changeset/pre/15204-su2-suggestion-acceptance.md similarity index 100% rename from .changeset/15204-su2-suggestion-acceptance.md rename to .changeset/pre/15204-su2-suggestion-acceptance.md diff --git a/.changeset/15204-verify-rls-probe-registry-persona.md b/.changeset/pre/15204-verify-rls-probe-registry-persona.md similarity index 100% rename from .changeset/15204-verify-rls-probe-registry-persona.md rename to .changeset/pre/15204-verify-rls-probe-registry-persona.md diff --git a/.changeset/15204-walled-catalog-package-refusal.md b/.changeset/pre/15204-walled-catalog-package-refusal.md similarity index 100% rename from .changeset/15204-walled-catalog-package-refusal.md rename to .changeset/pre/15204-walled-catalog-package-refusal.md diff --git a/.changeset/15204-walled-catalog-package-verbs.md b/.changeset/pre/15204-walled-catalog-package-verbs.md similarity index 100% rename from .changeset/15204-walled-catalog-package-verbs.md rename to .changeset/pre/15204-walled-catalog-package-verbs.md diff --git a/.changeset/15204-walled-catalog-refusal.md b/.changeset/pre/15204-walled-catalog-refusal.md similarity index 100% rename from .changeset/15204-walled-catalog-refusal.md rename to .changeset/pre/15204-walled-catalog-refusal.md diff --git a/.changeset/15205-email-template-org-door-closed.md b/.changeset/pre/15205-email-template-org-door-closed.md similarity index 100% rename from .changeset/15205-email-template-org-door-closed.md rename to .changeset/pre/15205-email-template-org-door-closed.md diff --git a/.changeset/15205-email-templates-resolve-registry.md b/.changeset/pre/15205-email-templates-resolve-registry.md similarity index 100% rename from .changeset/15205-email-templates-resolve-registry.md rename to .changeset/pre/15205-email-templates-resolve-registry.md diff --git a/.changeset/15206-boot-report-carry-overs.md b/.changeset/pre/15206-boot-report-carry-overs.md similarity index 100% rename from .changeset/15206-boot-report-carry-overs.md rename to .changeset/pre/15206-boot-report-carry-overs.md diff --git a/.changeset/15206-managed-content-sealed.md b/.changeset/pre/15206-managed-content-sealed.md similarity index 100% rename from .changeset/15206-managed-content-sealed.md rename to .changeset/pre/15206-managed-content-sealed.md diff --git a/.changeset/15206-meta-doors-environment-only.md b/.changeset/pre/15206-meta-doors-environment-only.md similarity index 100% rename from .changeset/15206-meta-doors-environment-only.md rename to .changeset/pre/15206-meta-doors-environment-only.md diff --git a/.changeset/15206-protocol-environment-only.md b/.changeset/pre/15206-protocol-environment-only.md similarity index 100% rename from .changeset/15206-protocol-environment-only.md rename to .changeset/pre/15206-protocol-environment-only.md diff --git a/.changeset/15206-reads-environment-only.md b/.changeset/pre/15206-reads-environment-only.md similarity index 100% rename from .changeset/15206-reads-environment-only.md rename to .changeset/pre/15206-reads-environment-only.md diff --git a/.changeset/15206-sys-view-definition-retired.md b/.changeset/pre/15206-sys-view-definition-retired.md similarity index 100% rename from .changeset/15206-sys-view-definition-retired.md rename to .changeset/pre/15206-sys-view-definition-retired.md diff --git a/.changeset/15207-audit-log-attribution-field.md b/.changeset/pre/15207-audit-log-attribution-field.md similarity index 100% rename from .changeset/15207-audit-log-attribution-field.md rename to .changeset/pre/15207-audit-log-attribution-field.md diff --git a/.changeset/15207-deployment-plumbing-no-organization-column.md b/.changeset/pre/15207-deployment-plumbing-no-organization-column.md similarity index 100% rename from .changeset/15207-deployment-plumbing-no-organization-column.md rename to .changeset/pre/15207-deployment-plumbing-no-organization-column.md diff --git a/.changeset/15207-lifecycle-no-tenant-column-no-partition.md b/.changeset/pre/15207-lifecycle-no-tenant-column-no-partition.md similarity index 100% rename from .changeset/15207-lifecycle-no-tenant-column-no-partition.md rename to .changeset/pre/15207-lifecycle-no-tenant-column-no-partition.md diff --git a/.changeset/15207-platform-global-no-organization-column.md b/.changeset/pre/15207-platform-global-no-organization-column.md similarity index 100% rename from .changeset/15207-platform-global-no-organization-column.md rename to .changeset/pre/15207-platform-global-no-organization-column.md diff --git a/.changeset/15207-settings-global-rung-platform-setting.md b/.changeset/pre/15207-settings-global-rung-platform-setting.md similarity index 100% rename from .changeset/15207-settings-global-rung-platform-setting.md rename to .changeset/pre/15207-settings-global-rung-platform-setting.md diff --git a/.changeset/15213-install-modes.md b/.changeset/pre/15213-install-modes.md similarity index 100% rename from .changeset/15213-install-modes.md rename to .changeset/pre/15213-install-modes.md diff --git a/.changeset/15213-readonly-package-remedy.md b/.changeset/pre/15213-readonly-package-remedy.md similarity index 100% rename from .changeset/15213-readonly-package-remedy.md rename to .changeset/pre/15213-readonly-package-remedy.md diff --git a/.changeset/15213-unmanaged-import-verb.md b/.changeset/pre/15213-unmanaged-import-verb.md similarity index 100% rename from .changeset/15213-unmanaged-import-verb.md rename to .changeset/pre/15213-unmanaged-import-verb.md diff --git a/.changeset/15213-unmanaged-lock-strip.md b/.changeset/pre/15213-unmanaged-lock-strip.md similarity index 100% rename from .changeset/15213-unmanaged-lock-strip.md rename to .changeset/pre/15213-unmanaged-lock-strip.md diff --git a/.changeset/15952-blank-template-test-story.md b/.changeset/pre/15952-blank-template-test-story.md similarity index 100% rename from .changeset/15952-blank-template-test-story.md rename to .changeset/pre/15952-blank-template-test-story.md diff --git a/.changeset/15953-verify-dashboard-window-family.md b/.changeset/pre/15953-verify-dashboard-window-family.md similarity index 100% rename from .changeset/15953-verify-dashboard-window-family.md rename to .changeset/pre/15953-verify-dashboard-window-family.md diff --git a/.changeset/15953-verify-delete-behavior-family.md b/.changeset/pre/15953-verify-delete-behavior-family.md similarity index 100% rename from .changeset/15953-verify-delete-behavior-family.md rename to .changeset/pre/15953-verify-delete-behavior-family.md diff --git a/.changeset/15953-verify-parent-write-reach-family.md b/.changeset/pre/15953-verify-parent-write-reach-family.md similarity index 100% rename from .changeset/15953-verify-parent-write-reach-family.md rename to .changeset/pre/15953-verify-parent-write-reach-family.md diff --git a/.changeset/15953-verify-reference-shape-family.md b/.changeset/pre/15953-verify-reference-shape-family.md similarity index 100% rename from .changeset/15953-verify-reference-shape-family.md rename to .changeset/pre/15953-verify-reference-shape-family.md diff --git a/.changeset/15953-verify-unique-scope-family.md b/.changeset/pre/15953-verify-unique-scope-family.md similarity index 100% rename from .changeset/15953-verify-unique-scope-family.md rename to .changeset/pre/15953-verify-unique-scope-family.md diff --git a/.changeset/19939-flow-callee-input-value-slots.md b/.changeset/pre/19939-flow-callee-input-value-slots.md similarity index 100% rename from .changeset/19939-flow-callee-input-value-slots.md rename to .changeset/pre/19939-flow-callee-input-value-slots.md diff --git a/.changeset/19939-flow-collection-value-slots.md b/.changeset/pre/19939-flow-collection-value-slots.md similarity index 100% rename from .changeset/19939-flow-collection-value-slots.md rename to .changeset/pre/19939-flow-collection-value-slots.md diff --git a/.changeset/19939-flow-crud-filter-operands.md b/.changeset/pre/19939-flow-crud-filter-operands.md similarity index 100% rename from .changeset/19939-flow-crud-filter-operands.md rename to .changeset/pre/19939-flow-crud-filter-operands.md diff --git a/.changeset/19939-flow-http-slots.md b/.changeset/pre/19939-flow-http-slots.md similarity index 100% rename from .changeset/19939-flow-http-slots.md rename to .changeset/pre/19939-flow-http-slots.md diff --git a/.changeset/19939-flow-notify-value-slots.md b/.changeset/pre/19939-flow-notify-value-slots.md similarity index 100% rename from .changeset/19939-flow-notify-value-slots.md rename to .changeset/pre/19939-flow-notify-value-slots.md diff --git a/.changeset/19939-flow-screen-value-slots.md b/.changeset/pre/19939-flow-screen-value-slots.md similarity index 100% rename from .changeset/19939-flow-screen-value-slots.md rename to .changeset/pre/19939-flow-screen-value-slots.md diff --git a/.changeset/19939-flow-value-slot-date-macros-refused.md b/.changeset/pre/19939-flow-value-slot-date-macros-refused.md similarity index 100% rename from .changeset/19939-flow-value-slot-date-macros-refused.md rename to .changeset/pre/19939-flow-value-slot-date-macros-refused.md diff --git a/.changeset/19939-flow-value-slot-run-user-refused.md b/.changeset/pre/19939-flow-value-slot-run-user-refused.md similarity index 100% rename from .changeset/19939-flow-value-slot-run-user-refused.md rename to .changeset/pre/19939-flow-value-slot-run-user-refused.md diff --git a/.changeset/19939-flow-value-slot-template-dialect-refused.md b/.changeset/pre/19939-flow-value-slot-template-dialect-refused.md similarity index 100% rename from .changeset/19939-flow-value-slot-template-dialect-refused.md rename to .changeset/pre/19939-flow-value-slot-template-dialect-refused.md diff --git a/.changeset/20471-component-row-anchor-quotes.md b/.changeset/pre/20471-component-row-anchor-quotes.md similarity index 100% rename from .changeset/20471-component-row-anchor-quotes.md rename to .changeset/pre/20471-component-row-anchor-quotes.md diff --git a/.changeset/20471-component-row-anchor-requote.md b/.changeset/pre/20471-component-row-anchor-requote.md similarity index 100% rename from .changeset/20471-component-row-anchor-requote.md rename to .changeset/pre/20471-component-row-anchor-requote.md diff --git a/.changeset/20749-reader-needles-in-words.md b/.changeset/pre/20749-reader-needles-in-words.md similarity index 100% rename from .changeset/20749-reader-needles-in-words.md rename to .changeset/pre/20749-reader-needles-in-words.md diff --git a/.changeset/21886-add-member-platform-admin-visibility.md b/.changeset/pre/21886-add-member-platform-admin-visibility.md similarity index 100% rename from .changeset/21886-add-member-platform-admin-visibility.md rename to .changeset/pre/21886-add-member-platform-admin-visibility.md diff --git a/.changeset/21898-flow-builtin-node-config-values-refused.md b/.changeset/pre/21898-flow-builtin-node-config-values-refused.md similarity index 100% rename from .changeset/21898-flow-builtin-node-config-values-refused.md rename to .changeset/pre/21898-flow-builtin-node-config-values-refused.md diff --git a/.changeset/21903-platform-admin-affordance-visibility.md b/.changeset/pre/21903-platform-admin-affordance-visibility.md similarity index 100% rename from .changeset/21903-platform-admin-affordance-visibility.md rename to .changeset/pre/21903-platform-admin-affordance-visibility.md diff --git a/.changeset/21908-by-id-producers-opt-in.md b/.changeset/pre/21908-by-id-producers-opt-in.md similarity index 100% rename from .changeset/21908-by-id-producers-opt-in.md rename to .changeset/pre/21908-by-id-producers-opt-in.md diff --git a/.changeset/21908-principal-less-producers-final.md b/.changeset/pre/21908-principal-less-producers-final.md similarity index 100% rename from .changeset/21908-principal-less-producers-final.md rename to .changeset/pre/21908-principal-less-producers-final.md diff --git a/.changeset/21908-principal-less-strict-mode.md b/.changeset/pre/21908-principal-less-strict-mode.md similarity index 100% rename from .changeset/21908-principal-less-strict-mode.md rename to .changeset/pre/21908-principal-less-strict-mode.md diff --git a/.changeset/21922-metadata-protocol-meta-read-declines-code-datasource-row.md b/.changeset/pre/21922-metadata-protocol-meta-read-declines-code-datasource-row.md similarity index 100% rename from .changeset/21922-metadata-protocol-meta-read-declines-code-datasource-row.md rename to .changeset/pre/21922-metadata-protocol-meta-read-declines-code-datasource-row.md diff --git a/.changeset/21923-datasource-origin-from-provenance.md b/.changeset/pre/21923-datasource-origin-from-provenance.md similarity index 100% rename from .changeset/21923-datasource-origin-from-provenance.md rename to .changeset/pre/21923-datasource-origin-from-provenance.md diff --git a/.changeset/21967-view-expansion-per-package.md b/.changeset/pre/21967-view-expansion-per-package.md similarity index 100% rename from .changeset/21967-view-expansion-per-package.md rename to .changeset/pre/21967-view-expansion-per-package.md diff --git a/.changeset/21968-spec-default-agent-chat-door.md b/.changeset/pre/21968-spec-default-agent-chat-door.md similarity index 100% rename from .changeset/21968-spec-default-agent-chat-door.md rename to .changeset/pre/21968-spec-default-agent-chat-door.md diff --git a/.changeset/21972-caller-scoped-views-not-first.md b/.changeset/pre/21972-caller-scoped-views-not-first.md similarity index 100% rename from .changeset/21972-caller-scoped-views-not-first.md rename to .changeset/pre/21972-caller-scoped-views-not-first.md diff --git a/.changeset/21972-record-shares-all-first.md b/.changeset/pre/21972-record-shares-all-first.md similarity index 100% rename from .changeset/21972-record-shares-all-first.md rename to .changeset/pre/21972-record-shares-all-first.md diff --git a/.changeset/21973-nav-view-name-default.md b/.changeset/pre/21973-nav-view-name-default.md similarity index 100% rename from .changeset/21973-nav-view-name-default.md rename to .changeset/pre/21973-nav-view-name-default.md diff --git a/.changeset/21978-checksum-less-row-served-version.md b/.changeset/pre/21978-checksum-less-row-served-version.md similarity index 100% rename from .changeset/21978-checksum-less-row-served-version.md rename to .changeset/pre/21978-checksum-less-row-served-version.md diff --git a/.changeset/21980-copy-overlays-shipped-names.md b/.changeset/pre/21980-copy-overlays-shipped-names.md similarity index 100% rename from .changeset/21980-copy-overlays-shipped-names.md rename to .changeset/pre/21980-copy-overlays-shipped-names.md diff --git a/.changeset/21982-flow-builtin-node-config-undeclared-keys-refused.md b/.changeset/pre/21982-flow-builtin-node-config-undeclared-keys-refused.md similarity index 100% rename from .changeset/21982-flow-builtin-node-config-undeclared-keys-refused.md rename to .changeset/pre/21982-flow-builtin-node-config-undeclared-keys-refused.md diff --git a/.changeset/21982-flow-script-subflow-config-undeclared-keys-refused.md b/.changeset/pre/21982-flow-script-subflow-config-undeclared-keys-refused.md similarity index 100% rename from .changeset/21982-flow-script-subflow-config-undeclared-keys-refused.md rename to .changeset/pre/21982-flow-script-subflow-config-undeclared-keys-refused.md diff --git a/.changeset/21984-approval-requests-all-first.md b/.changeset/pre/21984-approval-requests-all-first.md similarity index 100% rename from .changeset/21984-approval-requests-all-first.md rename to .changeset/pre/21984-approval-requests-all-first.md diff --git a/.changeset/21986-metadata-protocol-declines-stored-row-public.md b/.changeset/pre/21986-metadata-protocol-declines-stored-row-public.md similarity index 100% rename from .changeset/21986-metadata-protocol-declines-stored-row-public.md rename to .changeset/pre/21986-metadata-protocol-declines-stored-row-public.md diff --git a/.changeset/21986-rest-published-door-code-datasource.md b/.changeset/pre/21986-rest-published-door-code-datasource.md similarity index 100% rename from .changeset/21986-rest-published-door-code-datasource.md rename to .changeset/pre/21986-rest-published-door-code-datasource.md diff --git a/.changeset/21986-runtime-published-door-code-datasource.md b/.changeset/pre/21986-runtime-published-door-code-datasource.md similarity index 100% rename from .changeset/21986-runtime-published-door-code-datasource.md rename to .changeset/pre/21986-runtime-published-door-code-datasource.md diff --git a/.changeset/21995-measure-column-aggregate.md b/.changeset/pre/21995-measure-column-aggregate.md similarity index 100% rename from .changeset/21995-measure-column-aggregate.md rename to .changeset/pre/21995-measure-column-aggregate.md diff --git a/.changeset/22012-isplatformadmin-standing.md b/.changeset/pre/22012-isplatformadmin-standing.md similarity index 100% rename from .changeset/22012-isplatformadmin-standing.md rename to .changeset/pre/22012-isplatformadmin-standing.md diff --git a/.changeset/22019-object-save-door-formula-verdict.md b/.changeset/pre/22019-object-save-door-formula-verdict.md similarity index 100% rename from .changeset/22019-object-save-door-formula-verdict.md rename to .changeset/pre/22019-object-save-door-formula-verdict.md diff --git a/.changeset/22019-objectql-formula-fault-log.md b/.changeset/pre/22019-objectql-formula-fault-log.md similarity index 100% rename from .changeset/22019-objectql-formula-fault-log.md rename to .changeset/pre/22019-objectql-formula-fault-log.md diff --git a/.changeset/22024-no-package-read-wears-served-package.md b/.changeset/pre/22024-no-package-read-wears-served-package.md similarity index 100% rename from .changeset/22024-no-package-read-wears-served-package.md rename to .changeset/pre/22024-no-package-read-wears-served-package.md diff --git a/.changeset/22026-read-receipt-recipient-only.md b/.changeset/pre/22026-read-receipt-recipient-only.md similarity index 100% rename from .changeset/22026-read-receipt-recipient-only.md rename to .changeset/pre/22026-read-receipt-recipient-only.md diff --git a/.changeset/22028-control-plane-url-docblock.md b/.changeset/pre/22028-control-plane-url-docblock.md similarity index 100% rename from .changeset/22028-control-plane-url-docblock.md rename to .changeset/pre/22028-control-plane-url-docblock.md diff --git a/.changeset/22032-object-save-door-action-predicates.md b/.changeset/pre/22032-object-save-door-action-predicates.md similarity index 100% rename from .changeset/22032-object-save-door-action-predicates.md rename to .changeset/pre/22032-object-save-door-action-predicates.md diff --git a/.changeset/22032-object-save-door-field-rule-slots.md b/.changeset/pre/22032-object-save-door-field-rule-slots.md similarity index 100% rename from .changeset/22032-object-save-door-field-rule-slots.md rename to .changeset/pre/22032-object-save-door-field-rule-slots.md diff --git a/.changeset/22032-object-save-door-option-visible-when.md b/.changeset/pre/22032-object-save-door-option-visible-when.md similarity index 100% rename from .changeset/22032-object-save-door-option-visible-when.md rename to .changeset/pre/22032-object-save-door-option-visible-when.md diff --git a/.changeset/22032-object-save-door-validation-predicates.md b/.changeset/pre/22032-object-save-door-validation-predicates.md similarity index 100% rename from .changeset/22032-object-save-door-validation-predicates.md rename to .changeset/pre/22032-object-save-door-validation-predicates.md diff --git a/.changeset/22042-nested-validation-predicate-verdict.md b/.changeset/pre/22042-nested-validation-predicate-verdict.md similarity index 100% rename from .changeset/22042-nested-validation-predicate-verdict.md rename to .changeset/pre/22042-nested-validation-predicate-verdict.md diff --git a/.changeset/22044-shared-entry-drops-conversion-table.md b/.changeset/pre/22044-shared-entry-drops-conversion-table.md similarity index 100% rename from .changeset/22044-shared-entry-drops-conversion-table.md rename to .changeset/pre/22044-shared-entry-drops-conversion-table.md diff --git a/.changeset/22046-upload-doors-uploader-only.md b/.changeset/pre/22046-upload-doors-uploader-only.md similarity index 100% rename from .changeset/22046-upload-doors-uploader-only.md rename to .changeset/pre/22046-upload-doors-uploader-only.md diff --git a/.changeset/22047-spec-ui-anonymous-form-intake.md b/.changeset/pre/22047-spec-ui-anonymous-form-intake.md similarity index 100% rename from .changeset/22047-spec-ui-anonymous-form-intake.md rename to .changeset/pre/22047-spec-ui-anonymous-form-intake.md diff --git a/.changeset/22049-share-password-header-encoding.md b/.changeset/pre/22049-share-password-header-encoding.md similarity index 100% rename from .changeset/22049-share-password-header-encoding.md rename to .changeset/pre/22049-share-password-header-encoding.md diff --git a/.changeset/22054-notify-title-template-input.md b/.changeset/pre/22054-notify-title-template-input.md similarity index 100% rename from .changeset/22054-notify-title-template-input.md rename to .changeset/pre/22054-notify-title-template-input.md diff --git a/.changeset/22057-bound-row-not-under-shared-bare-name.md b/.changeset/pre/22057-bound-row-not-under-shared-bare-name.md similarity index 100% rename from .changeset/22057-bound-row-not-under-shared-bare-name.md rename to .changeset/pre/22057-bound-row-not-under-shared-bare-name.md diff --git a/.changeset/22062-email-template-boot-sweep.md b/.changeset/pre/22062-email-template-boot-sweep.md similarity index 100% rename from .changeset/22062-email-template-boot-sweep.md rename to .changeset/pre/22062-email-template-boot-sweep.md diff --git a/.changeset/22067-seed-claim-skip-automations.md b/.changeset/pre/22067-seed-claim-skip-automations.md similarity index 100% rename from .changeset/22067-seed-claim-skip-automations.md rename to .changeset/pre/22067-seed-claim-skip-automations.md diff --git a/.changeset/22070-audit-stamp-under-skip-automations.md b/.changeset/pre/22070-audit-stamp-under-skip-automations.md similarity index 100% rename from .changeset/22070-audit-stamp-under-skip-automations.md rename to .changeset/pre/22070-audit-stamp-under-skip-automations.md diff --git a/.changeset/22071-runtime-assets-boot-warning.md b/.changeset/pre/22071-runtime-assets-boot-warning.md similarity index 100% rename from .changeset/22071-runtime-assets-boot-warning.md rename to .changeset/pre/22071-runtime-assets-boot-warning.md diff --git a/.changeset/22072-migrate-meta-relevance-predicate.md b/.changeset/pre/22072-migrate-meta-relevance-predicate.md similarity index 100% rename from .changeset/22072-migrate-meta-relevance-predicate.md rename to .changeset/pre/22072-migrate-meta-relevance-predicate.md diff --git a/.changeset/22073-boot-warning-one-line-per-class.md b/.changeset/pre/22073-boot-warning-one-line-per-class.md similarity index 100% rename from .changeset/22073-boot-warning-one-line-per-class.md rename to .changeset/pre/22073-boot-warning-one-line-per-class.md diff --git a/.changeset/22074-auth-identity-objects-plugin.md b/.changeset/pre/22074-auth-identity-objects-plugin.md similarity index 100% rename from .changeset/22074-auth-identity-objects-plugin.md rename to .changeset/pre/22074-auth-identity-objects-plugin.md diff --git a/.changeset/22074-security-identity-objects-boot-refusal.md b/.changeset/pre/22074-security-identity-objects-boot-refusal.md similarity index 100% rename from .changeset/22074-security-identity-objects-boot-refusal.md rename to .changeset/pre/22074-security-identity-objects-boot-refusal.md diff --git a/.changeset/22079-forms-slug-redirect.md b/.changeset/pre/22079-forms-slug-redirect.md similarity index 100% rename from .changeset/22079-forms-slug-redirect.md rename to .changeset/pre/22079-forms-slug-redirect.md diff --git a/.changeset/22079-publiclink-describe.md b/.changeset/pre/22079-publiclink-describe.md similarity index 100% rename from .changeset/22079-publiclink-describe.md rename to .changeset/pre/22079-publiclink-describe.md diff --git a/.changeset/22080-v18-line-opens.md b/.changeset/pre/22080-v18-line-opens.md similarity index 100% rename from .changeset/22080-v18-line-opens.md rename to .changeset/pre/22080-v18-line-opens.md diff --git a/.changeset/22081-notify-refusal-single-brace.md b/.changeset/pre/22081-notify-refusal-single-brace.md similarity index 100% rename from .changeset/22081-notify-refusal-single-brace.md rename to .changeset/pre/22081-notify-refusal-single-brace.md diff --git a/.changeset/22088-flow-edge-unresolved-or-repeated-refused.md b/.changeset/pre/22088-flow-edge-unresolved-or-repeated-refused.md similarity index 100% rename from .changeset/22088-flow-edge-unresolved-or-repeated-refused.md rename to .changeset/pre/22088-flow-edge-unresolved-or-repeated-refused.md diff --git a/.changeset/22090-metadata-protocol-revert-stored-package.md b/.changeset/pre/22090-metadata-protocol-revert-stored-package.md similarity index 100% rename from .changeset/22090-metadata-protocol-revert-stored-package.md rename to .changeset/pre/22090-metadata-protocol-revert-stored-package.md diff --git a/.changeset/22090-runtime-revert-studio-package.md b/.changeset/pre/22090-runtime-revert-studio-package.md similarity index 100% rename from .changeset/22090-runtime-revert-studio-package.md rename to .changeset/pre/22090-runtime-revert-studio-package.md diff --git a/.changeset/22093-author-strings-internal-refs.md b/.changeset/pre/22093-author-strings-internal-refs.md similarity index 100% rename from .changeset/22093-author-strings-internal-refs.md rename to .changeset/pre/22093-author-strings-internal-refs.md diff --git a/.changeset/22093-submit-behavior-help-no-ruling-date.md b/.changeset/pre/22093-submit-behavior-help-no-ruling-date.md similarity index 100% rename from .changeset/22093-submit-behavior-help-no-ruling-date.md rename to .changeset/pre/22093-submit-behavior-help-no-ruling-date.md diff --git a/.changeset/22093-sys-email-field-help-product-words.md b/.changeset/pre/22093-sys-email-field-help-product-words.md similarity index 100% rename from .changeset/22093-sys-email-field-help-product-words.md rename to .changeset/pre/22093-sys-email-field-help-product-words.md diff --git a/.changeset/22099-owner-bind-in-flight.md b/.changeset/pre/22099-owner-bind-in-flight.md similarity index 100% rename from .changeset/22099-owner-bind-in-flight.md rename to .changeset/pre/22099-owner-bind-in-flight.md diff --git a/.changeset/22110-flow-text-slot-double-brace.md b/.changeset/pre/22110-flow-text-slot-double-brace.md similarity index 100% rename from .changeset/22110-flow-text-slot-double-brace.md rename to .changeset/pre/22110-flow-text-slot-double-brace.md diff --git a/.changeset/22113-revert-package-code-shipped-members.md b/.changeset/pre/22113-revert-package-code-shipped-members.md similarity index 100% rename from .changeset/22113-revert-package-code-shipped-members.md rename to .changeset/pre/22113-revert-package-code-shipped-members.md diff --git a/.changeset/22114-meta-read-version-token.md b/.changeset/pre/22114-meta-read-version-token.md similarity index 100% rename from .changeset/22114-meta-read-version-token.md rename to .changeset/pre/22114-meta-read-version-token.md diff --git a/.changeset/22116-migrate-meta-out-snapshot.md b/.changeset/pre/22116-migrate-meta-out-snapshot.md similarity index 100% rename from .changeset/22116-migrate-meta-out-snapshot.md rename to .changeset/pre/22116-migrate-meta-out-snapshot.md diff --git a/.changeset/22118-gate-baseline-stored-self.md b/.changeset/pre/22118-gate-baseline-stored-self.md similarity index 100% rename from .changeset/22118-gate-baseline-stored-self.md rename to .changeset/pre/22118-gate-baseline-stored-self.md diff --git a/.changeset/22128-draft-head-resolution.md b/.changeset/pre/22128-draft-head-resolution.md similarity index 100% rename from .changeset/22128-draft-head-resolution.md rename to .changeset/pre/22128-draft-head-resolution.md diff --git a/.changeset/22130-protocol-18.md b/.changeset/pre/22130-protocol-18.md similarity index 100% rename from .changeset/22130-protocol-18.md rename to .changeset/pre/22130-protocol-18.md diff --git a/.changeset/22135-security-catalog-one-holder.md b/.changeset/pre/22135-security-catalog-one-holder.md similarity index 100% rename from .changeset/22135-security-catalog-one-holder.md rename to .changeset/pre/22135-security-catalog-one-holder.md diff --git a/.changeset/22136-settings-timezone-help-plain.md b/.changeset/pre/22136-settings-timezone-help-plain.md similarity index 100% rename from .changeset/22136-settings-timezone-help-plain.md rename to .changeset/pre/22136-settings-timezone-help-plain.md diff --git a/.changeset/22141-rest-meta-save-request-options.md b/.changeset/pre/22141-rest-meta-save-request-options.md similarity index 100% rename from .changeset/22141-rest-meta-save-request-options.md rename to .changeset/pre/22141-rest-meta-save-request-options.md diff --git a/.changeset/22141-runtime-meta-save-preconditions.md b/.changeset/pre/22141-runtime-meta-save-preconditions.md similarity index 100% rename from .changeset/22141-runtime-meta-save-preconditions.md rename to .changeset/pre/22141-runtime-meta-save-preconditions.md diff --git a/.changeset/22149-define-seed-record-keys.md b/.changeset/pre/22149-define-seed-record-keys.md similarity index 100% rename from .changeset/22149-define-seed-record-keys.md rename to .changeset/pre/22149-define-seed-record-keys.md diff --git a/.changeset/22150-scaffold-dockerfile-lockfile.md b/.changeset/pre/22150-scaffold-dockerfile-lockfile.md similarity index 100% rename from .changeset/22150-scaffold-dockerfile-lockfile.md rename to .changeset/pre/22150-scaffold-dockerfile-lockfile.md diff --git a/.changeset/22154-analytics-security-absence-info.md b/.changeset/pre/22154-analytics-security-absence-info.md similarity index 100% rename from .changeset/22154-analytics-security-absence-info.md rename to .changeset/pre/22154-analytics-security-absence-info.md diff --git a/.changeset/22155-console-dist-remedy.md b/.changeset/pre/22155-console-dist-remedy.md similarity index 100% rename from .changeset/22155-console-dist-remedy.md rename to .changeset/pre/22155-console-dist-remedy.md diff --git a/.changeset/22156-cli-port-help-and-init-next-steps.md b/.changeset/pre/22156-cli-port-help-and-init-next-steps.md similarity index 100% rename from .changeset/22156-cli-port-help-and-init-next-steps.md rename to .changeset/pre/22156-cli-port-help-and-init-next-steps.md diff --git a/.changeset/22157-option-visible-when-parent.md b/.changeset/pre/22157-option-visible-when-parent.md similarity index 100% rename from .changeset/22157-option-visible-when-parent.md rename to .changeset/pre/22157-option-visible-when-parent.md diff --git a/.changeset/22158-print-page-spec.md b/.changeset/pre/22158-print-page-spec.md similarity index 100% rename from .changeset/22158-print-page-spec.md rename to .changeset/pre/22158-print-page-spec.md diff --git a/.changeset/22160-boot-diagnostics-noise-budget.md b/.changeset/pre/22160-boot-diagnostics-noise-budget.md similarity index 100% rename from .changeset/22160-boot-diagnostics-noise-budget.md rename to .changeset/pre/22160-boot-diagnostics-noise-budget.md diff --git a/.changeset/22161-functional-completeness-one-line.md b/.changeset/pre/22161-functional-completeness-one-line.md similarity index 100% rename from .changeset/22161-functional-completeness-one-line.md rename to .changeset/pre/22161-functional-completeness-one-line.md diff --git a/.changeset/22161-lint-slice-10-one-line.md b/.changeset/pre/22161-lint-slice-10-one-line.md similarity index 100% rename from .changeset/22161-lint-slice-10-one-line.md rename to .changeset/pre/22161-lint-slice-10-one-line.md diff --git a/.changeset/22161-lint-slice-11-one-line-fix.md b/.changeset/pre/22161-lint-slice-11-one-line-fix.md similarity index 100% rename from .changeset/22161-lint-slice-11-one-line-fix.md rename to .changeset/pre/22161-lint-slice-11-one-line-fix.md diff --git a/.changeset/22161-lint-slice-12-one-line-fix.md b/.changeset/pre/22161-lint-slice-12-one-line-fix.md similarity index 100% rename from .changeset/22161-lint-slice-12-one-line-fix.md rename to .changeset/pre/22161-lint-slice-12-one-line-fix.md diff --git a/.changeset/22161-lint-slice-13-one-line-fix.md b/.changeset/pre/22161-lint-slice-13-one-line-fix.md similarity index 100% rename from .changeset/22161-lint-slice-13-one-line-fix.md rename to .changeset/pre/22161-lint-slice-13-one-line-fix.md diff --git a/.changeset/22161-lint-slice-14-one-line-fix.md b/.changeset/pre/22161-lint-slice-14-one-line-fix.md similarity index 100% rename from .changeset/22161-lint-slice-14-one-line-fix.md rename to .changeset/pre/22161-lint-slice-14-one-line-fix.md diff --git a/.changeset/22161-lint-slice-15-one-line-fix.md b/.changeset/pre/22161-lint-slice-15-one-line-fix.md similarity index 100% rename from .changeset/22161-lint-slice-15-one-line-fix.md rename to .changeset/pre/22161-lint-slice-15-one-line-fix.md diff --git a/.changeset/22161-lint-slice-2-one-line.md b/.changeset/pre/22161-lint-slice-2-one-line.md similarity index 100% rename from .changeset/22161-lint-slice-2-one-line.md rename to .changeset/pre/22161-lint-slice-2-one-line.md diff --git a/.changeset/22161-lint-slice-3-one-line.md b/.changeset/pre/22161-lint-slice-3-one-line.md similarity index 100% rename from .changeset/22161-lint-slice-3-one-line.md rename to .changeset/pre/22161-lint-slice-3-one-line.md diff --git a/.changeset/22161-lint-slice-4-one-line.md b/.changeset/pre/22161-lint-slice-4-one-line.md similarity index 100% rename from .changeset/22161-lint-slice-4-one-line.md rename to .changeset/pre/22161-lint-slice-4-one-line.md diff --git a/.changeset/22161-lint-slice-5-one-line.md b/.changeset/pre/22161-lint-slice-5-one-line.md similarity index 100% rename from .changeset/22161-lint-slice-5-one-line.md rename to .changeset/pre/22161-lint-slice-5-one-line.md diff --git a/.changeset/22161-lint-slice-6-one-line.md b/.changeset/pre/22161-lint-slice-6-one-line.md similarity index 100% rename from .changeset/22161-lint-slice-6-one-line.md rename to .changeset/pre/22161-lint-slice-6-one-line.md diff --git a/.changeset/22161-lint-slice-7-one-line.md b/.changeset/pre/22161-lint-slice-7-one-line.md similarity index 100% rename from .changeset/22161-lint-slice-7-one-line.md rename to .changeset/pre/22161-lint-slice-7-one-line.md diff --git a/.changeset/22161-lint-slice-8-one-line.md b/.changeset/pre/22161-lint-slice-8-one-line.md similarity index 100% rename from .changeset/22161-lint-slice-8-one-line.md rename to .changeset/pre/22161-lint-slice-8-one-line.md diff --git a/.changeset/22161-lint-slice-9-one-line.md b/.changeset/pre/22161-lint-slice-9-one-line.md similarity index 100% rename from .changeset/22161-lint-slice-9-one-line.md rename to .changeset/pre/22161-lint-slice-9-one-line.md diff --git a/.changeset/22161-rule-message-one-line.md b/.changeset/pre/22161-rule-message-one-line.md similarity index 100% rename from .changeset/22161-rule-message-one-line.md rename to .changeset/pre/22161-rule-message-one-line.md diff --git a/.changeset/22161-spec-approver-type-describe.md b/.changeset/pre/22161-spec-approver-type-describe.md similarity index 100% rename from .changeset/22161-spec-approver-type-describe.md rename to .changeset/pre/22161-spec-approver-type-describe.md diff --git a/.changeset/22162-scaffold-workspace-minimal.md b/.changeset/pre/22162-scaffold-workspace-minimal.md similarity index 100% rename from .changeset/22162-scaffold-workspace-minimal.md rename to .changeset/pre/22162-scaffold-workspace-minimal.md diff --git a/.changeset/22163-doctor-node-env-source-posture.md b/.changeset/pre/22163-doctor-node-env-source-posture.md similarity index 100% rename from .changeset/22163-doctor-node-env-source-posture.md rename to .changeset/pre/22163-doctor-node-env-source-posture.md diff --git a/.changeset/22168-settings-user-rung-owner.md b/.changeset/pre/22168-settings-user-rung-owner.md similarity index 100% rename from .changeset/22168-settings-user-rung-owner.md rename to .changeset/pre/22168-settings-user-rung-owner.md diff --git a/.changeset/22169-permission-set-heal-converges.md b/.changeset/pre/22169-permission-set-heal-converges.md similarity index 100% rename from .changeset/22169-permission-set-heal-converges.md rename to .changeset/pre/22169-permission-set-heal-converges.md diff --git a/.changeset/22175-upload-org-change-4xx.md b/.changeset/pre/22175-upload-org-change-4xx.md similarity index 100% rename from .changeset/22175-upload-org-change-4xx.md rename to .changeset/pre/22175-upload-org-change-4xx.md diff --git a/.changeset/22178-analytics-picklist-dimension-labels.md b/.changeset/pre/22178-analytics-picklist-dimension-labels.md similarity index 100% rename from .changeset/22178-analytics-picklist-dimension-labels.md rename to .changeset/pre/22178-analytics-picklist-dimension-labels.md diff --git a/.changeset/22182-approval-approve-description.md b/.changeset/pre/22182-approval-approve-description.md similarity index 100% rename from .changeset/22182-approval-approve-description.md rename to .changeset/pre/22182-approval-approve-description.md diff --git a/.changeset/22182-invite-user-action-copy.md b/.changeset/pre/22182-invite-user-action-copy.md similarity index 100% rename from .changeset/22182-invite-user-action-copy.md rename to .changeset/pre/22182-invite-user-action-copy.md diff --git a/.changeset/22183-import-kept-option-values.md b/.changeset/pre/22183-import-kept-option-values.md similarity index 100% rename from .changeset/22183-import-kept-option-values.md rename to .changeset/pre/22183-import-kept-option-values.md diff --git a/.changeset/22188-rest-meta-package-all-fold.md b/.changeset/pre/22188-rest-meta-package-all-fold.md similarity index 100% rename from .changeset/22188-rest-meta-package-all-fold.md rename to .changeset/pre/22188-rest-meta-package-all-fold.md diff --git a/.changeset/22188-runtime-meta-package-all-fold.md b/.changeset/pre/22188-runtime-meta-package-all-fold.md similarity index 100% rename from .changeset/22188-runtime-meta-package-all-fold.md rename to .changeset/pre/22188-runtime-meta-package-all-fold.md diff --git a/.changeset/22190-flat-docs-owner-package.md b/.changeset/pre/22190-flat-docs-owner-package.md similarity index 100% rename from .changeset/22190-flat-docs-owner-package.md rename to .changeset/pre/22190-flat-docs-owner-package.md diff --git a/.changeset/22200-drafts-header-label.md b/.changeset/pre/22200-drafts-header-label.md similarity index 100% rename from .changeset/22200-drafts-header-label.md rename to .changeset/pre/22200-drafts-header-label.md diff --git a/.changeset/22201-granted-by-provenance.md b/.changeset/pre/22201-granted-by-provenance.md similarity index 100% rename from .changeset/22201-granted-by-provenance.md rename to .changeset/pre/22201-granted-by-provenance.md diff --git a/.changeset/22202-migrate-exit-schema-parity.md b/.changeset/pre/22202-migrate-exit-schema-parity.md similarity index 100% rename from .changeset/22202-migrate-exit-schema-parity.md rename to .changeset/pre/22202-migrate-exit-schema-parity.md diff --git a/.changeset/22203-position-package-door.md b/.changeset/pre/22203-position-package-door.md similarity index 100% rename from .changeset/22203-position-package-door.md rename to .changeset/pre/22203-position-package-door.md diff --git a/.changeset/22212-lint-sibling-spellings.md b/.changeset/pre/22212-lint-sibling-spellings.md similarity index 100% rename from .changeset/22212-lint-sibling-spellings.md rename to .changeset/pre/22212-lint-sibling-spellings.md diff --git a/.changeset/22218-chunk-progress-org-change-4xx.md b/.changeset/pre/22218-chunk-progress-org-change-4xx.md similarity index 100% rename from .changeset/22218-chunk-progress-org-change-4xx.md rename to .changeset/pre/22218-chunk-progress-org-change-4xx.md diff --git a/.changeset/22219-migrate-meta-write-range.md b/.changeset/pre/22219-migrate-meta-write-range.md similarity index 100% rename from .changeset/22219-migrate-meta-write-range.md rename to .changeset/pre/22219-migrate-meta-write-range.md diff --git a/.changeset/22220-package-door-before-gates.md b/.changeset/pre/22220-package-door-before-gates.md similarity index 100% rename from .changeset/22220-package-door-before-gates.md rename to .changeset/pre/22220-package-door-before-gates.md diff --git a/.changeset/22225-console-not-built-loud.md b/.changeset/pre/22225-console-not-built-loud.md similarity index 100% rename from .changeset/22225-console-not-built-loud.md rename to .changeset/pre/22225-console-not-built-loud.md diff --git a/.changeset/22226-grant-holder-must-be-organization-member.md b/.changeset/pre/22226-grant-holder-must-be-organization-member.md similarity index 100% rename from .changeset/22226-grant-holder-must-be-organization-member.md rename to .changeset/pre/22226-grant-holder-must-be-organization-member.md diff --git a/.changeset/22227-field-deadline-due-like.md b/.changeset/pre/22227-field-deadline-due-like.md similarity index 100% rename from .changeset/22227-field-deadline-due-like.md rename to .changeset/pre/22227-field-deadline-due-like.md diff --git a/.changeset/22228-field-conditional-formatting.md b/.changeset/pre/22228-field-conditional-formatting.md similarity index 100% rename from .changeset/22228-field-conditional-formatting.md rename to .changeset/pre/22228-field-conditional-formatting.md diff --git a/.changeset/22237-console-asset-cache.md b/.changeset/pre/22237-console-asset-cache.md similarity index 100% rename from .changeset/22237-console-asset-cache.md rename to .changeset/pre/22237-console-asset-cache.md diff --git a/.changeset/22238-package-union-readers.md b/.changeset/pre/22238-package-union-readers.md similarity index 100% rename from .changeset/22238-package-union-readers.md rename to .changeset/pre/22238-package-union-readers.md diff --git a/.changeset/22249-doctor-env-files-loader.md b/.changeset/pre/22249-doctor-env-files-loader.md similarity index 100% rename from .changeset/22249-doctor-env-files-loader.md rename to .changeset/pre/22249-doctor-env-files-loader.md diff --git a/.changeset/22250-meta-fls-runtime-sortability.md b/.changeset/pre/22250-meta-fls-runtime-sortability.md similarity index 100% rename from .changeset/22250-meta-fls-runtime-sortability.md rename to .changeset/pre/22250-meta-fls-runtime-sortability.md diff --git a/.changeset/22255-policy-unbound-info-glyph.md b/.changeset/pre/22255-policy-unbound-info-glyph.md similarity index 100% rename from .changeset/22255-policy-unbound-info-glyph.md rename to .changeset/pre/22255-policy-unbound-info-glyph.md diff --git a/.changeset/22256-composed-load-path-conversions.md b/.changeset/pre/22256-composed-load-path-conversions.md similarity index 100% rename from .changeset/22256-composed-load-path-conversions.md rename to .changeset/pre/22256-composed-load-path-conversions.md diff --git a/.changeset/22256-migrate-meta-load-path-conversions.md b/.changeset/pre/22256-migrate-meta-load-path-conversions.md similarity index 100% rename from .changeset/22256-migrate-meta-load-path-conversions.md rename to .changeset/pre/22256-migrate-meta-load-path-conversions.md diff --git a/.changeset/22257-auth-settings-read-after-bind.md b/.changeset/pre/22257-auth-settings-read-after-bind.md similarity index 100% rename from .changeset/22257-auth-settings-read-after-bind.md rename to .changeset/pre/22257-auth-settings-read-after-bind.md diff --git a/.changeset/22258-in-process-session-read-no-renewal-behind-cookie.md b/.changeset/pre/22258-in-process-session-read-no-renewal-behind-cookie.md similarity index 100% rename from .changeset/22258-in-process-session-read-no-renewal-behind-cookie.md rename to .changeset/pre/22258-in-process-session-read-no-renewal-behind-cookie.md diff --git a/.changeset/22258-services-in-process-session-read.md b/.changeset/pre/22258-services-in-process-session-read.md similarity index 100% rename from .changeset/22258-services-in-process-session-read.md rename to .changeset/pre/22258-services-in-process-session-read.md diff --git a/.changeset/22261-settings-organization-row-identity.md b/.changeset/pre/22261-settings-organization-row-identity.md similarity index 100% rename from .changeset/22261-settings-organization-row-identity.md rename to .changeset/pre/22261-settings-organization-row-identity.md diff --git a/.changeset/22272-kernel-boot-phase-hooks-option.md b/.changeset/pre/22272-kernel-boot-phase-hooks-option.md similarity index 100% rename from .changeset/22272-kernel-boot-phase-hooks-option.md rename to .changeset/pre/22272-kernel-boot-phase-hooks-option.md diff --git a/.changeset/22274-option-visible-when-members.md b/.changeset/pre/22274-option-visible-when-members.md similarity index 100% rename from .changeset/22274-option-visible-when-members.md rename to .changeset/pre/22274-option-visible-when-members.md diff --git a/.changeset/22277-cel-timestamp-string.md b/.changeset/pre/22277-cel-timestamp-string.md similarity index 100% rename from .changeset/22277-cel-timestamp-string.md rename to .changeset/pre/22277-cel-timestamp-string.md diff --git a/.changeset/22278-tenant-wall-judges-emptied-organization.md b/.changeset/pre/22278-tenant-wall-judges-emptied-organization.md similarity index 100% rename from .changeset/22278-tenant-wall-judges-emptied-organization.md rename to .changeset/pre/22278-tenant-wall-judges-emptied-organization.md diff --git a/.changeset/22279-analytics-absent-security-comments.md b/.changeset/pre/22279-analytics-absent-security-comments.md similarity index 100% rename from .changeset/22279-analytics-absent-security-comments.md rename to .changeset/pre/22279-analytics-absent-security-comments.md diff --git a/.changeset/22283-storage-limits-honoured.md b/.changeset/pre/22283-storage-limits-honoured.md similarity index 100% rename from .changeset/22283-storage-limits-honoured.md rename to .changeset/pre/22283-storage-limits-honoured.md diff --git a/.changeset/22286-kernel-signal-listeners.md b/.changeset/pre/22286-kernel-signal-listeners.md similarity index 100% rename from .changeset/22286-kernel-signal-listeners.md rename to .changeset/pre/22286-kernel-signal-listeners.md diff --git a/.changeset/22288-cli-package-owned-readers.md b/.changeset/pre/22288-cli-package-owned-readers.md similarity index 100% rename from .changeset/22288-cli-package-owned-readers.md rename to .changeset/pre/22288-cli-package-owned-readers.md diff --git a/.changeset/22289-migrate-meta-composed-project.md b/.changeset/pre/22289-migrate-meta-composed-project.md similarity index 100% rename from .changeset/22289-migrate-meta-composed-project.md rename to .changeset/pre/22289-migrate-meta-composed-project.md diff --git a/.changeset/22290-value-slot-remedy-cel-claimed-head.md b/.changeset/pre/22290-value-slot-remedy-cel-claimed-head.md similarity index 100% rename from .changeset/22290-value-slot-remedy-cel-claimed-head.md rename to .changeset/pre/22290-value-slot-remedy-cel-claimed-head.md diff --git a/.changeset/22300-formula-projection-trim.md b/.changeset/pre/22300-formula-projection-trim.md similarity index 100% rename from .changeset/22300-formula-projection-trim.md rename to .changeset/pre/22300-formula-projection-trim.md diff --git a/.changeset/22301-seed-temporal-stored-form.md b/.changeset/pre/22301-seed-temporal-stored-form.md similarity index 100% rename from .changeset/22301-seed-temporal-stored-form.md rename to .changeset/pre/22301-seed-temporal-stored-form.md diff --git a/.changeset/22301-spec-ledger-verify-provenance.md b/.changeset/pre/22301-spec-ledger-verify-provenance.md similarity index 100% rename from .changeset/22301-spec-ledger-verify-provenance.md rename to .changeset/pre/22301-spec-ledger-verify-provenance.md diff --git a/.changeset/22301-verify-boots-the-served-slate.md b/.changeset/pre/22301-verify-boots-the-served-slate.md similarity index 100% rename from .changeset/22301-verify-boots-the-served-slate.md rename to .changeset/pre/22301-verify-boots-the-served-slate.md diff --git a/.changeset/22301-verify-boots-what-serve-boots.md b/.changeset/pre/22301-verify-boots-what-serve-boots.md similarity index 100% rename from .changeset/22301-verify-boots-what-serve-boots.md rename to .changeset/pre/22301-verify-boots-what-serve-boots.md diff --git a/.changeset/22301-verify-evaluate-condition-door.md b/.changeset/pre/22301-verify-evaluate-condition-door.md similarity index 100% rename from .changeset/22301-verify-evaluate-condition-door.md rename to .changeset/pre/22301-verify-evaluate-condition-door.md diff --git a/.changeset/22301-verify-lowered-body-door.md b/.changeset/pre/22301-verify-lowered-body-door.md similarity index 100% rename from .changeset/22301-verify-lowered-body-door.md rename to .changeset/pre/22301-verify-lowered-body-door.md diff --git a/.changeset/22301-verify-observe-writes-door.md b/.changeset/pre/22301-verify-observe-writes-door.md similarity index 100% rename from .changeset/22301-verify-observe-writes-door.md rename to .changeset/pre/22301-verify-observe-writes-door.md diff --git a/.changeset/22301-verify-public-form-door.md b/.changeset/pre/22301-verify-public-form-door.md similarity index 100% rename from .changeset/22301-verify-public-form-door.md rename to .changeset/pre/22301-verify-public-form-door.md diff --git a/.changeset/22301-verify-system-insert-delete-door.md b/.changeset/pre/22301-verify-system-insert-delete-door.md similarity index 100% rename from .changeset/22301-verify-system-insert-delete-door.md rename to .changeset/pre/22301-verify-system-insert-delete-door.md diff --git a/.changeset/22301-verify-update-doors.md b/.changeset/pre/22301-verify-update-doors.md similarity index 100% rename from .changeset/22301-verify-update-doors.md rename to .changeset/pre/22301-verify-update-doors.md diff --git a/.changeset/22302-security-service-principal-less-text.md b/.changeset/pre/22302-security-service-principal-less-text.md similarity index 100% rename from .changeset/22302-security-service-principal-less-text.md rename to .changeset/pre/22302-security-service-principal-less-text.md diff --git a/.changeset/22305-cascade-sibling-restrict.md b/.changeset/pre/22305-cascade-sibling-restrict.md similarity index 100% rename from .changeset/22305-cascade-sibling-restrict.md rename to .changeset/pre/22305-cascade-sibling-restrict.md diff --git a/.changeset/22306-insert-readonly-withheld-from-before-insert.md b/.changeset/pre/22306-insert-readonly-withheld-from-before-insert.md similarity index 100% rename from .changeset/22306-insert-readonly-withheld-from-before-insert.md rename to .changeset/pre/22306-insert-readonly-withheld-from-before-insert.md diff --git a/.changeset/22307-cold-boot-catalog-refusal.md b/.changeset/pre/22307-cold-boot-catalog-refusal.md similarity index 100% rename from .changeset/22307-cold-boot-catalog-refusal.md rename to .changeset/pre/22307-cold-boot-catalog-refusal.md diff --git a/.changeset/22310-elevated-flow-trigger-door.md b/.changeset/pre/22310-elevated-flow-trigger-door.md similarity index 100% rename from .changeset/22310-elevated-flow-trigger-door.md rename to .changeset/pre/22310-elevated-flow-trigger-door.md diff --git a/.changeset/22313-chunked-completion-assembles-held-parts.md b/.changeset/pre/22313-chunked-completion-assembles-held-parts.md similarity index 100% rename from .changeset/22313-chunked-completion-assembles-held-parts.md rename to .changeset/pre/22313-chunked-completion-assembles-held-parts.md diff --git a/.changeset/22314-storage-payload-too-large-provenance.md b/.changeset/pre/22314-storage-payload-too-large-provenance.md similarity index 100% rename from .changeset/22314-storage-payload-too-large-provenance.md rename to .changeset/pre/22314-storage-payload-too-large-provenance.md diff --git a/.changeset/22318-audit-plugin-host-locale.md b/.changeset/pre/22318-audit-plugin-host-locale.md similarity index 100% rename from .changeset/22318-audit-plugin-host-locale.md rename to .changeset/pre/22318-audit-plugin-host-locale.md diff --git a/.changeset/22328-auth-seeded-hook-registered-at-ready.md b/.changeset/pre/22328-auth-seeded-hook-registered-at-ready.md similarity index 100% rename from .changeset/22328-auth-seeded-hook-registered-at-ready.md rename to .changeset/pre/22328-auth-seeded-hook-registered-at-ready.md diff --git a/.changeset/22332-chunk-part-record-compare-and-set.md b/.changeset/pre/22332-chunk-part-record-compare-and-set.md similarity index 100% rename from .changeset/22332-chunk-part-record-compare-and-set.md rename to .changeset/pre/22332-chunk-part-record-compare-and-set.md diff --git a/.changeset/22335-graceful-shutdown-false-no-exit.md b/.changeset/pre/22335-graceful-shutdown-false-no-exit.md similarity index 100% rename from .changeset/22335-graceful-shutdown-false-no-exit.md rename to .changeset/pre/22335-graceful-shutdown-false-no-exit.md diff --git a/.changeset/22340-registry-allow-environment-overlay.md b/.changeset/pre/22340-registry-allow-environment-overlay.md similarity index 100% rename from .changeset/22340-registry-allow-environment-overlay.md rename to .changeset/pre/22340-registry-allow-environment-overlay.md diff --git a/.changeset/22340-s3-governed-text-rename.md b/.changeset/pre/22340-s3-governed-text-rename.md similarity index 100% rename from .changeset/22340-s3-governed-text-rename.md rename to .changeset/pre/22340-s3-governed-text-rename.md diff --git a/.changeset/22343-retry-policy-try-catch-undeclared-keys-refused.md b/.changeset/pre/22343-retry-policy-try-catch-undeclared-keys-refused.md similarity index 100% rename from .changeset/22343-retry-policy-try-catch-undeclared-keys-refused.md rename to .changeset/pre/22343-retry-policy-try-catch-undeclared-keys-refused.md diff --git a/.changeset/22344-get-record-id-projection.md b/.changeset/pre/22344-get-record-id-projection.md similarity index 100% rename from .changeset/22344-get-record-id-projection.md rename to .changeset/pre/22344-get-record-id-projection.md diff --git a/.changeset/22345-run-provenance-context-retired.md b/.changeset/pre/22345-run-provenance-context-retired.md similarity index 100% rename from .changeset/22345-run-provenance-context-retired.md rename to .changeset/pre/22345-run-provenance-context-retired.md diff --git a/.changeset/22360-declared-position-package-provenance.md b/.changeset/pre/22360-declared-position-package-provenance.md similarity index 100% rename from .changeset/22360-declared-position-package-provenance.md rename to .changeset/pre/22360-declared-position-package-provenance.md diff --git a/.changeset/22362-unscoped-run-strings.md b/.changeset/pre/22362-unscoped-run-strings.md similarity index 100% rename from .changeset/22362-unscoped-run-strings.md rename to .changeset/pre/22362-unscoped-run-strings.md diff --git a/.changeset/22371-security-catalog-overlays-step.md b/.changeset/pre/22371-security-catalog-overlays-step.md similarity index 100% rename from .changeset/22371-security-catalog-overlays-step.md rename to .changeset/pre/22371-security-catalog-overlays-step.md diff --git a/.changeset/22384-auth-session-api-getsession-input-query.md b/.changeset/pre/22384-auth-session-api-getsession-input-query.md similarity index 100% rename from .changeset/22384-auth-session-api-getsession-input-query.md rename to .changeset/pre/22384-auth-session-api-getsession-input-query.md diff --git a/.changeset/22386-spec-object-attached-on-read.md b/.changeset/pre/22386-spec-object-attached-on-read.md similarity index 100% rename from .changeset/22386-spec-object-attached-on-read.md rename to .changeset/pre/22386-spec-object-attached-on-read.md diff --git a/.changeset/22386-validator-attached-on-read-leaf.md b/.changeset/pre/22386-validator-attached-on-read-leaf.md similarity index 100% rename from .changeset/22386-validator-attached-on-read-leaf.md rename to .changeset/pre/22386-validator-attached-on-read-leaf.md diff --git a/.changeset/22387-plugin-approvals-viewer-attached-on-read.md b/.changeset/pre/22387-plugin-approvals-viewer-attached-on-read.md similarity index 100% rename from .changeset/22387-plugin-approvals-viewer-attached-on-read.md rename to .changeset/pre/22387-plugin-approvals-viewer-attached-on-read.md diff --git a/.changeset/22394-option-visible-when-evaluser-members.md b/.changeset/pre/22394-option-visible-when-evaluser-members.md similarity index 100% rename from .changeset/22394-option-visible-when-evaluser-members.md rename to .changeset/pre/22394-option-visible-when-evaluser-members.md diff --git a/.changeset/22398-plugin-auth-session-redispatch.md b/.changeset/pre/22398-plugin-auth-session-redispatch.md similarity index 100% rename from .changeset/22398-plugin-auth-session-redispatch.md rename to .changeset/pre/22398-plugin-auth-session-redispatch.md diff --git a/.changeset/22402-option-gate-fails-closed.md b/.changeset/pre/22402-option-gate-fails-closed.md similarity index 100% rename from .changeset/22402-option-gate-fails-closed.md rename to .changeset/pre/22402-option-gate-fails-closed.md diff --git a/.changeset/22405-serve-flat-docs-owner.md b/.changeset/pre/22405-serve-flat-docs-owner.md similarity index 100% rename from .changeset/22405-serve-flat-docs-owner.md rename to .changeset/pre/22405-serve-flat-docs-owner.md diff --git a/.changeset/22407-flow-door-elevated-start.md b/.changeset/pre/22407-flow-door-elevated-start.md similarity index 100% rename from .changeset/22407-flow-door-elevated-start.md rename to .changeset/pre/22407-flow-door-elevated-start.md diff --git a/.changeset/22410-dev-banner-order.md b/.changeset/pre/22410-dev-banner-order.md similarity index 100% rename from .changeset/22410-dev-banner-order.md rename to .changeset/pre/22410-dev-banner-order.md diff --git a/.changeset/22411-hatch-legacy-spelling.md b/.changeset/pre/22411-hatch-legacy-spelling.md similarity index 100% rename from .changeset/22411-hatch-legacy-spelling.md rename to .changeset/pre/22411-hatch-legacy-spelling.md diff --git a/.changeset/22420-console-drift-responder.md b/.changeset/pre/22420-console-drift-responder.md similarity index 100% rename from .changeset/22420-console-drift-responder.md rename to .changeset/pre/22420-console-drift-responder.md diff --git a/.changeset/22423-mcp-stdio-package-cwd.md b/.changeset/pre/22423-mcp-stdio-package-cwd.md similarity index 100% rename from .changeset/22423-mcp-stdio-package-cwd.md rename to .changeset/pre/22423-mcp-stdio-package-cwd.md diff --git a/.changeset/22428-field-existence-every-spelling.md b/.changeset/pre/22428-field-existence-every-spelling.md similarity index 100% rename from .changeset/22428-field-existence-every-spelling.md rename to .changeset/pre/22428-field-existence-every-spelling.md diff --git a/.changeset/22430-api-description-anonymous-deny.md b/.changeset/pre/22430-api-description-anonymous-deny.md similarity index 100% rename from .changeset/22430-api-description-anonymous-deny.md rename to .changeset/pre/22430-api-description-anonymous-deny.md diff --git a/.changeset/22431-unclaimed-download-signed-in.md b/.changeset/pre/22431-unclaimed-download-signed-in.md similarity index 100% rename from .changeset/22431-unclaimed-download-signed-in.md rename to .changeset/pre/22431-unclaimed-download-signed-in.md diff --git a/.changeset/22432-i18n-anonymous-deny.md b/.changeset/pre/22432-i18n-anonymous-deny.md similarity index 100% rename from .changeset/22432-i18n-anonymous-deny.md rename to .changeset/pre/22432-i18n-anonymous-deny.md diff --git a/.changeset/22434-connector-provider-package-path.md b/.changeset/pre/22434-connector-provider-package-path.md similarity index 100% rename from .changeset/22434-connector-provider-package-path.md rename to .changeset/pre/22434-connector-provider-package-path.md diff --git a/.changeset/22437-public-form-submit-answers-id.md b/.changeset/pre/22437-public-form-submit-answers-id.md similarity index 100% rename from .changeset/22437-public-form-submit-answers-id.md rename to .changeset/pre/22437-public-form-submit-answers-id.md diff --git a/.changeset/22443-storage-scope-public-retired.md b/.changeset/pre/22443-storage-scope-public-retired.md similarity index 100% rename from .changeset/22443-storage-scope-public-retired.md rename to .changeset/pre/22443-storage-scope-public-retired.md diff --git a/.changeset/22443-sys-file-public-scope-rewrite.md b/.changeset/pre/22443-sys-file-public-scope-rewrite.md similarity index 100% rename from .changeset/22443-sys-file-public-scope-rewrite.md rename to .changeset/pre/22443-sys-file-public-scope-rewrite.md diff --git a/.changeset/22445-update-preview-stored-row.md b/.changeset/pre/22445-update-preview-stored-row.md similarity index 100% rename from .changeset/22445-update-preview-stored-row.md rename to .changeset/pre/22445-update-preview-stored-row.md diff --git a/.changeset/22450-flow-refusal-translation.md b/.changeset/pre/22450-flow-refusal-translation.md similarity index 100% rename from .changeset/22450-flow-refusal-translation.md rename to .changeset/pre/22450-flow-refusal-translation.md diff --git a/.changeset/22455-cbp-parent-gates-judge-master.md b/.changeset/pre/22455-cbp-parent-gates-judge-master.md similarity index 100% rename from .changeset/22455-cbp-parent-gates-judge-master.md rename to .changeset/pre/22455-cbp-parent-gates-judge-master.md diff --git a/.changeset/22455-security-serves-controlled-by-parent-write.md b/.changeset/pre/22455-security-serves-controlled-by-parent-write.md similarity index 100% rename from .changeset/22455-security-serves-controlled-by-parent-write.md rename to .changeset/pre/22455-security-serves-controlled-by-parent-write.md diff --git a/.changeset/22459-hook-lowering-destructuring-alias.md b/.changeset/pre/22459-hook-lowering-destructuring-alias.md similarity index 100% rename from .changeset/22459-hook-lowering-destructuring-alias.md rename to .changeset/pre/22459-hook-lowering-destructuring-alias.md diff --git a/.changeset/22463-sso-domain-verification-unknown-provider.md b/.changeset/pre/22463-sso-domain-verification-unknown-provider.md similarity index 100% rename from .changeset/22463-sso-domain-verification-unknown-provider.md rename to .changeset/pre/22463-sso-domain-verification-unknown-provider.md diff --git a/.changeset/22464-security-service-controlled-by-parent-write.md b/.changeset/pre/22464-security-service-controlled-by-parent-write.md similarity index 100% rename from .changeset/22464-security-service-controlled-by-parent-write.md rename to .changeset/pre/22464-security-service-controlled-by-parent-write.md diff --git a/.changeset/22466-refused-attach-tombstone.md b/.changeset/pre/22466-refused-attach-tombstone.md similarity index 100% rename from .changeset/22466-refused-attach-tombstone.md rename to .changeset/pre/22466-refused-attach-tombstone.md diff --git a/.changeset/22470-upload-scope-vocabulary.md b/.changeset/pre/22470-upload-scope-vocabulary.md similarity index 100% rename from .changeset/22470-upload-scope-vocabulary.md rename to .changeset/pre/22470-upload-scope-vocabulary.md diff --git a/.changeset/22472-spec-record-approval-decision.md b/.changeset/pre/22472-spec-record-approval-decision.md similarity index 100% rename from .changeset/22472-spec-record-approval-decision.md rename to .changeset/pre/22472-spec-record-approval-decision.md diff --git a/.changeset/22473-approval-request-page.md b/.changeset/pre/22473-approval-request-page.md similarity index 100% rename from .changeset/22473-approval-request-page.md rename to .changeset/pre/22473-approval-request-page.md diff --git a/.changeset/22474-preview-binds-parent.md b/.changeset/pre/22474-preview-binds-parent.md similarity index 100% rename from .changeset/22474-preview-binds-parent.md rename to .changeset/pre/22474-preview-binds-parent.md diff --git a/.changeset/22477-flow-text-slot-dollar-root-refused.md b/.changeset/pre/22477-flow-text-slot-dollar-root-refused.md similarity index 100% rename from .changeset/22477-flow-text-slot-dollar-root-refused.md rename to .changeset/pre/22477-flow-text-slot-dollar-root-refused.md diff --git a/.changeset/22481-attached-block-served-rows.md b/.changeset/pre/22481-attached-block-served-rows.md similarity index 100% rename from .changeset/22481-attached-block-served-rows.md rename to .changeset/pre/22481-attached-block-served-rows.md diff --git a/.changeset/22482-spec-projections-generated-at-publish.md b/.changeset/pre/22482-spec-projections-generated-at-publish.md similarity index 100% rename from .changeset/22482-spec-projections-generated-at-publish.md rename to .changeset/pre/22482-spec-projections-generated-at-publish.md diff --git a/.changeset/22483-upgrade-guide-docs-address.md b/.changeset/pre/22483-upgrade-guide-docs-address.md similarity index 100% rename from .changeset/22483-upgrade-guide-docs-address.md rename to .changeset/pre/22483-upgrade-guide-docs-address.md diff --git a/.changeset/22491-chart-list-view-binds-a-dataset.md b/.changeset/pre/22491-chart-list-view-binds-a-dataset.md similarity index 100% rename from .changeset/22491-chart-list-view-binds-a-dataset.md rename to .changeset/pre/22491-chart-list-view-binds-a-dataset.md diff --git a/.changeset/22497-master-chain-docblock.md b/.changeset/pre/22497-master-chain-docblock.md similarity index 100% rename from .changeset/22497-master-chain-docblock.md rename to .changeset/pre/22497-master-chain-docblock.md diff --git a/.changeset/22502-flow-binding-variable-dollar-name-refused.md b/.changeset/pre/22502-flow-binding-variable-dollar-name-refused.md similarity index 100% rename from .changeset/22502-flow-binding-variable-dollar-name-refused.md rename to .changeset/pre/22502-flow-binding-variable-dollar-name-refused.md diff --git a/.changeset/22506-migrate-composes-boot.md b/.changeset/pre/22506-migrate-composes-boot.md similarity index 100% rename from .changeset/22506-migrate-composes-boot.md rename to .changeset/pre/22506-migrate-composes-boot.md diff --git a/.changeset/22507-flow-options-toasts-translation.md b/.changeset/pre/22507-flow-options-toasts-translation.md similarity index 100% rename from .changeset/22507-flow-options-toasts-translation.md rename to .changeset/pre/22507-flow-options-toasts-translation.md diff --git a/.changeset/22507-screen-copy-engine-pick-automation.md b/.changeset/pre/22507-screen-copy-engine-pick-automation.md similarity index 100% rename from .changeset/22507-screen-copy-engine-pick-automation.md rename to .changeset/pre/22507-screen-copy-engine-pick-automation.md diff --git a/.changeset/22507-screen-copy-engine-pick-cli.md b/.changeset/pre/22507-screen-copy-engine-pick-cli.md similarity index 100% rename from .changeset/22507-screen-copy-engine-pick-cli.md rename to .changeset/pre/22507-screen-copy-engine-pick-cli.md diff --git a/.changeset/22507-screen-copy-engine-pick-lint.md b/.changeset/pre/22507-screen-copy-engine-pick-lint.md similarity index 100% rename from .changeset/22507-screen-copy-engine-pick-lint.md rename to .changeset/pre/22507-screen-copy-engine-pick-lint.md diff --git a/.changeset/22507-screen-copy-engine-pick-spec.md b/.changeset/pre/22507-screen-copy-engine-pick-spec.md similarity index 100% rename from .changeset/22507-screen-copy-engine-pick-spec.md rename to .changeset/pre/22507-screen-copy-engine-pick-spec.md diff --git a/.changeset/22508-page-header-title-keeps-authored.md b/.changeset/pre/22508-page-header-title-keeps-authored.md similarity index 100% rename from .changeset/22508-page-header-title-keeps-authored.md rename to .changeset/pre/22508-page-header-title-keeps-authored.md diff --git a/.changeset/22510-activity-actor-name.md b/.changeset/pre/22510-activity-actor-name.md similarity index 100% rename from .changeset/22510-activity-actor-name.md rename to .changeset/pre/22510-activity-actor-name.md diff --git a/.changeset/22514-explain-cbp-update-master-check.md b/.changeset/pre/22514-explain-cbp-update-master-check.md similarity index 100% rename from .changeset/22514-explain-cbp-update-master-check.md rename to .changeset/pre/22514-explain-cbp-update-master-check.md diff --git a/.changeset/22519-unreadable-header-write.md b/.changeset/pre/22519-unreadable-header-write.md similarity index 100% rename from .changeset/22519-unreadable-header-write.md rename to .changeset/pre/22519-unreadable-header-write.md diff --git a/.changeset/22521-config-boot-residual.md b/.changeset/pre/22521-config-boot-residual.md similarity index 100% rename from .changeset/22521-config-boot-residual.md rename to .changeset/pre/22521-config-boot-residual.md diff --git a/.changeset/22527-activity-actor-avatar.md b/.changeset/pre/22527-activity-actor-avatar.md similarity index 100% rename from .changeset/22527-activity-actor-avatar.md rename to .changeset/pre/22527-activity-actor-avatar.md diff --git a/.changeset/22530-explain-cbp-write-verb-parity.md b/.changeset/pre/22530-explain-cbp-write-verb-parity.md similarity index 100% rename from .changeset/22530-explain-cbp-write-verb-parity.md rename to .changeset/pre/22530-explain-cbp-write-verb-parity.md diff --git a/.changeset/22536-record-page-header-title.md b/.changeset/pre/22536-record-page-header-title.md similarity index 100% rename from .changeset/22536-record-page-header-title.md rename to .changeset/pre/22536-record-page-header-title.md diff --git a/.changeset/22537-spec-record-approvals-attachments.md b/.changeset/pre/22537-spec-record-approvals-attachments.md similarity index 100% rename from .changeset/22537-spec-record-approvals-attachments.md rename to .changeset/pre/22537-spec-record-approvals-attachments.md diff --git a/.changeset/22550-explain-lifecycle-verb-rls-operation.md b/.changeset/pre/22550-explain-lifecycle-verb-rls-operation.md similarity index 100% rename from .changeset/22550-explain-lifecycle-verb-rls-operation.md rename to .changeset/pre/22550-explain-lifecycle-verb-rls-operation.md diff --git a/.changeset/22554-migration-registry-generated-at-build.md b/.changeset/pre/22554-migration-registry-generated-at-build.md similarity index 100% rename from .changeset/22554-migration-registry-generated-at-build.md rename to .changeset/pre/22554-migration-registry-generated-at-build.md diff --git a/.changeset/22558-empty-approver-no-slot.md b/.changeset/pre/22558-empty-approver-no-slot.md similarity index 100% rename from .changeset/22558-empty-approver-no-slot.md rename to .changeset/pre/22558-empty-approver-no-slot.md diff --git a/.changeset/22559-approval-request-read-gate.md b/.changeset/pre/22559-approval-request-read-gate.md similarity index 100% rename from .changeset/22559-approval-request-read-gate.md rename to .changeset/pre/22559-approval-request-read-gate.md diff --git a/.changeset/22560-platform-objects-approvals-visible-to-readers-form-row.md b/.changeset/pre/22560-platform-objects-approvals-visible-to-readers-form-row.md similarity index 100% rename from .changeset/22560-platform-objects-approvals-visible-to-readers-form-row.md rename to .changeset/pre/22560-platform-objects-approvals-visible-to-readers-form-row.md diff --git a/.changeset/22560-plugin-approvals-declared-record-reader-tier.md b/.changeset/pre/22560-plugin-approvals-declared-record-reader-tier.md similarity index 100% rename from .changeset/22560-plugin-approvals-declared-record-reader-tier.md rename to .changeset/pre/22560-plugin-approvals-declared-record-reader-tier.md diff --git a/.changeset/22560-spec-approvals-visible-to-readers.md b/.changeset/pre/22560-spec-approvals-visible-to-readers.md similarity index 100% rename from .changeset/22560-spec-approvals-visible-to-readers.md rename to .changeset/pre/22560-spec-approvals-visible-to-readers.md diff --git a/.changeset/22565-flow-cel-unbound-root-refused.md b/.changeset/pre/22565-flow-cel-unbound-root-refused.md similarity index 100% rename from .changeset/22565-flow-cel-unbound-root-refused.md rename to .changeset/pre/22565-flow-cel-unbound-root-refused.md diff --git a/.changeset/22566-comment-reaction-record.md b/.changeset/pre/22566-comment-reaction-record.md similarity index 100% rename from .changeset/22566-comment-reaction-record.md rename to .changeset/pre/22566-comment-reaction-record.md diff --git a/.changeset/22568-page-slots-details-beside-tabs-refused.md b/.changeset/pre/22568-page-slots-details-beside-tabs-refused.md similarity index 100% rename from .changeset/22568-page-slots-details-beside-tabs-refused.md rename to .changeset/pre/22568-page-slots-details-beside-tabs-refused.md diff --git a/.changeset/22571-explain-transfer-unreadable-row-not-found.md b/.changeset/pre/22571-explain-transfer-unreadable-row-not-found.md similarity index 100% rename from .changeset/22571-explain-transfer-unreadable-row-not-found.md rename to .changeset/pre/22571-explain-transfer-unreadable-row-not-found.md diff --git a/.changeset/22572-flow-binding-name-dollar-refused.md b/.changeset/pre/22572-flow-binding-name-dollar-refused.md similarity index 100% rename from .changeset/22572-flow-binding-name-dollar-refused.md rename to .changeset/pre/22572-flow-binding-name-dollar-refused.md diff --git a/.changeset/22573-sys-comment-reactions-retired.md b/.changeset/pre/22573-sys-comment-reactions-retired.md similarity index 100% rename from .changeset/22573-sys-comment-reactions-retired.md rename to .changeset/pre/22573-sys-comment-reactions-retired.md diff --git a/.changeset/22575-spec-approvals-action-page-member.md b/.changeset/pre/22575-spec-approvals-action-page-member.md similarity index 100% rename from .changeset/22575-spec-approvals-action-page-member.md rename to .changeset/pre/22575-spec-approvals-action-page-member.md diff --git a/.changeset/22576-approvals-act-domain.md b/.changeset/pre/22576-approvals-act-domain.md similarity index 100% rename from .changeset/22576-approvals-act-domain.md rename to .changeset/pre/22576-approvals-act-domain.md diff --git a/.changeset/22577-approvals-act-allowlist.md b/.changeset/pre/22577-approvals-act-allowlist.md similarity index 100% rename from .changeset/22577-approvals-act-allowlist.md rename to .changeset/pre/22577-approvals-act-allowlist.md diff --git a/.changeset/22578-approvals-action-page-member.md b/.changeset/pre/22578-approvals-action-page-member.md similarity index 100% rename from .changeset/22578-approvals-action-page-member.md rename to .changeset/pre/22578-approvals-action-page-member.md diff --git a/.changeset/22579-migrate-telemetry-sibling.md b/.changeset/pre/22579-migrate-telemetry-sibling.md similarity index 100% rename from .changeset/22579-migrate-telemetry-sibling.md rename to .changeset/pre/22579-migrate-telemetry-sibling.md diff --git a/.changeset/22580-unmanaged-tables-served-boot.md b/.changeset/pre/22580-unmanaged-tables-served-boot.md similarity index 100% rename from .changeset/22580-unmanaged-tables-served-boot.md rename to .changeset/pre/22580-unmanaged-tables-served-boot.md diff --git a/.changeset/22581-migrate-reads-env-files.md b/.changeset/pre/22581-migrate-reads-env-files.md similarity index 100% rename from .changeset/22581-migrate-reads-env-files.md rename to .changeset/pre/22581-migrate-reads-env-files.md diff --git a/.changeset/22588-nested-refusal-sentence-zero-row-total.md b/.changeset/pre/22588-nested-refusal-sentence-zero-row-total.md similarity index 100% rename from .changeset/22588-nested-refusal-sentence-zero-row-total.md rename to .changeset/pre/22588-nested-refusal-sentence-zero-row-total.md diff --git a/.changeset/22589-approval-sibling-read-gates.md b/.changeset/pre/22589-approval-sibling-read-gates.md similarity index 100% rename from .changeset/22589-approval-sibling-read-gates.md rename to .changeset/pre/22589-approval-sibling-read-gates.md diff --git a/.changeset/22590-run-summary-acted.md b/.changeset/pre/22590-run-summary-acted.md similarity index 100% rename from .changeset/22590-run-summary-acted.md rename to .changeset/pre/22590-run-summary-acted.md diff --git a/.changeset/22591-sealed-remedy-kind.md b/.changeset/pre/22591-sealed-remedy-kind.md similarity index 100% rename from .changeset/22591-sealed-remedy-kind.md rename to .changeset/pre/22591-sealed-remedy-kind.md diff --git a/.changeset/22593-file-field-hydration-refused.md b/.changeset/pre/22593-file-field-hydration-refused.md similarity index 100% rename from .changeset/22593-file-field-hydration-refused.md rename to .changeset/pre/22593-file-field-hydration-refused.md diff --git a/.changeset/22599-verify-hooks-run-delete-answer.md b/.changeset/pre/22599-verify-hooks-run-delete-answer.md similarity index 100% rename from .changeset/22599-verify-hooks-run-delete-answer.md rename to .changeset/pre/22599-verify-hooks-run-delete-answer.md diff --git a/.changeset/22605-spec-list-view-edit-inline-default-on.md b/.changeset/pre/22605-spec-list-view-edit-inline-default-on.md similarity index 100% rename from .changeset/22605-spec-list-view-edit-inline-default-on.md rename to .changeset/pre/22605-spec-list-view-edit-inline-default-on.md diff --git a/.changeset/22607-approval-opened-topic.md b/.changeset/pre/22607-approval-opened-topic.md similarity index 100% rename from .changeset/22607-approval-opened-topic.md rename to .changeset/pre/22607-approval-opened-topic.md diff --git a/.changeset/22611-list-view-dashboard-required-permissions.md b/.changeset/pre/22611-list-view-dashboard-required-permissions.md similarity index 100% rename from .changeset/22611-list-view-dashboard-required-permissions.md rename to .changeset/pre/22611-list-view-dashboard-required-permissions.md diff --git a/.changeset/22614-meta-view-container-read.md b/.changeset/pre/22614-meta-view-container-read.md similarity index 100% rename from .changeset/22614-meta-view-container-read.md rename to .changeset/pre/22614-meta-view-container-read.md diff --git a/.changeset/22615-objectql-residual-objects-served.md b/.changeset/pre/22615-objectql-residual-objects-served.md similarity index 100% rename from .changeset/22615-objectql-residual-objects-served.md rename to .changeset/pre/22615-objectql-residual-objects-served.md diff --git a/.changeset/22616-approval-token-generic-door.md b/.changeset/pre/22616-approval-token-generic-door.md similarity index 100% rename from .changeset/22616-approval-token-generic-door.md rename to .changeset/pre/22616-approval-token-generic-door.md diff --git a/.changeset/22617-cli-organization-ownership-plan.md b/.changeset/pre/22617-cli-organization-ownership-plan.md similarity index 100% rename from .changeset/22617-cli-organization-ownership-plan.md rename to .changeset/pre/22617-cli-organization-ownership-plan.md diff --git a/.changeset/22627-flow-screen-title-translation-text-slot.md b/.changeset/pre/22627-flow-screen-title-translation-text-slot.md similarity index 100% rename from .changeset/22627-flow-screen-title-translation-text-slot.md rename to .changeset/pre/22627-flow-screen-title-translation-text-slot.md diff --git a/.changeset/22634-analytics-exposure-gate.md b/.changeset/pre/22634-analytics-exposure-gate.md similarity index 100% rename from .changeset/22634-analytics-exposure-gate.md rename to .changeset/pre/22634-analytics-exposure-gate.md diff --git a/.changeset/22634-spec-ledger-analytics-exposure-codes.md b/.changeset/pre/22634-spec-ledger-analytics-exposure-codes.md similarity index 100% rename from .changeset/22634-spec-ledger-analytics-exposure-codes.md rename to .changeset/pre/22634-spec-ledger-analytics-exposure-codes.md diff --git a/.changeset/22636-flow-cel-record-trigger-no-object-opens.md b/.changeset/pre/22636-flow-cel-record-trigger-no-object-opens.md similarity index 100% rename from .changeset/22636-flow-cel-record-trigger-no-object-opens.md rename to .changeset/pre/22636-flow-cel-record-trigger-no-object-opens.md diff --git a/.changeset/22637-file-field-metadata-parent-derived.md b/.changeset/pre/22637-file-field-metadata-parent-derived.md similarity index 100% rename from .changeset/22637-file-field-metadata-parent-derived.md rename to .changeset/pre/22637-file-field-metadata-parent-derived.md diff --git a/.changeset/22639-lint-capability-references-list-views.md b/.changeset/pre/22639-lint-capability-references-list-views.md similarity index 100% rename from .changeset/22639-lint-capability-references-list-views.md rename to .changeset/pre/22639-lint-capability-references-list-views.md diff --git a/.changeset/22639-rest-meta-read-gate-audience.md b/.changeset/pre/22639-rest-meta-read-gate-audience.md similarity index 100% rename from .changeset/22639-rest-meta-read-gate-audience.md rename to .changeset/pre/22639-rest-meta-read-gate-audience.md diff --git a/.changeset/22639-runtime-meta-legacy-object-exit.md b/.changeset/pre/22639-runtime-meta-legacy-object-exit.md similarity index 100% rename from .changeset/22639-runtime-meta-legacy-object-exit.md rename to .changeset/pre/22639-runtime-meta-legacy-object-exit.md diff --git a/.changeset/22639-spec-audience-gate-enforced.md b/.changeset/pre/22639-spec-audience-gate-enforced.md similarity index 100% rename from .changeset/22639-spec-audience-gate-enforced.md rename to .changeset/pre/22639-spec-audience-gate-enforced.md diff --git a/.changeset/22640-search-api-exposure.md b/.changeset/pre/22640-search-api-exposure.md similarity index 100% rename from .changeset/22640-search-api-exposure.md rename to .changeset/pre/22640-search-api-exposure.md diff --git a/.changeset/22642-flow-cel-record-binding.md b/.changeset/pre/22642-flow-cel-record-binding.md similarity index 100% rename from .changeset/22642-flow-cel-record-binding.md rename to .changeset/pre/22642-flow-cel-record-binding.md diff --git a/.changeset/22646-internal-field-data-door-positions.md b/.changeset/pre/22646-internal-field-data-door-positions.md similarity index 100% rename from .changeset/22646-internal-field-data-door-positions.md rename to .changeset/pre/22646-internal-field-data-door-positions.md diff --git a/.changeset/22655-submit-redirect-docblock.md b/.changeset/pre/22655-submit-redirect-docblock.md similarity index 100% rename from .changeset/22655-submit-redirect-docblock.md rename to .changeset/pre/22655-submit-redirect-docblock.md diff --git a/.changeset/22658-skills-package.md b/.changeset/pre/22658-skills-package.md similarity index 100% rename from .changeset/22658-skills-package.md rename to .changeset/pre/22658-skills-package.md diff --git a/.changeset/22661-second-object-exposure.md b/.changeset/pre/22661-second-object-exposure.md similarity index 100% rename from .changeset/22661-second-object-exposure.md rename to .changeset/pre/22661-second-object-exposure.md diff --git a/.changeset/22663-analytics-unexposed-cube-registration.md b/.changeset/pre/22663-analytics-unexposed-cube-registration.md similarity index 100% rename from .changeset/22663-analytics-unexposed-cube-registration.md rename to .changeset/pre/22663-analytics-unexposed-cube-registration.md diff --git a/.changeset/22664-spec-ledger-mcp-exposure-codes.md b/.changeset/pre/22664-spec-ledger-mcp-exposure-codes.md similarity index 100% rename from .changeset/22664-spec-ledger-mcp-exposure-codes.md rename to .changeset/pre/22664-spec-ledger-mcp-exposure-codes.md diff --git a/.changeset/22677-flow-cel-record-entrance.md b/.changeset/pre/22677-flow-cel-record-entrance.md similarity index 100% rename from .changeset/22677-flow-cel-record-entrance.md rename to .changeset/pre/22677-flow-cel-record-entrance.md diff --git a/.changeset/22682-position-form-permission-sets-picker.md b/.changeset/pre/22682-position-form-permission-sets-picker.md similarity index 100% rename from .changeset/22682-position-form-permission-sets-picker.md rename to .changeset/pre/22682-position-form-permission-sets-picker.md diff --git a/.changeset/22682-spec-position-bindings-by-name.md b/.changeset/pre/22682-spec-position-bindings-by-name.md similarity index 100% rename from .changeset/22682-spec-position-bindings-by-name.md rename to .changeset/pre/22682-spec-position-bindings-by-name.md diff --git a/.changeset/22683-approver-remedy-bulk-import.md b/.changeset/pre/22683-approver-remedy-bulk-import.md similarity index 100% rename from .changeset/22683-approver-remedy-bulk-import.md rename to .changeset/pre/22683-approver-remedy-bulk-import.md diff --git a/.changeset/22689-registry-package-version-stamp.md b/.changeset/pre/22689-registry-package-version-stamp.md similarity index 100% rename from .changeset/22689-registry-package-version-stamp.md rename to .changeset/pre/22689-registry-package-version-stamp.md diff --git a/.changeset/22694-import-row-hook-sentence.md b/.changeset/pre/22694-import-row-hook-sentence.md similarity index 100% rename from .changeset/22694-import-row-hook-sentence.md rename to .changeset/pre/22694-import-row-hook-sentence.md diff --git a/.changeset/22695-install-local-hotloaded.md b/.changeset/pre/22695-install-local-hotloaded.md similarity index 100% rename from .changeset/22695-install-local-hotloaded.md rename to .changeset/pre/22695-install-local-hotloaded.md diff --git a/.changeset/22698-file-delegate-metadata-text.md b/.changeset/pre/22698-file-delegate-metadata-text.md similarity index 100% rename from .changeset/22698-file-delegate-metadata-text.md rename to .changeset/pre/22698-file-delegate-metadata-text.md diff --git a/.changeset/22718-import-row-sandbox-fault.md b/.changeset/pre/22718-import-row-sandbox-fault.md similarity index 100% rename from .changeset/22718-import-row-sandbox-fault.md rename to .changeset/pre/22718-import-row-sandbox-fault.md diff --git a/.changeset/22719-rest-write-hook-refusal-sentence.md b/.changeset/pre/22719-rest-write-hook-refusal-sentence.md similarity index 100% rename from .changeset/22719-rest-write-hook-refusal-sentence.md rename to .changeset/pre/22719-rest-write-hook-refusal-sentence.md diff --git a/.changeset/22726-write-answer-warnings.md b/.changeset/pre/22726-write-answer-warnings.md similarity index 100% rename from .changeset/22726-write-answer-warnings.md rename to .changeset/pre/22726-write-answer-warnings.md diff --git a/.changeset/22727-formula-currency-result.md b/.changeset/pre/22727-formula-currency-result.md similarity index 100% rename from .changeset/22727-formula-currency-result.md rename to .changeset/pre/22727-formula-currency-result.md diff --git a/.changeset/22729-package-install-hotloaded.md b/.changeset/pre/22729-package-install-hotloaded.md similarity index 100% rename from .changeset/22729-package-install-hotloaded.md rename to .changeset/pre/22729-package-install-hotloaded.md diff --git a/.changeset/22737-relation-condition-target-exposure.md b/.changeset/pre/22737-relation-condition-target-exposure.md similarity index 100% rename from .changeset/22737-relation-condition-target-exposure.md rename to .changeset/pre/22737-relation-condition-target-exposure.md diff --git a/.changeset/22737-spec-ledger-metadata-protocol-exposure-codes.md b/.changeset/pre/22737-spec-ledger-metadata-protocol-exposure-codes.md similarity index 100% rename from .changeset/22737-spec-ledger-metadata-protocol-exposure-codes.md rename to .changeset/pre/22737-spec-ledger-metadata-protocol-exposure-codes.md diff --git a/.changeset/22737-types-exposure-arm-names-target.md b/.changeset/pre/22737-types-exposure-arm-names-target.md similarity index 100% rename from .changeset/22737-types-exposure-arm-names-target.md rename to .changeset/pre/22737-types-exposure-arm-names-target.md diff --git a/.changeset/22738-lookup-title-exposure.md b/.changeset/pre/22738-lookup-title-exposure.md similarity index 100% rename from .changeset/22738-lookup-title-exposure.md rename to .changeset/pre/22738-lookup-title-exposure.md diff --git a/.changeset/22739-import-reference-target-exposure.md b/.changeset/pre/22739-import-reference-target-exposure.md similarity index 100% rename from .changeset/22739-import-reference-target-exposure.md rename to .changeset/pre/22739-import-reference-target-exposure.md diff --git a/.changeset/22741-sandbox-fault-import-row.md b/.changeset/pre/22741-sandbox-fault-import-row.md similarity index 100% rename from .changeset/22741-sandbox-fault-import-row.md rename to .changeset/pre/22741-sandbox-fault-import-row.md diff --git a/.changeset/22743-field-types-currency-returntype.md b/.changeset/pre/22743-field-types-currency-returntype.md similarity index 100% rename from .changeset/22743-field-types-currency-returntype.md rename to .changeset/pre/22743-field-types-currency-returntype.md diff --git a/.changeset/22748-stack-email-sms-door.md b/.changeset/pre/22748-stack-email-sms-door.md similarity index 100% rename from .changeset/22748-stack-email-sms-door.md rename to .changeset/pre/22748-stack-email-sms-door.md diff --git a/.changeset/22754-spec-webhooks-redeliver-member.md b/.changeset/pre/22754-spec-webhooks-redeliver-member.md similarity index 100% rename from .changeset/22754-spec-webhooks-redeliver-member.md rename to .changeset/pre/22754-spec-webhooks-redeliver-member.md diff --git a/.changeset/22755-webhooks-redeliver-domain.md b/.changeset/pre/22755-webhooks-redeliver-domain.md similarity index 100% rename from .changeset/22755-webhooks-redeliver-domain.md rename to .changeset/pre/22755-webhooks-redeliver-domain.md diff --git a/.changeset/22756-webhooks-redeliver-member.md b/.changeset/pre/22756-webhooks-redeliver-member.md similarity index 100% rename from .changeset/22756-webhooks-redeliver-member.md rename to .changeset/pre/22756-webhooks-redeliver-member.md diff --git a/.changeset/22759-runtime-authoring-issue-path-rule.md b/.changeset/pre/22759-runtime-authoring-issue-path-rule.md similarity index 100% rename from .changeset/22759-runtime-authoring-issue-path-rule.md rename to .changeset/pre/22759-runtime-authoring-issue-path-rule.md diff --git a/.changeset/22761-explain-read-absent-layers.md b/.changeset/pre/22761-explain-read-absent-layers.md similarity index 100% rename from .changeset/22761-explain-read-absent-layers.md rename to .changeset/pre/22761-explain-read-absent-layers.md diff --git a/.changeset/22764-rest-write-fault-withheld.md b/.changeset/pre/22764-rest-write-fault-withheld.md similarity index 100% rename from .changeset/22764-rest-write-fault-withheld.md rename to .changeset/pre/22764-rest-write-fault-withheld.md diff --git a/.changeset/22769-versioned-http-signature.md b/.changeset/pre/22769-versioned-http-signature.md similarity index 100% rename from .changeset/22769-versioned-http-signature.md rename to .changeset/pre/22769-versioned-http-signature.md diff --git a/.changeset/22771-milestone-activity-kind.md b/.changeset/pre/22771-milestone-activity-kind.md similarity index 100% rename from .changeset/22771-milestone-activity-kind.md rename to .changeset/pre/22771-milestone-activity-kind.md diff --git a/.changeset/22772-spec-trigger-api-inbound-hook-member.md b/.changeset/pre/22772-spec-trigger-api-inbound-hook-member.md similarity index 100% rename from .changeset/22772-spec-trigger-api-inbound-hook-member.md rename to .changeset/pre/22772-spec-trigger-api-inbound-hook-member.md diff --git a/.changeset/22773-automation-hooks-domain.md b/.changeset/pre/22773-automation-hooks-domain.md similarity index 100% rename from .changeset/22773-automation-hooks-domain.md rename to .changeset/pre/22773-automation-hooks-domain.md diff --git a/.changeset/22774-inbound-hook-member.md b/.changeset/pre/22774-inbound-hook-member.md similarity index 100% rename from .changeset/22774-inbound-hook-member.md rename to .changeset/pre/22774-inbound-hook-member.md diff --git a/.changeset/22777-core-relation-condition-sites.md b/.changeset/pre/22777-core-relation-condition-sites.md similarity index 100% rename from .changeset/22777-core-relation-condition-sites.md rename to .changeset/pre/22777-core-relation-condition-sites.md diff --git a/.changeset/22777-mcp-stdio-relation-condition-target-exposure.md b/.changeset/pre/22777-mcp-stdio-relation-condition-target-exposure.md similarity index 100% rename from .changeset/22777-mcp-stdio-relation-condition-target-exposure.md rename to .changeset/pre/22777-mcp-stdio-relation-condition-target-exposure.md diff --git a/.changeset/22780-bulk-param-promoted-route-guidance.md b/.changeset/pre/22780-bulk-param-promoted-route-guidance.md similarity index 100% rename from .changeset/22780-bulk-param-promoted-route-guidance.md rename to .changeset/pre/22780-bulk-param-promoted-route-guidance.md diff --git a/.changeset/22782-objectql-after-hook-abort-rolls-back.md b/.changeset/pre/22782-objectql-after-hook-abort-rolls-back.md similarity index 100% rename from .changeset/22782-objectql-after-hook-abort-rolls-back.md rename to .changeset/pre/22782-objectql-after-hook-abort-rolls-back.md diff --git a/.changeset/22782-spec-hook-onerror-abort-text.md b/.changeset/pre/22782-spec-hook-onerror-abort-text.md similarity index 100% rename from .changeset/22782-spec-hook-onerror-abort-text.md rename to .changeset/pre/22782-spec-hook-onerror-abort-text.md diff --git a/.changeset/22785-import-user-field-target.md b/.changeset/pre/22785-import-user-field-target.md similarity index 100% rename from .changeset/22785-import-user-field-target.md rename to .changeset/pre/22785-import-user-field-target.md diff --git a/.changeset/22786-withhold-milestone-row.md b/.changeset/pre/22786-withhold-milestone-row.md similarity index 100% rename from .changeset/22786-withhold-milestone-row.md rename to .changeset/pre/22786-withhold-milestone-row.md diff --git a/.changeset/22787-readable-ids-leaf.md b/.changeset/pre/22787-readable-ids-leaf.md similarity index 100% rename from .changeset/22787-readable-ids-leaf.md rename to .changeset/pre/22787-readable-ids-leaf.md diff --git a/.changeset/22789-sms-configured-provider.md b/.changeset/pre/22789-sms-configured-provider.md similarity index 100% rename from .changeset/22789-sms-configured-provider.md rename to .changeset/pre/22789-sms-configured-provider.md diff --git a/.changeset/22790-search-total-counted.md b/.changeset/pre/22790-search-total-counted.md similarity index 100% rename from .changeset/22790-search-total-counted.md rename to .changeset/pre/22790-search-total-counted.md diff --git a/.changeset/22792-explain-asker-gate.md b/.changeset/pre/22792-explain-asker-gate.md similarity index 100% rename from .changeset/22792-explain-asker-gate.md rename to .changeset/pre/22792-explain-asker-gate.md diff --git a/.changeset/22792-explain-read-master-leg.md b/.changeset/pre/22792-explain-read-master-leg.md similarity index 100% rename from .changeset/22792-explain-read-master-leg.md rename to .changeset/pre/22792-explain-read-master-leg.md diff --git a/.changeset/22800-identity-import-exposure.md b/.changeset/pre/22800-identity-import-exposure.md similarity index 100% rename from .changeset/22800-identity-import-exposure.md rename to .changeset/pre/22800-identity-import-exposure.md diff --git a/.changeset/22804-redeliver-inactive-veto.md b/.changeset/pre/22804-redeliver-inactive-veto.md similarity index 100% rename from .changeset/22804-redeliver-inactive-veto.md rename to .changeset/pre/22804-redeliver-inactive-veto.md diff --git a/.changeset/22806-uniform-hook-refusal.md b/.changeset/pre/22806-uniform-hook-refusal.md similarity index 100% rename from .changeset/22806-uniform-hook-refusal.md rename to .changeset/pre/22806-uniform-hook-refusal.md diff --git a/.changeset/22810-lowered-body-scope-submitted-refused.md b/.changeset/pre/22810-lowered-body-scope-submitted-refused.md similarity index 100% rename from .changeset/22810-lowered-body-scope-submitted-refused.md rename to .changeset/pre/22810-lowered-body-scope-submitted-refused.md diff --git a/.changeset/22817-action-activation-unpackaged.md b/.changeset/pre/22817-action-activation-unpackaged.md similarity index 100% rename from .changeset/22817-action-activation-unpackaged.md rename to .changeset/pre/22817-action-activation-unpackaged.md diff --git a/.changeset/22821-admin-password-choice.md b/.changeset/pre/22821-admin-password-choice.md similarity index 100% rename from .changeset/22821-admin-password-choice.md rename to .changeset/pre/22821-admin-password-choice.md diff --git a/.changeset/22823-calldata-fallback-relation-exposure.md b/.changeset/pre/22823-calldata-fallback-relation-exposure.md similarity index 100% rename from .changeset/22823-calldata-fallback-relation-exposure.md rename to .changeset/pre/22823-calldata-fallback-relation-exposure.md diff --git a/.changeset/22823-spec-ledger-runtime-exposure-codes.md b/.changeset/pre/22823-spec-ledger-runtime-exposure-codes.md similarity index 100% rename from .changeset/22823-spec-ledger-runtime-exposure-codes.md rename to .changeset/pre/22823-spec-ledger-runtime-exposure-codes.md diff --git a/.changeset/22824-admin-rescue-named-slate.md b/.changeset/pre/22824-admin-rescue-named-slate.md similarity index 100% rename from .changeset/22824-admin-rescue-named-slate.md rename to .changeset/pre/22824-admin-rescue-named-slate.md diff --git a/.changeset/22825-inbound-hook-timestamped-guidance.md b/.changeset/pre/22825-inbound-hook-timestamped-guidance.md similarity index 100% rename from .changeset/22825-inbound-hook-timestamped-guidance.md rename to .changeset/pre/22825-inbound-hook-timestamped-guidance.md diff --git a/.changeset/22826-refuse-body-only-signature.md b/.changeset/pre/22826-refuse-body-only-signature.md similarity index 100% rename from .changeset/22826-refuse-body-only-signature.md rename to .changeset/pre/22826-refuse-body-only-signature.md diff --git a/.changeset/22835-datasource-reads-environment-only.md b/.changeset/pre/22835-datasource-reads-environment-only.md similarity index 100% rename from .changeset/22835-datasource-reads-environment-only.md rename to .changeset/pre/22835-datasource-reads-environment-only.md diff --git a/.changeset/22837-lowered-hook-parameter-rebind.md b/.changeset/pre/22837-lowered-hook-parameter-rebind.md similarity index 100% rename from .changeset/22837-lowered-hook-parameter-rebind.md rename to .changeset/pre/22837-lowered-hook-parameter-rebind.md diff --git a/.changeset/22839-strict-body-count.md b/.changeset/pre/22839-strict-body-count.md similarity index 100% rename from .changeset/22839-strict-body-count.md rename to .changeset/pre/22839-strict-body-count.md diff --git a/.changeset/22841-cli-rule-ids-one-line-verdicts.md b/.changeset/pre/22841-cli-rule-ids-one-line-verdicts.md similarity index 100% rename from .changeset/22841-cli-rule-ids-one-line-verdicts.md rename to .changeset/pre/22841-cli-rule-ids-one-line-verdicts.md diff --git a/.changeset/22848-service-slot-contracts-webhooks-trigger-api.md b/.changeset/pre/22848-service-slot-contracts-webhooks-trigger-api.md similarity index 100% rename from .changeset/22848-service-slot-contracts-webhooks-trigger-api.md rename to .changeset/pre/22848-service-slot-contracts-webhooks-trigger-api.md diff --git a/.changeset/22853-runtime-bundle-bodyless.md b/.changeset/pre/22853-runtime-bundle-bodyless.md similarity index 100% rename from .changeset/22853-runtime-bundle-bodyless.md rename to .changeset/pre/22853-runtime-bundle-bodyless.md diff --git a/.changeset/22855-scoped-transaction-ambient.md b/.changeset/pre/22855-scoped-transaction-ambient.md similarity index 100% rename from .changeset/22855-scoped-transaction-ambient.md rename to .changeset/pre/22855-scoped-transaction-ambient.md diff --git a/.changeset/22856-log-hook-unevaluable-condition.md b/.changeset/pre/22856-log-hook-unevaluable-condition.md similarity index 100% rename from .changeset/22856-log-hook-unevaluable-condition.md rename to .changeset/pre/22856-log-hook-unevaluable-condition.md diff --git a/.changeset/22858-bulk-param-promoted-route-migration-text.md b/.changeset/pre/22858-bulk-param-promoted-route-migration-text.md similarity index 100% rename from .changeset/22858-bulk-param-promoted-route-migration-text.md rename to .changeset/pre/22858-bulk-param-promoted-route-migration-text.md diff --git a/.changeset/22860-lowered-body-parse-peel.md b/.changeset/pre/22860-lowered-body-parse-peel.md similarity index 100% rename from .changeset/22860-lowered-body-parse-peel.md rename to .changeset/pre/22860-lowered-body-parse-peel.md diff --git a/.changeset/22868-sandbox-fault-not-unknown-object.md b/.changeset/pre/22868-sandbox-fault-not-unknown-object.md similarity index 100% rename from .changeset/22868-sandbox-fault-not-unknown-object.md rename to .changeset/pre/22868-sandbox-fault-not-unknown-object.md diff --git a/.changeset/22870-field-mail-association.md b/.changeset/pre/22870-field-mail-association.md similarity index 100% rename from .changeset/22870-field-mail-association.md rename to .changeset/pre/22870-field-mail-association.md diff --git a/.changeset/22870-service-mailbox.md b/.changeset/pre/22870-service-mailbox.md similarity index 100% rename from .changeset/22870-service-mailbox.md rename to .changeset/pre/22870-service-mailbox.md diff --git a/.changeset/22876-email-template-category-is-system-retired.md b/.changeset/pre/22876-email-template-category-is-system-retired.md similarity index 100% rename from .changeset/22876-email-template-category-is-system-retired.md rename to .changeset/pre/22876-email-template-category-is-system-retired.md diff --git a/.changeset/22877-member-selector.md b/.changeset/pre/22877-member-selector.md similarity index 100% rename from .changeset/22877-member-selector.md rename to .changeset/pre/22877-member-selector.md diff --git a/.changeset/22877-overlay-locale-member.md b/.changeset/pre/22877-overlay-locale-member.md similarity index 100% rename from .changeset/22877-overlay-locale-member.md rename to .changeset/pre/22877-overlay-locale-member.md diff --git a/.changeset/22885-sandbox-own-fault-rest-write-routes.md b/.changeset/pre/22885-sandbox-own-fault-rest-write-routes.md similarity index 100% rename from .changeset/22885-sandbox-own-fault-rest-write-routes.md rename to .changeset/pre/22885-sandbox-own-fault-rest-write-routes.md diff --git a/.changeset/22886-functions-key-keeps-authored-name.md b/.changeset/pre/22886-functions-key-keeps-authored-name.md similarity index 100% rename from .changeset/22886-functions-key-keeps-authored-name.md rename to .changeset/pre/22886-functions-key-keeps-authored-name.md diff --git a/.changeset/22892-predicate-related-master-detail.md b/.changeset/pre/22892-predicate-related-master-detail.md similarity index 100% rename from .changeset/22892-predicate-related-master-detail.md rename to .changeset/pre/22892-predicate-related-master-detail.md diff --git a/.changeset/22902-stack-functions-array-duplicate-name-refused.md b/.changeset/pre/22902-stack-functions-array-duplicate-name-refused.md similarity index 100% rename from .changeset/22902-stack-functions-array-duplicate-name-refused.md rename to .changeset/pre/22902-stack-functions-array-duplicate-name-refused.md diff --git a/.changeset/22908-multi-reference-element-shape.md b/.changeset/pre/22908-multi-reference-element-shape.md similarity index 100% rename from .changeset/22908-multi-reference-element-shape.md rename to .changeset/pre/22908-multi-reference-element-shape.md diff --git a/.changeset/22911-blank-prefixed-project-name.md b/.changeset/pre/22911-blank-prefixed-project-name.md similarity index 100% rename from .changeset/22911-blank-prefixed-project-name.md rename to .changeset/pre/22911-blank-prefixed-project-name.md diff --git a/.changeset/22912-scaffold-vitest-range-npm10.md b/.changeset/pre/22912-scaffold-vitest-range-npm10.md similarity index 100% rename from .changeset/22912-scaffold-vitest-range-npm10.md rename to .changeset/pre/22912-scaffold-vitest-range-npm10.md diff --git a/.changeset/22913-audit-provision-failure-error.md b/.changeset/pre/22913-audit-provision-failure-error.md similarity index 100% rename from .changeset/22913-audit-provision-failure-error.md rename to .changeset/pre/22913-audit-provision-failure-error.md diff --git a/.changeset/22934-stack-collection-duplicate-entry-refused.md b/.changeset/pre/22934-stack-collection-duplicate-entry-refused.md similarity index 100% rename from .changeset/22934-stack-collection-duplicate-entry-refused.md rename to .changeset/pre/22934-stack-collection-duplicate-entry-refused.md diff --git a/.changeset/22937-core-row-definition-mappers.md b/.changeset/pre/22937-core-row-definition-mappers.md similarity index 100% rename from .changeset/22937-core-row-definition-mappers.md rename to .changeset/pre/22937-core-row-definition-mappers.md diff --git a/.changeset/22944-runtime-request-kernel-first.md b/.changeset/pre/22944-runtime-request-kernel-first.md similarity index 100% rename from .changeset/22944-runtime-request-kernel-first.md rename to .changeset/pre/22944-runtime-request-kernel-first.md diff --git a/.changeset/22957-tree-delete-behavior.md b/.changeset/pre/22957-tree-delete-behavior.md similarity index 100% rename from .changeset/22957-tree-delete-behavior.md rename to .changeset/pre/22957-tree-delete-behavior.md diff --git a/.changeset/22961-plugin-audit-own-ui-state-no-activity.md b/.changeset/pre/22961-plugin-audit-own-ui-state-no-activity.md similarity index 100% rename from .changeset/22961-plugin-audit-own-ui-state-no-activity.md rename to .changeset/pre/22961-plugin-audit-own-ui-state-no-activity.md diff --git a/.changeset/22972-host-bundle-wins.md b/.changeset/pre/22972-host-bundle-wins.md similarity index 100% rename from .changeset/22972-host-bundle-wins.md rename to .changeset/pre/22972-host-bundle-wins.md diff --git a/.changeset/22984-boot-provision-honours-skip-sync.md b/.changeset/pre/22984-boot-provision-honours-skip-sync.md similarity index 100% rename from .changeset/22984-boot-provision-honours-skip-sync.md rename to .changeset/pre/22984-boot-provision-honours-skip-sync.md diff --git a/.changeset/22985-schema-sync-refusal-error.md b/.changeset/pre/22985-schema-sync-refusal-error.md similarity index 100% rename from .changeset/22985-schema-sync-refusal-error.md rename to .changeset/pre/22985-schema-sync-refusal-error.md diff --git a/.changeset/22989-config-boot-api-items.md b/.changeset/pre/22989-config-boot-api-items.md similarity index 100% rename from .changeset/22989-config-boot-api-items.md rename to .changeset/pre/22989-config-boot-api-items.md diff --git a/.changeset/22989-host-config-metadata-door.md b/.changeset/pre/22989-host-config-metadata-door.md similarity index 100% rename from .changeset/22989-host-config-metadata-door.md rename to .changeset/pre/22989-host-config-metadata-door.md diff --git a/.changeset/22994-app-plugin-converted-collections.md b/.changeset/pre/22994-app-plugin-converted-collections.md similarity index 100% rename from .changeset/22994-app-plugin-converted-collections.md rename to .changeset/pre/22994-app-plugin-converted-collections.md diff --git a/.changeset/23000-branding-logo-describe.md b/.changeset/pre/23000-branding-logo-describe.md similarity index 100% rename from .changeset/23000-branding-logo-describe.md rename to .changeset/pre/23000-branding-logo-describe.md diff --git a/.changeset/23001-prerelease-artifact-window.md b/.changeset/pre/23001-prerelease-artifact-window.md similarity index 100% rename from .changeset/23001-prerelease-artifact-window.md rename to .changeset/pre/23001-prerelease-artifact-window.md diff --git a/.changeset/23004-empty-state-link.md b/.changeset/pre/23004-empty-state-link.md similarity index 100% rename from .changeset/23004-empty-state-link.md rename to .changeset/pre/23004-empty-state-link.md diff --git a/.changeset/23008-org-seed-claim-skip-automations.md b/.changeset/pre/23008-org-seed-claim-skip-automations.md similarity index 100% rename from .changeset/23008-org-seed-claim-skip-automations.md rename to .changeset/pre/23008-org-seed-claim-skip-automations.md diff --git a/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md b/.changeset/pre/23009-rls-deny-sentinel-composed-readable-ids.md similarity index 100% rename from .changeset/23009-rls-deny-sentinel-composed-readable-ids.md rename to .changeset/pre/23009-rls-deny-sentinel-composed-readable-ids.md diff --git a/.changeset/23010-url-action-target-scheme-set.md b/.changeset/pre/23010-url-action-target-scheme-set.md similarity index 100% rename from .changeset/23010-url-action-target-scheme-set.md rename to .changeset/pre/23010-url-action-target-scheme-set.md diff --git a/.changeset/23013-section-heading-count.md b/.changeset/pre/23013-section-heading-count.md similarity index 100% rename from .changeset/23013-section-heading-count.md rename to .changeset/pre/23013-section-heading-count.md diff --git a/.changeset/23016-page-element-api-data-source.md b/.changeset/pre/23016-page-element-api-data-source.md similarity index 100% rename from .changeset/23016-page-element-api-data-source.md rename to .changeset/pre/23016-page-element-api-data-source.md diff --git a/.changeset/23022-sys-packages-managed-then-flag.md b/.changeset/pre/23022-sys-packages-managed-then-flag.md similarity index 100% rename from .changeset/23022-sys-packages-managed-then-flag.md rename to .changeset/pre/23022-sys-packages-managed-then-flag.md diff --git a/.changeset/23026-retired-component-prop-refused.md b/.changeset/pre/23026-retired-component-prop-refused.md similarity index 100% rename from .changeset/23026-retired-component-prop-refused.md rename to .changeset/pre/23026-retired-component-prop-refused.md diff --git a/.changeset/23027-package-body-conversion.md b/.changeset/pre/23027-package-body-conversion.md similarity index 100% rename from .changeset/23027-package-body-conversion.md rename to .changeset/pre/23027-package-body-conversion.md diff --git a/.changeset/23029-app-plugin-converted-readers.md b/.changeset/pre/23029-app-plugin-converted-readers.md similarity index 100% rename from .changeset/23029-app-plugin-converted-readers.md rename to .changeset/pre/23029-app-plugin-converted-readers.md diff --git a/.changeset/23032-page-header-actions-whole-list.md b/.changeset/pre/23032-page-header-actions-whole-list.md similarity index 100% rename from .changeset/23032-page-header-actions-whole-list.md rename to .changeset/pre/23032-page-header-actions-whole-list.md diff --git a/.changeset/23033-metadata-row-actor.md b/.changeset/pre/23033-metadata-row-actor.md similarity index 100% rename from .changeset/23033-metadata-row-actor.md rename to .changeset/pre/23033-metadata-row-actor.md diff --git a/.changeset/23036-visiblewhen-no-data-root.md b/.changeset/pre/23036-visiblewhen-no-data-root.md similarity index 100% rename from .changeset/23036-visiblewhen-no-data-root.md rename to .changeset/pre/23036-visiblewhen-no-data-root.md diff --git a/.changeset/23039-indexing-partial-example-recut.md b/.changeset/pre/23039-indexing-partial-example-recut.md similarity index 100% rename from .changeset/23039-indexing-partial-example-recut.md rename to .changeset/pre/23039-indexing-partial-example-recut.md diff --git a/.changeset/23042-retired-after-prerelease-stamp.md b/.changeset/pre/23042-retired-after-prerelease-stamp.md similarity index 100% rename from .changeset/23042-retired-after-prerelease-stamp.md rename to .changeset/pre/23042-retired-after-prerelease-stamp.md diff --git a/.changeset/23047-me-permissions-outer-catch-closed.md b/.changeset/pre/23047-me-permissions-outer-catch-closed.md similarity index 100% rename from .changeset/23047-me-permissions-outer-catch-closed.md rename to .changeset/pre/23047-me-permissions-outer-catch-closed.md diff --git a/.changeset/23054-org-seed-claim-paged.md b/.changeset/pre/23054-org-seed-claim-paged.md similarity index 100% rename from .changeset/23054-org-seed-claim-paged.md rename to .changeset/pre/23054-org-seed-claim-paged.md diff --git a/.changeset/23065-referenceless-tree.md b/.changeset/pre/23065-referenceless-tree.md similarity index 100% rename from .changeset/23065-referenceless-tree.md rename to .changeset/pre/23065-referenceless-tree.md diff --git a/.changeset/23068-migrate-actor.md b/.changeset/pre/23068-migrate-actor.md similarity index 100% rename from .changeset/23068-migrate-actor.md rename to .changeset/pre/23068-migrate-actor.md diff --git a/.changeset/23072-meta-doors-closed-sets.md b/.changeset/pre/23072-meta-doors-closed-sets.md similarity index 100% rename from .changeset/23072-meta-doors-closed-sets.md rename to .changeset/pre/23072-meta-doors-closed-sets.md diff --git a/.changeset/23073-list-member.md b/.changeset/pre/23073-list-member.md similarity index 100% rename from .changeset/23073-list-member.md rename to .changeset/pre/23073-list-member.md diff --git a/.changeset/23074-member-envelope.md b/.changeset/pre/23074-member-envelope.md similarity index 100% rename from .changeset/23074-member-envelope.md rename to .changeset/pre/23074-member-envelope.md diff --git a/.changeset/23077-url-target-doors-scheme-set.md b/.changeset/pre/23077-url-target-doors-scheme-set.md similarity index 100% rename from .changeset/23077-url-target-doors-scheme-set.md rename to .changeset/pre/23077-url-target-doors-scheme-set.md diff --git a/.changeset/23083-openapi-read-decorations.md b/.changeset/pre/23083-openapi-read-decorations.md similarity index 100% rename from .changeset/23083-openapi-read-decorations.md rename to .changeset/pre/23083-openapi-read-decorations.md diff --git a/.changeset/23086-actor-family.md b/.changeset/pre/23086-actor-family.md similarity index 100% rename from .changeset/23086-actor-family.md rename to .changeset/pre/23086-actor-family.md diff --git a/.changeset/23088-verify-bootstack-metadata-plugin.md b/.changeset/pre/23088-verify-bootstack-metadata-plugin.md similarity index 100% rename from .changeset/23088-verify-bootstack-metadata-plugin.md rename to .changeset/pre/23088-verify-bootstack-metadata-plugin.md diff --git a/.changeset/23090-app-plugin-hooks-translations.md b/.changeset/pre/23090-app-plugin-hooks-translations.md similarity index 100% rename from .changeset/23090-app-plugin-hooks-translations.md rename to .changeset/pre/23090-app-plugin-hooks-translations.md diff --git a/.changeset/23093-cbp-middle-master-floor.md b/.changeset/pre/23093-cbp-middle-master-floor.md similarity index 100% rename from .changeset/23093-cbp-middle-master-floor.md rename to .changeset/pre/23093-cbp-middle-master-floor.md diff --git a/.changeset/23096-save-door-protection.md b/.changeset/pre/23096-save-door-protection.md similarity index 100% rename from .changeset/23096-save-door-protection.md rename to .changeset/pre/23096-save-door-protection.md diff --git a/.changeset/23119-save-door-lock-envelope.md b/.changeset/pre/23119-save-door-lock-envelope.md similarity index 100% rename from .changeset/23119-save-door-lock-envelope.md rename to .changeset/pre/23119-save-door-lock-envelope.md diff --git a/.changeset/23122-outbox-claim-credential-read-back.md b/.changeset/pre/23122-outbox-claim-credential-read-back.md similarity index 100% rename from .changeset/23122-outbox-claim-credential-read-back.md rename to .changeset/pre/23122-outbox-claim-credential-read-back.md diff --git a/.changeset/23124-comment-edit-stamp.md b/.changeset/pre/23124-comment-edit-stamp.md similarity index 100% rename from .changeset/23124-comment-edit-stamp.md rename to .changeset/pre/23124-comment-edit-stamp.md diff --git a/.changeset/23142-settings-anonymous-floor.md b/.changeset/pre/23142-settings-anonymous-floor.md similarity index 100% rename from .changeset/23142-settings-anonymous-floor.md rename to .changeset/pre/23142-settings-anonymous-floor.md diff --git a/.changeset/23143-storage-token-refusal-401.md b/.changeset/pre/23143-storage-token-refusal-401.md similarity index 100% rename from .changeset/23143-storage-token-refusal-401.md rename to .changeset/pre/23143-storage-token-refusal-401.md diff --git a/.changeset/5082-declared-index-unique-scope.md b/.changeset/pre/5082-declared-index-unique-scope.md similarity index 100% rename from .changeset/5082-declared-index-unique-scope.md rename to .changeset/pre/5082-declared-index-unique-scope.md diff --git a/.changeset/9139-cbp-master-detail-required-error.md b/.changeset/pre/9139-cbp-master-detail-required-error.md similarity index 100% rename from .changeset/9139-cbp-master-detail-required-error.md rename to .changeset/pre/9139-cbp-master-detail-required-error.md diff --git a/.changeset/9272-grant-window-order-refused.md b/.changeset/pre/9272-grant-window-order-refused.md similarity index 100% rename from .changeset/9272-grant-window-order-refused.md rename to .changeset/pre/9272-grant-window-order-refused.md diff --git a/.changeset/9272-user-page-grant-window-columns.md b/.changeset/pre/9272-user-page-grant-window-columns.md similarity index 100% rename from .changeset/9272-user-page-grant-window-columns.md rename to .changeset/pre/9272-user-page-grant-window-columns.md diff --git a/.changeset/9591-migrate-meta-write.md b/.changeset/pre/9591-migrate-meta-write.md similarity index 100% rename from .changeset/9591-migrate-meta-write.md rename to .changeset/pre/9591-migrate-meta-write.md diff --git a/.changeset/9591-retirement-sentence-write.md b/.changeset/pre/9591-retirement-sentence-write.md similarity index 100% rename from .changeset/9591-retirement-sentence-write.md rename to .changeset/pre/9591-retirement-sentence-write.md diff --git a/.changeset/console-088e0f705520.md b/.changeset/pre/console-088e0f705520.md similarity index 100% rename from .changeset/console-088e0f705520.md rename to .changeset/pre/console-088e0f705520.md diff --git a/.changeset/console-0df67f237c5a.md b/.changeset/pre/console-0df67f237c5a.md similarity index 100% rename from .changeset/console-0df67f237c5a.md rename to .changeset/pre/console-0df67f237c5a.md diff --git a/.changeset/console-1fb1ced36243.md b/.changeset/pre/console-1fb1ced36243.md similarity index 100% rename from .changeset/console-1fb1ced36243.md rename to .changeset/pre/console-1fb1ced36243.md diff --git a/.changeset/console-20c6d351ad74.md b/.changeset/pre/console-20c6d351ad74.md similarity index 100% rename from .changeset/console-20c6d351ad74.md rename to .changeset/pre/console-20c6d351ad74.md diff --git a/.changeset/console-47b1f0bb7174.md b/.changeset/pre/console-47b1f0bb7174.md similarity index 100% rename from .changeset/console-47b1f0bb7174.md rename to .changeset/pre/console-47b1f0bb7174.md diff --git a/.changeset/console-4997995022f8.md b/.changeset/pre/console-4997995022f8.md similarity index 100% rename from .changeset/console-4997995022f8.md rename to .changeset/pre/console-4997995022f8.md diff --git a/.changeset/console-7578323a544e.md b/.changeset/pre/console-7578323a544e.md similarity index 100% rename from .changeset/console-7578323a544e.md rename to .changeset/pre/console-7578323a544e.md diff --git a/.changeset/console-9646e2ed2bf3.md b/.changeset/pre/console-9646e2ed2bf3.md similarity index 100% rename from .changeset/console-9646e2ed2bf3.md rename to .changeset/pre/console-9646e2ed2bf3.md diff --git a/.changeset/console-a58626c88dc8.md b/.changeset/pre/console-a58626c88dc8.md similarity index 100% rename from .changeset/console-a58626c88dc8.md rename to .changeset/pre/console-a58626c88dc8.md diff --git a/.changeset/console-e5311165a8d6.md b/.changeset/pre/console-e5311165a8d6.md similarity index 100% rename from .changeset/console-e5311165a8d6.md rename to .changeset/pre/console-e5311165a8d6.md diff --git a/.changeset/console-f0268ad78485.md b/.changeset/pre/console-f0268ad78485.md similarity index 100% rename from .changeset/console-f0268ad78485.md rename to .changeset/pre/console-f0268ad78485.md diff --git a/.changeset/objectui-pin-citations-088e0f705520.md b/.changeset/pre/objectui-pin-citations-088e0f705520.md similarity index 100% rename from .changeset/objectui-pin-citations-088e0f705520.md rename to .changeset/pre/objectui-pin-citations-088e0f705520.md diff --git a/.changeset/objectui-pin-citations-0df67f237c5a.md b/.changeset/pre/objectui-pin-citations-0df67f237c5a.md similarity index 100% rename from .changeset/objectui-pin-citations-0df67f237c5a.md rename to .changeset/pre/objectui-pin-citations-0df67f237c5a.md diff --git a/.changeset/objectui-pin-citations-1fb1ced36243.md b/.changeset/pre/objectui-pin-citations-1fb1ced36243.md similarity index 100% rename from .changeset/objectui-pin-citations-1fb1ced36243.md rename to .changeset/pre/objectui-pin-citations-1fb1ced36243.md diff --git a/.changeset/objectui-pin-citations-20c6d351ad74.md b/.changeset/pre/objectui-pin-citations-20c6d351ad74.md similarity index 100% rename from .changeset/objectui-pin-citations-20c6d351ad74.md rename to .changeset/pre/objectui-pin-citations-20c6d351ad74.md diff --git a/.changeset/objectui-pin-citations-47b1f0bb7174.md b/.changeset/pre/objectui-pin-citations-47b1f0bb7174.md similarity index 100% rename from .changeset/objectui-pin-citations-47b1f0bb7174.md rename to .changeset/pre/objectui-pin-citations-47b1f0bb7174.md diff --git a/.changeset/objectui-pin-citations-4997995022f8.md b/.changeset/pre/objectui-pin-citations-4997995022f8.md similarity index 100% rename from .changeset/objectui-pin-citations-4997995022f8.md rename to .changeset/pre/objectui-pin-citations-4997995022f8.md diff --git a/.changeset/objectui-pin-citations-7578323a544e.md b/.changeset/pre/objectui-pin-citations-7578323a544e.md similarity index 100% rename from .changeset/objectui-pin-citations-7578323a544e.md rename to .changeset/pre/objectui-pin-citations-7578323a544e.md diff --git a/.changeset/objectui-pin-citations-9646e2ed2bf3.md b/.changeset/pre/objectui-pin-citations-9646e2ed2bf3.md similarity index 100% rename from .changeset/objectui-pin-citations-9646e2ed2bf3.md rename to .changeset/pre/objectui-pin-citations-9646e2ed2bf3.md diff --git a/.changeset/objectui-pin-citations-a58626c88dc8.md b/.changeset/pre/objectui-pin-citations-a58626c88dc8.md similarity index 100% rename from .changeset/objectui-pin-citations-a58626c88dc8.md rename to .changeset/pre/objectui-pin-citations-a58626c88dc8.md diff --git a/.changeset/objectui-pin-citations-e5311165a8d6.md b/.changeset/pre/objectui-pin-citations-e5311165a8d6.md similarity index 100% rename from .changeset/objectui-pin-citations-e5311165a8d6.md rename to .changeset/pre/objectui-pin-citations-e5311165a8d6.md diff --git a/.changeset/objectui-pin-citations-f0268ad78485.md b/.changeset/pre/objectui-pin-citations-f0268ad78485.md similarity index 100% rename from .changeset/objectui-pin-citations-f0268ad78485.md rename to .changeset/pre/objectui-pin-citations-f0268ad78485.md diff --git a/.changeset/request-scoped-grants-memo.md b/.changeset/pre/request-scoped-grants-memo.md similarity index 100% rename from .changeset/request-scoped-grants-memo.md rename to .changeset/pre/request-scoped-grants-memo.md diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index e806482a1fc..73f6ff69b56 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,214 @@ # @objectstack/example-crm +## 4.0.100-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [cd3d391] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [ca135dc] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [cb3bb93] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [117817e] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/runtime@18.0.0-next.0 + - @objectstack/service-i18n@18.0.0-next.0 + ## 4.0.99 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 144e13b5ff2..8a4b3ae053a 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.99", + "version": "4.0.100-next.0", "description": "Minimal CRM example \u2014 a smoke-test workspace that exercises the metadata loading pipeline (objects \u2192 views \u2192 app \u2192 dashboard \u2192 hook \u2192 flow \u2192 seed). For a full-featured enterprise CRM see https://github.com/objectstack-ai/hotcrm.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-multi-package/CHANGELOG.md b/examples/app-multi-package/CHANGELOG.md index 931c0e1c5cd..5a477c10099 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,177 @@ # @objectstack/example-multi-package +## 0.0.7-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + ## 0.0.6 ### Patch Changes diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json index 9624a7ae77b..a511f779574 100644 --- a/examples/app-multi-package/package.json +++ b/examples/app-multi-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-multi-package", - "version": "0.0.6", + "version": "0.0.7-next.0", "description": "One release artifact carrying TWO packages that share a namespace (ADR-0130 D4) — the producer-side fixture for `packages[]`", "license": "Apache-2.0", "private": true, diff --git a/examples/app-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md index e81d88ed103..dbe37252915 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,230 @@ # @objectstack/example-showcase +## 0.3.22-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [cd3d391] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [8a399b2] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [8601526] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [ca135dc] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [f8566a0] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [cb3bb93] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [a360cee] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [117817e] +- Updated dependencies [6a6a4d3] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/runtime@18.0.0-next.0 + - @objectstack/cloud-connection@18.0.0-next.0 + - @objectstack/driver-sql@18.0.0-next.0 + - @objectstack/service-datasource@18.0.0-next.0 + - @objectstack/connector-mcp@18.0.0-next.0 + - @objectstack/connector-openapi@18.0.0-next.0 + - @objectstack/connector-rest@18.0.0-next.0 + - @objectstack/connector-slack@18.0.0-next.0 + - @objectstack/service-i18n@18.0.0-next.0 + ## 0.3.21 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 571d3dc5a41..f50e796a0db 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.21", + "version": "0.3.22-next.0", "description": "Kitchen-sink showcase workspace — exercises every metadata type, every view type, every chart type, and the major end-to-end capability chains (security, automation, analytics). Built for demonstration, debugging, and coverage-driven verification.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-todo/CHANGELOG.md b/examples/app-todo/CHANGELOG.md index 48292ae4f43..bf282a77549 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,251 @@ # @objectstack/example-todo +## 4.0.100-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [cd3d391] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [cb3bb93] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/runtime@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/client@18.0.0-next.0 + - @objectstack/metadata@18.0.0-next.0 + - @objectstack/mcp@18.0.0-next.0 + - @objectstack/driver-sqlite-wasm@18.0.0-next.0 + - @objectstack/knowledge-memory@18.0.0-next.0 + - @objectstack/service-i18n@18.0.0-next.0 + - @objectstack/service-knowledge@18.0.0-next.0 + ## 4.0.99 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index b59cbddcb3a..864352c8902 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.99", + "version": "4.0.100-next.0", "description": "Example Todo App using ObjectStack Protocol", "license": "Apache-2.0", "private": true, diff --git a/examples/embed-objectql/CHANGELOG.md b/examples/embed-objectql/CHANGELOG.md index 272a2c5ded7..9282306db87 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,207 @@ # @objectstack/example-embed-objectql +## 0.0.40-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/driver-memory@18.0.0-next.0 + ## 0.0.39 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index f761b2a9574..19764d9dd00 100644 --- a/examples/embed-objectql/package.json +++ b/examples/embed-objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-embed-objectql", - "version": "0.0.39", + "version": "0.0.40-next.0", "private": true, "description": "Embed the ObjectQL engine as a plain library via @objectstack/objectql/core — no kernel, no plugins, no metadata protocol (ADR-0076).", "type": "module", diff --git a/packages/adapters/hono/CHANGELOG.md b/packages/adapters/hono/CHANGELOG.md index 0882184915b..c530388220b 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,91 @@ # @objectstack/hono +## 18.0.0-next.0 + +### Minor Changes + +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. + +### Patch Changes + +- 26aa999: fix(runtime,hono): approval e-mail links work on a kernel with no raw HTTP app — the dispatcher serves `/approvals/act`, and the Hono catch-all no longer consumes request bodies + + Clause-②: no + + On a self-hosted server, `@objectstack/plugin-approvals` serves the ADR-0043 action page (the approve / reject link in an approval e-mail or IM message) by mounting `GET` and `POST /api/v1/approvals/act` on the raw Hono app. A hosted tenant kernel has no raw app, so on that shape the path answered `404 ROUTE_NOT_FOUND` and every action link was dead. + + **`@objectstack/runtime`.** `HttpDispatcher` now serves exactly `/approvals/act` (no sub-paths) for `GET`, `HEAD` and `POST`. It forwards the request, unread, to the request kernel's `approvals` service (`IApprovalService.handleActionPage`) and returns that `Response` as it is. + + - `HEAD` answers as the self-hosted mount does: the `GET` page's status and headers, with no body. It renders and never decides. + - No `approvals` service registered, or one without `handleActionPage`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`. + - A member that throws: a sanitised `500 INTERNAL_ERROR`, with the original error in the server log. The same answer, logged, when a transport hands over a `POST` whose body was already read, instead of an "invalid link" page for a live link. + - The token is the only credential, as on the self-hosted mount. The project-membership gate now skips the exact act path (unscoped or `/environments/:id`-scoped), so a signed-in caller who is not a member of the environment reaches the page like an anonymous one instead of getting `403 PROJECT_MEMBERSHIP_REQUIRED`. Every other path, `/approvals/act/...` and `/approvals/requests/...` included, is checked as before. + + **`@objectstack/hono`.** The `createHonoApp` catch-all (and the `/auth/*` fallback) read the JSON body with `c.req.json()`, which consumed the raw request for every `Content-Type`. A domain that forwarded the request got it empty. The body is now parsed from a clone, so `context.request` reaches the dispatcher unread, byte for byte. The parsed `body` the domains receive is unchanged for every input: JSON values as parsed, and `{}` for an empty, form, text or invalid body. The cost is that a request body is buffered twice for the life of the request. + + Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher. +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [67b669e] +- Updated dependencies [45e206e] +- Updated dependencies [cd3d391] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [240890c] +- Updated dependencies [1fb274e] +- Updated dependencies [5cfd866] +- Updated dependencies [a543e24] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3aa0c7b] +- Updated dependencies [fbcbcf1] +- Updated dependencies [f4069c5] +- Updated dependencies [a45d5d8] +- Updated dependencies [ca135dc] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [c572fe2] +- Updated dependencies [b53b949] +- Updated dependencies [faf6348] +- Updated dependencies [0fea05f] +- Updated dependencies [26aa999] +- Updated dependencies [cb3bb93] +- Updated dependencies [5081090] +- Updated dependencies [ec7c7e0] +- Updated dependencies [6d5191e] +- Updated dependencies [8558668] +- Updated dependencies [fe58bbc] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [8bc7836] +- Updated dependencies [117817e] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [0aef788] +- Updated dependencies [8c10776] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] + - @objectstack/runtime@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/plugin-hono-server@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 1b37f4b2b8e..83cd9497e5f 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apps/account/CHANGELOG.md b/packages/apps/account/CHANGELOG.md index f5a62f910ee..6f3dfec2fe5 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,192 @@ # @objectstack/account +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index 64dc5c33f74..6a5c3ea4af5 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "ObjectStack Account — the end-user account/self-service console app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/setup/CHANGELOG.md b/packages/apps/setup/CHANGELOG.md index b7d45ed6cb0..ad5fd1709af 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,192 @@ # @objectstack/setup +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index f1ef331e465..4ebae39b69c 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "ObjectStack Setup — the platform administration app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/studio/CHANGELOG.md b/packages/apps/studio/CHANGELOG.md index 0e7fff77818..c5d9d25b302 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,192 @@ # @objectstack/studio +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index bfeda6eb73e..2c54772068b 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "ObjectStack Studio — the metadata builder app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index cb1f79f1b34..0bce0b5122a 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,1464 @@ # @objectstack/cli +## 18.0.0-next.0 + +### Minor Changes + +- 9e9e6f9: feat(cli)!: `os meta resync` is deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + + In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. +- c52bfb4: feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. + + - **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. + - **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. + - **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. + - **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. + + **What moves for consumers.** + + - **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. + - **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. + - **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. + - **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. +- c7d6037: A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) + + Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) + + A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. + + - **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. + - **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: + - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; + - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; + - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. + An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. + - **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. + - **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. + + Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. +- 2d21b9c: An unmanaged install no longer copies a package's protection lock, in either spelling: every copied item is editable, and the install's answer names each lock it dropped with the package's reason (ADR-0131 D6, ADR-0010) + + Clause-②: yes (widening: the new response field `strippedLocks` on `ImportUnmanagedPackageResponseSchema`, relayed on the install-local answer for `installMode: 'unmanaged'`) + + An unmanaged install copies a package into this environment as the environment's own metadata. A package can lock an item in two spellings: the `_lock` envelope (`_lock`, with `_lockReason`, `_lockSource` and `_lockDocsUrl`), or the author-facing `protection: { lock, reason, docsUrl }` block that a managed install translates into it. Until now an item locked with `_lock` was copied with that lock, so the next save of the copy answered `403 ITEM_LOCKED`; an item locked with `protection` was copied with an inert block that bound nothing, so the vendor's lock vanished without a word. The package permitted the unmanaged install through its `installModes`, so it chose to let environments take editable copies; a lock whose source is the package does not survive the copy. + + - **The copy** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` writes every item without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and, on a type whose schema declares one, without its `protection` block. Its answer gains `strippedLocks`: one entry per lock that bound (`no-overlay`, `no-delete` or `full`), in the order the items were written — `{ type, name, lock, reason? }`, with `reason` the package's reason verbatim and absent when it gave none. Each entry is the lock a managed install of the same item would have bound: an item carrying both spellings is reported once, with the `protection` block's lock and reason, because that is the one a managed install binds. A package that locks nothing answers `strippedLocks: []`. + - **Refusals are unchanged.** A lock the spec's declarations refuse (an unknown `_lock` value, a reason that is not a string, a malformed `protection` block) is not stripped: the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back. So is a `protection` block on a type whose schema does not declare one (`action`, `hook`, `page`). A managed install is unchanged: the package stays the authority, and its locks bind. + - **The declaration** (`@objectstack/spec`). `ImportUnmanagedPackageResponseSchema` declares `strippedLocks`; `lock` takes the three binding values of `MetadataLockSchema`. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'`). The answer carries the import's `strippedLocks` as the import states it. + - **`os package install --install-mode unmanaged`** (`@objectstack/cli`). Each dropped lock prints as one line under `Locks removed`, spelled `.: — `, so the installer reads why the vendor locked an item before editing it. An answer from a runtime that states no list prints no lock line. + + Nothing is removed or narrowed: the answer gains a field, and every refusal of the unmanaged install is unchanged. +- 54f062b: feat(verify): the dashboard date-window family — every widget a dashboard's `dateRange` binds counts, under each preset of the picker, the rows that window selects, runnable as `os verify --dashboard-windows` (#15953) + + Clause-②: yes + + A seventh derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs`, `runUniqueScopeProofs` and `runParentWriteReachProofs`. It needs no hand-written test: from the app's own dashboards and datasets, read through the spec's own parse (`DashboardSchema`, `DatasetSchema`), it takes every dashboard that declares a `dateRange` and every widget that window binds, and asks the dataset door what each one counts. + + Where each half of the promise is read from: + + - the field a widget's window lands on is `DashboardWidgetSchema.filterBindings`' contract: `filterBindings.dateRange` names it, `false` opts the widget out (reported `unbound`), and absent means the dashboard's `dateRange.field` (`created_at` when unauthored). It resolves against the object behind the widget's dataset, as the stack registers it, and must be a `date` or `datetime` field; + - a preset's window is `DATE_RANGE_PRESET_MACRO_WINDOWS`, the `{date-macro}` pair each of the 13 `DATE_RANGE_PRESETS` names. It is handed to `POST /analytics/dataset/query` as written (`{ $gte: '{7_days_ago}' }`, `{ $gte: '{month_start}', $lte: '{month_end}' }`), ANDed with the widget's own filter as `runtimeFilter`, and the server resolves the tokens; + - the days a window selects, for the ground truth, are that same window resolved by `@objectstack/core`'s `resolveFilterTokens` on the caller's reference timezone (UTC unless the `ExecutionContext` carries one), first and last day inclusive. + + Per bound widget and count measure, fixture rows are written through the seed door on every preset's first and last day and the day either side (never after today), each stamped at UTC noon (a `date` field takes the day itself), owned by the caller, with every column the widget's filter, the dataset's filter and the measure's filter compare held to a value they select, and read back before anything is judged. The widget is asked as the signed-in caller with no window and under every preset, before and after the fixtures, so each count is a difference and the app's own rows cancel out. With no window it must count every fixture row before any window is judged. Under each preset it must count exactly the fixture rows that window selects: a widget that shows nothing there is `blank`, one that counts another number is contradicted, and one whose every preset answers what no window answers is named inert. + + A widget is `proven`, `contradicted` (a preset went blank or counted other rows), `failed` (the door refused or faulted on a windowed query while the widget's own query answered), `inconclusive` (a fixture could not be written or did not read back as written, the widget with no window did not count every fixture row, its own query is refused, the day turned mid-probe, or the caller's timezone is not UTC), `unbound` or `excluded` (`excludedKind` names why: no count measure, a filter the ground truth cannot model, a window on a missing or non-temporal field, an undeclared or unparsed dataset or dashboard, an object the app does not declare or the CRUD derivation cannot synthesize). `contradicted + failed` widgets are the report's `hardFailures`. An average, a sum or a derived measure is counted as excluded; a held column is an equality, an `$in`, a `$nin` or `$ne` on a field that declares its options, or `{current_user_id}`. + + **`@objectstack/verify` — new exports.** + + - `runDashboardWindowProofs(stack, token, config, { now? })` → `DashboardWindowReport`. Run it on a fresh stack of its own, as a principal that reads the dashboards' objects: it writes fixture rows. + - `deriveDashboardWindowCases(config)`, `formatDashboardWindowReport(report)`. + - Types: `DashboardWindowExclusion`, `DashboardWindowMeasureCase`, `DashboardWindowCase`, `DashboardWindowDashboard`, `DashboardWindowPreset`, `DashboardWindowAnswer`, `DashboardWindowPresetProbe`, `DashboardWindowMeasureResult`, `DashboardWindowWidgetStatus`, `DashboardWindowWidgetResult`, `DashboardWindowReport`, `DashboardWindowProofOptions`. + + ```ts + import { bootStack, runDashboardWindowProofs, formatDashboardWindowReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runDashboardWindowProofs(stack, await stack.signIn(), myApp); + console.log(formatDashboardWindowReport(report)); // report.presets names what each preset handed the door + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --dashboard-windows`.** Opt-in, like `--unique-scopes` and `--parent-write-reach`, on a fresh boot of its own, as the signed-in admin. A widget that went blank or counted other rows than its window selects, or a windowed query the door refused or faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `dashboardWindows` when the flag is given; without the flag the run and its document are unchanged. +- 9f4b652: feat(verify): the delete-behaviour family — every `deleteBehavior` a reference field declares is what a delete of the referenced record does, runnable as `os verify --delete-behaviors` (#15953) + + Clause-②: yes + + A fourth derived proof family beside `runCrudVerification`, `runRlsProofs` and `runReferenceShapeProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`) and reads what the declaration states through the spec's own parse of the field (`FieldSchema`), which is the value a built artifact carries: a `lookup` or `tree` that authors nothing states `set_null`, an authored value is kept, and a bare `master_detail` states none. For each field it creates a fresh target record and a holder referencing it, deletes the target by id, and reads back what the engine left, through two doors: the REST data route as the signed-in caller, and the engine's own delete as the system principal. + + - `cascade` holds when the holder is gone with the target. + - `set_null` holds when the holder survives and no longer references the deleted record. + - `restrict` holds when the delete is refused with `DELETE_RESTRICTED` naming the holder's object, and both records are still there. + - In every case a second holder, referencing another record of the same target object, must be untouched. + + A refusal for any other reason (a hook's veto, a validation rule, a permission) is `unattributed`, so an engine that refused every delete would not read as a proven `restrict`. A field is `proven`, `contradicted` (a delete did something else), `failed` (a door faulted), `inconclusive` (an unattributed refusal, or a fixture the app refused; the report says why), `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. + + Two shapes are `excluded` rather than given an expectation, because an engine rule decides what a delete does to them, not the declaration: a `set_null` on a required reference (it cannot be cleared), and a field whose parse carries no `deleteBehavior` (a bare `master_detail`, a `user`). A field is `not-derivable` when no fixture can be built from the app's metadata: its target is not an object the app declares (such as `sys_user`), it is `readonly` or `system`, or the CRUD derivation cannot synthesize its holder or target. Only by-id deletes are asked; a predicate or multi-row delete is outside the family. + + **`@objectstack/verify` — new exports.** + + - `runDeleteBehaviorProofs(stack, token, config, { doors? })` → `DeleteBehaviorReport`. Run it on a fresh stack of its own: every `restrict` probe leaves its records behind. + - `deriveDeleteBehaviorCases(config)`, `formatDeleteBehaviorReport(report)`. + - Types: `DeclaredDeleteBehavior`, `DeleteBehaviorCase`, `DeleteBehaviorDoor`, `DeleteBehaviorAnswer`, `DeleteBehaviorProbeResult`, `DeleteBehaviorFieldStatus`, `DeleteBehaviorFieldResult`, `DeleteBehaviorReport`, `DeleteBehaviorProofOptions`. + + ```ts + import { bootStack, runDeleteBehaviorProofs, formatDeleteBehaviorReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runDeleteBehaviorProofs(stack, await stack.signIn(), myApp); + console.log(formatDeleteBehaviorReport(report)); + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --delete-behaviors`.** Opt-in, like `--reference-shapes`, on a fresh boot of its own. A contradicted declaration, or a door that faulted, counts in `hardFailures` and fails the run. A `tree` field's delete is not honoured today (deleting the parent leaves the child's reference in place), so an app that declares one fails the run with the flag. The `--json` document carries the report as `deleteBehaviors` when the flag is given; without the flag the run and its document are unchanged. +- 965a1bf: feat(verify): the parent-derived write reach family — a write on a `controlled_by_parent` detail is admitted exactly when the caller holds that verb on the detail and can edit its master, runnable as `os verify --parent-write-reach` (#15953) + + Clause-②: yes + + A sixth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs` and `runUniqueScopeProofs`. It needs no hand-written test: from the app's own objects it takes every object the spec's own parse (`ObjectSchema`) declares `sharingModel: 'controlled_by_parent'` with exactly one `master_detail` field, walks the chain of masters above it to the first one that governs its own rows, and asks the engine what a detail write meets as a signed-in caller: the insert, update and delete by id the REST data route makes, with the caller's context, where a refusal keeps the object and the operation it names (the REST envelope names the route's object whatever was refused). The system door bypasses authorization and is not asked. + + The promise is ADR-0055 §3 and its chain amendment: editing, deleting or creating a detail requires edit access to its master, and the detail's own object permission is the first door. So each write is expected admitted exactly when both halves hold: + + - the caller holds the verb on the detail's object — read from the server's effective permissions for the caller (`GET /auth/me/permissions`), folded by `objectPermissionGrants`; + - the caller can edit the master — measured, never re-derived: an empty update of the master as the caller, which runs every write gate the master has, the gate above a master that is itself controlled by its parent included. + + The writes are made by three verifier personas whose permission sets the family authors from the app's own objects — the root master by the all-records grant (`modifyAllRecords`) with every verb on the detail; the root master readable but not editable with every verb on the detail; the all-records grant with no delete on the detail — and by one persona per position the app declares, whose capability is what the app binds to the position. Each writes under a master chain another principal owns (so the all-records grant, not ownership, is exercised) and one it owns, on a detail another principal owns and one it owns. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. An object is `proven`, `contradicted` (a write met the other answer), `failed` (a door faulted), `inconclusive`, `excluded` (no one master: none, two, an undeclared one, a cycle, or a chain at the write gate's depth bound) or `not-derivable`. `contradicted + failed` objects, plus personas that could not be provisioned, are the report's `hardFailures`. + + A detail two levels below its root master (a line under a quote under an account) is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so the family reports such a chain `proven`. + + **`@objectstack/verify` — new exports.** + + - `runParentWriteReachProofs(stack, config)` → `ParentWriteReachReport`. Run it on a fresh stack of its own, booted with `parentWriteReachSecurity(config)`: it signs up personas and writes, deletes and creates details. On a stack booted without it the verifier personas are reported not run, with the reason, and counted. + - `parentWriteReachSecurity(config)` → the `SecurityPlugin` that registers the verifier personas' permission sets beside the app's declared default profile. + - `deriveParentWriteReachCases(config)`, `formatParentWriteReachReport(report)`. + - Types: `ParentWriteReachHop`, `ParentWriteReachCase`, `ParentWriteReachVerb`, `ParentWriteReachOwner`, `ParentWriteReachAnswer`, `ParentWriteReachProbeResult`, `ParentWriteReachReach`, `ParentWriteReachObjectStatus`, `ParentWriteReachObjectResult`, `ParentWriteReachPersona`, `ParentWriteReachReport`. + + ```ts + import { bootStack, runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity } from '@objectstack/verify'; + + const stack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) }); + const report = await runParentWriteReachProofs(stack, myApp); + console.log(formatParentWriteReachReport(report)); // report.personas names who wrote + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --parent-write-reach`.** Opt-in, like `--delete-behaviors` and `--unique-scopes`, on a fresh boot of its own with the verifier personas' sets registered. A write whose answer disagrees, a door that faulted, or a persona that could not be provisioned counts in `hardFailures` and fails the run. The `--json` document carries the report as `parentWriteReach` when the flag is given; without the flag the run and its document are unchanged. + + **The unique-scope family — one change and one correction.** `runUniqueScopeProofs` is new in this same release, and two sentences of its own entry in these notes no longer hold: + + - **Attribution changed.** A unique refusal that names no field is now `unattributed` where a unique `indexes[]` entry covers the probed column. That entry refuses the same second holder, naming no field either, and admits the twin, so there the refusal may be the entry's rather than the field's own declaration. This is a change to the family's attribution rule: a field-less refusal is attributed to the field only when its twin with only the probed value changed is admitted AND no unique `indexes[]` entry covers the field. + - **How the second organization is provisioned, corrected.** The family provisions it by `signUp`, then `POST /auth/organization/create`, not by a sign-up that creates its own organization as that entry's parenthetical says. +- eab7236: feat(verify): the reference-value shape family — every client-writable reference field refuses a value that is not a record id (ADR-0104 D1), runnable as `os verify --reference-shapes` (#15953) + + Clause-②: yes + + A third derived proof family beside `runCrudVerification` and `runRlsProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), reads what the declaration promises from `valueSchemaFor(field, 'stored')` (a record-id string, or an array of them when the field is multi-valued), and asks the engine whether three wrong-shaped values are refused: a boolean, a number, and an embedded record object (the expanded read shape written back). A multi-valued field gets each one as an array element. Every probe is written on insert and on update, through two doors: the REST data routes as the signed-in caller, and the engine's own write as the system principal. + + A probe counts as refused only when the engine names the probed field in its refusal. A refusal that names something else is reported `unattributed`, so an engine that refused every write would not read as proven. A field is `proven` (every probe refused), `holes` (a probe was admitted), `failed` (a door faulted), `inconclusive` (a probe could not be attributed or written; the report says why) or `excluded` (declared `readonly` or `system`, which the caller does not write). `holes + failed` is the report's `hardFailures`. + + **`@objectstack/verify` — new exports.** + + - `runReferenceShapeProofs(stack, token, config, { doors? })` → `ReferenceShapeReport`. Run it on a fresh stack of its own: a door that admits a probe lands a row. + - `deriveReferenceShapeCases(config)`, `referenceShapeProbeValue(kind, multiple, id)`, `formatReferenceShapeReport(report)`, `REFERENCE_SHAPE_PROBE_ID`. + - Types: `ReferenceShapeCase`, `ReferenceShapeDoor`, `ReferenceShapeOperation`, `ReferenceShapeProbeKind`, `ReferenceShapeProbeResult`, `ReferenceShapeFieldStatus`, `ReferenceShapeFieldResult`, `ReferenceShapeReport`, `ReferenceShapeProofOptions`. + + ```ts + import { bootStack, runReferenceShapeProofs, formatReferenceShapeReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runReferenceShapeProofs(stack, await stack.signIn(), myApp); + console.log(formatReferenceShapeReport(report)); + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. It is opt-in because the engine still admits wrong-shaped elements on a `multiple: true` `lookup` or `user` field (#22908), so an app that declares one would fail the run until the engine refuses them. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged. +- f93bf12: feat(verify): the unique-scope family — every field declared `unique` collides as its declared scope says, runnable as `os verify --unique-scopes` (#15953) + + Clause-②: yes + + A fifth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs` and `runDeleteBehaviorProofs`. It needs no hand-written test: from the app's own objects it takes every field whose `unique` the spec's own parse of the field (`FieldSchema`) declares — the value a built artifact carries, so an `autonumber` that authors no `unique` states `'organization'` — and asks the engine what a second holder of one value meets, through two doors: the REST data route as a signed-in caller, and the engine's own write as the system principal. + + - **Within one organization** — by insert and by update — the second holder must be refused, whatever the scope. On a `single`-posture boot that organization is the install's one: the untenanted install, where every write lands in it. + - **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (a sign-up that creates its own) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. + + A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none and the same record with only the probed value changed is admitted. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. + + A field the caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is probed through the system door only, which keeps an explicit value. A derived value (`formula`, `summary`) is `excluded`; a field with no fresh values to offer (a `boolean`, a `select`, a multi-valued reference, a target the app does not declare) is `not-derivable`. The population is the field-level declaration: a unique `indexes[]` entry is a different surface, with its own meaning of bare `true`, and is not in it. + + **`@objectstack/verify` — new exports.** + + - `runUniqueScopeProofs(stack, token, config, { doors? })` → `UniqueScopeReport`. Run it on a fresh stack of its own: it writes duplicates on purpose. Boot the stack with `{ multiTenant: true }` to run the cross-organization leg. + - `deriveUniqueScopeCases(config)`, `formatUniqueScopeReport(report)`. + - Types: `DeclaredUniqueScope`, `UniqueScopeCase`, `UniqueScopeDoor`, `UniqueScopeLeg`, `UniqueScopeOperation`, `UniqueScopeAnswer`, `UniqueScopeProbeResult`, `UniqueScopeFieldStatus`, `UniqueScopeFieldResult`, `UniqueScopeReport`, `UniqueScopeProofOptions`. + + ```ts + import { bootStack, runUniqueScopeProofs, formatUniqueScopeReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp, { multiTenant: true }); + const report = await runUniqueScopeProofs(stack, await stack.signIn(), myApp); + console.log(formatUniqueScopeReport(report)); // report.boot names the legs this boot ran + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --unique-scopes`.** Opt-in, like `--reference-shapes` and `--delete-behaviors`, on a fresh boot of its own; with `--multi-tenant` it runs both legs. A second holder met wrongly, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `uniqueScopes` when the flag is given; without the flag the run and its document are unchanged. +- cdeabec: `os migrate meta` stops listing semantic notices whose surface the stack provably does not declare. It counts them instead, and `--all` lists them in full. + + Clause-②: yes + + - **`SemanticMigration.relevantWhen`** (`@objectstack/spec/migrations`) is a new optional field. It holds a structured question over the loaded stack, `{ kind: 'stack-declares', keys: [...] }`: does the stack declare anything under one of these top-level keys? A key's value in a `packages[].manifest` body counts the same as a top-level value. The question is closed and named. It is never free text, and it never matches against the prose of `surface`. The new types `SemanticRelevance`, `StackDeclaresRelevance` and `SemanticRelevanceKey` are exported beside `SemanticMigration`. + - **`applyMetaMigrations`** asks each entry's question of the stack it is given and of every hop checkpoint. + - **`todos` is unchanged.** `MigrationChainResult.todos` and `MigrationHopResult.todos` still hold every semantic entry of every hop crossed, whatever the stack holds, as before. + - **`absentTodos` is a new required member** of `MigrationChainResult` and `MigrationHopResult`. It names the subset of `todos` whose question answered `absent` in all of them: the same objects, in chain order. Code that only reads a chain result needs no change. Code that builds one of these two interfaces itself must now supply `absentTodos` (an empty array when nothing is proven absent). + - **Only a positive proof names an entry.** These cases answer `unknown` and leave it off `absentTodos`: + - a value the question cannot read (a function, a promise, a scalar, a getter that throws); + - a stack that is not a plain object; + - any `plugins`, `devPlugins` or `tiers` entry, since a plugin, or the platform plugins a tier preset loads, can register metadata the stack does not show. + + An entry that judges a conversion which applied an edit in the same run is never named either. + - **The first batch is 25 entries** (5 from protocol 17, 20 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. None of them names a code door. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is never named absent, so it is listed exactly as before. + - **`os migrate meta`** lists `todos` minus `absentTodos`. After the listed notices it prints one line that counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores. + - `--all` prints each of those entries in full, with the keys it was proven absent under. + - `--json` keeps `todos` whole and adds `absentTodos`, plus `hops[].absentTodos` with `--step`. + - `--step` reports each hop's listed count, and adds a `not listed` count to the hop line when that count is not zero. + - A run whose only notices are proven absent still writes `--out`. +- dd39171: The path an author writes as a public form's `sharing.publicLink` now answers: `GET /forms/SLUG` redirects to the Console's public form page, `/_console/f/SLUG`, when the anonymous form door serves that slug + + Clause-②: yes (widening) + + - **What was wrong.** An app declares a public form as `sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' }`, the spelling the platform's own examples use. The Console serves that form to an anonymous visitor at `/_console/f/contact-us`, but the path as written answered `404 ENDPOINT_NOT_FOUND`, so the link an author put on a website reached nothing. + - **What it does now.** Wherever the Console is mounted (`os serve` / `os dev` with the Console, or any host that mounts `createConsoleStaticPlugin` from `@objectstack/cli/console`), `GET /forms/SLUG` answers `302` with `Location: /_console/f/SLUG`, followed by the request's query string. It does so only when the anonymous form door, `GET /api/v1/forms/SLUG`, serves the form to the same request: the form's `sharing` has `enabled` and `allowAnonymous` set and names the slug, no other metadata layer withdraws it, and the deployment's tenancy posture lets it take an anonymous submission. The redirect asks that door in-process with the visitor's own request, so it decides nothing the door does not, and it reveals nothing the door does not. + - **Unchanged.** Every other request under `/forms/` answers exactly as before: a disabled form, a form not open to anonymous visitors, an unknown slug, a form the posture withholds, any method other than `GET` / `HEAD`, a trailing slash or a deeper path all get the same `404 ENDPOINT_NOT_FOUND`. Nothing is mounted at the root for a bare slug. The signed-in Console route `/_console/forms/NAME` is untouched. A deployment without the Console (`--no-ui`, `--no-console`, `OS_DISABLE_CONSOLE=1`, or no built Console) mounts no redirect. + - **Status and target.** The redirect is a `302`, never a permanent one, because whether a form is served can change with its sharing. The `Location` path is built from the Console path and the slug, encoded as one path segment, so no request can point it anywhere else. The request's query string is carried verbatim after that path, because the public form page seeds its fields from `?prefill_FIELD=` parameters: `/forms/contact-us?prefill_source=website` lands on `/_console/f/contact-us?prefill_source=website`. A query string can change only the query of the page the visitor lands on, never its origin or path. + - **Nothing to migrate.** No key, export or signature changes. `sharing.publicLink` keeps its meaning and its accepted spellings (`/forms/x`, `forms/x` and `x` name one slug). +- 54d32e5: feat(lint, cli): one-line author-time rule verdicts, and `os explain ` for the reasoning + + Clause-②: yes (widening) + + - **Shorter warnings.** `field-no-consumers` and `security-owd-unset` now print one verdict sentence and one fix. `os validate`, `os build` and `os dev` used to print `field-no-consumers` as a single line of about 860 characters, and `os build` added a second paragraph of about 700; the warning now reads: + + ```text + ⚠ object "my_app_ticket" · field "description": declared, but nothing in this stack displays or reads it (inert) + fix: add it to a view column or a form section, or remove the declaration + rule: field-no-consumers at objects[1].fields.description — `os explain field-no-consumers` for what counts as a consumer + ``` + + The finding's `message` no longer restates its `where`, and no longer carries the list of consumer and carrier kinds, the exemptions or the scanned roots; `hint` is the fix alone (for a `carrier-only` verdict it still names each carrier site a removal must clean). The `verdict`, `carriers` and `rootsScanned` fields of a `field-no-consumers` finding are unchanged. A tool that matched the old message text should match on `rule`, `where` and `path` instead. + + `security-owd-unset` also changes on the runtime wire. It runs at the metadata write door's publish gate for `object` writes (Studio, REST `/meta`, MCP), and an active write of a custom object (neither `isSystem: true` nor `sys_`-named) with no `sharingModel` is refused with a 422 whose `security-owd-unset` issue now carries the message `custom object declares no sharingModel (OWD); the runtime falls back to 'private', but the baseline must be an authored decision` and the hint `declare sharingModel: 'private' (owner + shares; recommended), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children)`. The old message named the object, which the issue's `where` and `path` still carry, and told the leave_request incident, which `os explain security-owd-unset` now prints. + - **`os explain `.** `os explain` takes an author-time rule id as well as a schema name — `os explain field-no-consumers`, `os explain security-owd-unset` — and prints the reasoning the warning no longer carries; `--json` prints `{ rule, covers, paragraphs }`. A schema name resolves exactly as before. With no argument it also lists the rule ids that have an explanation (`--json` adds `rules: [{ id, covers }]`). The `rule:` line names the command only for those rule ids. An argument that is neither still exits 1, and its error string changes from `Unknown schema: "X"` to `Unknown schema or rule id: "X"`, followed by a second list, `Rules with an explanation: …`; the `--json` `error` field changes the same way, from `Unknown schema: X` to `Unknown schema or rule id: X`. + - **`os explain constructor` (or `__proto__`) is refused as an unknown id** instead of printing `Schema: Object … undefined` and crashing with `schema.required is not iterable`: both lookups read own keys only. + - **`os validate` prints the `fix:` and `rule:` lines** under each author-time warning, the way `os build` does, and the hint line under every author-time finding (`os build`, `os validate`, `os verify`, `os init`) is now labelled `fix:`. `os lint` adds the same `os explain` pointer to its rule line. + - **The `fix:` line is always a fix.** `expression-invalid` no longer puts the authored source in `hint`, where it printed as `fix: source: …`: the source now ends the finding's `message` as `` — source: `…` `` (the spelling the flow engine's runtime refusals use), so the CLI verdict line still carries it, and so does the issue `message` at the runtime publish gate for `flow`, `action`, `hook` and `object` writes (Studio, REST `/meta`, MCP): in the 422 for an `error`, in the 2xx `advisories` for a `warning`. Its `hint` is empty, so no `fix:` line prints and the runtime issue's `hint` is `''`. `component-props-invalid`, `flow-time-relative-descriptor-invalid`, `react-prop-missing-required` (where the component contract describes the binding) and `liveness-experimental-property` carried context in `hint`; each now leads with the instruction, and `component-props-invalid`'s message states its consequence (nothing refuses it today, so the renderer receives the props as written). Two of the four run at the runtime publish gate. `flow-time-relative-descriptor-invalid` is an `error` on `flow` writes, so its new hint reaches the 422 issue `hint`. `liveness-experimental-property` runs on `email_template`, `mapping` and `datasource` writes as a `warning`, so its hint would ride the 2xx `advisories`, but none of those three ledgers has an `experimental` row today, so it reaches no runtime response yet. `component-props-invalid` is CLI-only, and `react-prop-missing-required` judges no `page` write at the gate, so their new text reaches the CLI only. + - **New exports in `@objectstack/lint`:** `RULE_EXPLANATIONS`, `explainRule(ruleId)` and the type `RuleExplanation`, from the root entry and from the import-free `@objectstack/lint/rule-explanations` entry. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- f89b5f5: feat(i18n): a screen field's option labels and a flow's terminal toasts have translation keys (`flows..screens..fields..options.`, `flows..successMessage` / `.errorMessage`) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. Two options of one screen field whose values read the same as text — `1` and `"1"`, `true` and `"true"`, or a repeated value — are now refused when the flow is parsed (`FlowSchema`, `objectstack validate`, the metadata save door, `registerFlow`), at the second option's `value`. Neither the option's label translation (keyed by the value as text) nor the console's select can tell such a pair apart. To fix a refused flow, give each option of the field a value that stays distinct as text, or drop the duplicate. Measured reach in this repository: 0 — no flow in its examples carries such a pair. + + - **What is new.** A fully translated screen flow no longer has to keep its select options and its completion or failure toast in the source language. Translate an option's label under `flows..screens..fields..options.`, and the flow's own toasts under `flows..successMessage` and `flows..errorMessage`. + - **How an option is addressed.** By its value read as text: `String(value)`, so the option `{ value: 1, label: 'Tier 1' }` is translated under `options.1` and `{ value: true, … }` under `options.true`. `flowScreenFieldOptionKey(value)` (`@objectstack/spec/automation`) spells the key. Not by position (reordering options would re-point every translation), and not by an object field's options (a screen field declares no such binding). + - **One new refusal**, the narrowing the BREAKING note above describes: option values of one screen field must stay distinct as text, because the label translation is keyed by them. + - **The toasts translate only where the flow authors one.** A bundle cannot add a toast to a flow that declares none; `objectstack validate` refuses such a key (`translation-target-unknown`, error). + - **Where it is read.** `translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn. + - **`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`). + - **`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them. + - **A screen's `description`** is keyed too, in the same release, and translated by the engine rather than the console: see the entry for `flows..screens..description`. +- d25b187: feat(cli): `os i18n extract` and the coverage gate scaffold and demand a screen's `description` + + Clause-②: yes (widening) + + - `os i18n extract` now writes `flows..screens..description` into the skeleton for every screen that authors body text, seeded with the authored `{{ }}` template so a translator keeps its holes. + - The coverage gate demands it in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each screen that authors a `description`, until it translates it. A screen with no `description` is never asked for one. +- cc305a3: feat(cli): `os migrate organization-ownership` — the read-only plan of the v18 organization-ownership ceremony (ADR-0131 D10, ceremony item 1) + + Clause-②: yes (widening) + + A new command beside the `os migrate` family. It reads the database and writes a plan file the operator keeps; it never changes a row or a column, and it has no writing mode yet. + + - **Per table:** the object's fate from the ADR-0131 D10 inventory (column drop, mirror deletion, attribution through a parent anchor, or report) with its citation, the row counts that fate touches, the rows whose owner cannot be derived (listed by id, with the reason), and whether the table will receive the `NOT NULL` constraint. + - **Ruled populations are counted by name:** the organization-scoped `sys_metadata` rows that the ceremony promotes, with their conflict list; the customized email templates; and the global settings rung that moves to `sys_platform_setting`. + - **Refuses, naming the table:** a table it cannot enumerate. That covers an unsupported driver, a read that fails, a column the fate needs that the table lacks, a platform table the inventory gives no fate, a name it cannot quote, and a database with no `sys_organization`. ⛔ It never reports such a table as empty. An inventoried object with no table on the database is listed as `absent`. + - **Under the `single` posture,** a NULL row that no anchor derives is attributed to the Default Organization (slug `default`). Under a walled posture it is reported. + + ```bash + os migrate organization-ownership # writes organization-ownership-plan-TIMESTAMP.json + os migrate organization-ownership --out plan.json --json + ``` + + The plan file is never overwritten. Nothing about bare `os migrate` (the schema-drift plan) or any other subcommand changes. +- 1eff322: fix(cli)!: `objectstack build` refuses to lower a hook body that reaches `ctx.dispatch.scope` or `ctx.submitted` (#22810) + + Clause-②: no (narrowing) + + The sandboxed body face does not carry either member, and the spec says so: `HookContextSchema.dispatch` gives a body `{ mode, index }` "and not `scope`", and `submitted` is "NOT marshalled into the sandboxed `body` face". The build lowered such a handler anyway, at exit 0, with no warning. Measured through `bootStack`'s artifact door: a body writing `ctx.dispatch.scope.` threw `TypeError: cannot set property … of undefined` on every insert, so REST answered `500 INTERNAL_ERROR` and stored nothing, while the same handler in-process answered `201`. A body reading `ctx.submitted` saw `undefined` on an update, where the in-process handler sees the caller's submission, so a guard written on it evaluated differently with no error at all. + + The hook body extractor (`extractHookBody`) now refuses both members as forbidden tokens, the way it already refuses `.sudo(` and `.create(`. `os lint` calls the same extractor, so it reports them too. + + **BREAKING — what moves for consumers.** + + - **`objectstack build`.** A handler that writes or reads `ctx.dispatch.scope`, or reads `ctx.submitted`, was lowered to a `body` that fails in the sandbox. It is now bundled into the runtime module (`objectstack-runtime.*.mjs`) instead, with a warning naming the hook and the member, and the build still exits 0. Where that module is served beside the artifact the handler runs in-process, so `scope` and `submitted` are real again: measured, the same insert answers `201` and stashes. A deployment that serves `objectstack.json` alone has no function to bind the hook to, so it logs the hook as refused at boot and the hook does not fire. + - **`objectstack build --strict-body`.** Such a handler built at exit 0. It now fails the build (exit 1), naming the hook and the member. + - **`os lint`.** It said nothing. It now reports a `hook-body/bundled-fallback` warning at `hooks[i].handler`. + - **The spellings refused.** Direct and optional (`ctx.dispatch.scope`, `ctx?.submitted`), bracket (`ctx.dispatch['scope']`), through an alias (`const d = ctx.dispatch; d.scope`, `const c = ctx; c.submitted`), and destructured (`const { scope } = ctx.dispatch`, `const { submitted } = ctx`). For `scope` the body must also name `dispatch` somewhere, because `scope` is an ordinary field name. + - **Not refused.** `ctx.dispatch.mode` and `ctx.dispatch.index`, and a field that happens to be named `scope` or `submitted` read through `ctx.input` or `ctx.previous` (`ctx.input.scope`). A body reading such a field off a local row variable (`row.submitted`), or spelling `x.submitted` inside a string, is refused too. That is the over-refusal side: the handler is bundled and keeps working. + + **The one-line fix**, for a handler you want shipped as a body: do the cross-dispatch work once at `ctx.dispatch.index === 0`, or keep the state in the record through `ctx.api`, instead of stashing on `ctx.dispatch.scope`; read the stored row on `ctx.previous` instead of `ctx.submitted`. A handler you are content to run bundled needs no change. + + **Unchanged.** No spec key, export, type or stored shape moves. The sandbox still marshals exactly what it did: `{ mode, index }` for `ctx.dispatch`, no `ctx.submitted`. An in-process handler, a top-level `functions:` entry referenced by name, and a hook that carries an explicit `body` are not lowered here and are not judged. + + +- 6d36017: fix(cli)!: `objectstack build` rebinds a hook handler's parameter to the sandbox's `ctx` instead of dropping it, and refuses the parameter lists it cannot rebind (#22837) + + Clause-②: yes (narrowing) + + The lowering ships a hook handler's statements as `body.source`, and the runtime runs a body as `(async (ctx) => { SOURCE })`, so `ctx` is the only name a body is given. The handler's parameter list was dropped. A handler written `async (hookCtx) => { hookCtx.input.note = … }`, or `async ({ input }) => { input.note = … }`, lowered at exit 0 with no warning, and its body threw `ReferenceError: 'hookCtx' is not defined` on every run. Measured through `bootStack`'s artifact door: REST answered `500 INTERNAL_ERROR` and stored nothing, while the same handler in-process answered `201`. + + The hook body extractor (`extractHookBody`) now re-emits the handler's one parameter as the body's first statement, `var hookCtx = ctx;` or `var { input } = ctx;`, so the handler's own code runs as written. Both boots of the door above now store the same row. `os lint` calls the same extractor, so it agrees. + + **BREAKING: what moves for consumers.** + + - **A hook handler whose parameter is not named `ctx`.** It was lowered to a body that failed in the sandbox. It is now lowered to a body that runs. A handler whose parameter is `ctx`, or that takes none, lowers byte-for-byte as before. + - **A hook handler whose parameter list cannot be rebound.** This covers more than one parameter, a default value or a rest parameter on it, a destructuring pattern that binds the name `ctx`, a body that declares its own `ctx` with `let` / `const` / `class` / `function`, and a destructured `log` / `crypto` / `title` / `api` taken apart further than one plain name (capability inference cannot follow that). Such a handler was lowered to a body that failed, or that ran with a parameter the sandbox never bound. It is now bundled into the runtime module (`objectstack-runtime.*.mjs`), as `.sudo(` and `fetch(` already are, with a warning that names the hook and the parameter. `objectstack build` still exits 0, `objectstack build --strict-body` now fails on it (exit 1), and `os lint` reports `hook-body/bundled-fallback` at `hooks[i].handler`. A handler taking `(ctx, extra)` is refused even when `extra` is unused: an in-process hook handler is only ever called with the context. + - **A hook handler reaching `ctx.provenance`, `ctx.ql` or `ctx.transaction`.** These are the rest of the in-process `HookContext` that the sandbox does not carry. A body reads `ctx.provenance` as `undefined`, so a guard on `ctx.provenance.flowRunId` evaluated as if the write had no origin. `ctx.ql` (the engine) and `ctx.transaction` (a driver handle) are live host objects the sandbox cannot hold. Such a handler is now bundled, with the same warning, build exit code, `--strict-body` failure and lint rule as above. `ctx.api.transaction(fn)`, `ctx.api.object(name)` and a field merely named `provenance`, `ql` or `transaction` read through `ctx.input` or `ctx.previous` are not refused. + - **A member destructured in the parameter list now meets the refusal the body spelling meets.** `async ({ input, submitted }) => …` and `async ({ dispatch: { scope } }) => …` were lowered (and failed). They are now refused like `const { submitted } = ctx`. + - **A method-shorthand handler with exactly one nested function** (`async handler(ctx) { … rows.map((r) => r.id) … }`) is now judged on the method rather than on the nested function. A method-body name that nested function closes over was reported as a free identifier, and the handler was bundled. It now lowers. A module-scope helper called outside the nested function went unreported, and the handler lowered and threw `ReferenceError`. It is now refused and bundled, like any other module-scope reference. + - **The published `extractHookBody` (`@objectstack/cli/hook-body`) is the hook face.** Its signature is unchanged. An action's `(input, ctx)` target passed to it is refused for its second parameter, where `objectstack build` lowers it unchanged: an action body runs in its own `(input, ctx)` wrapper and gets no rebind. + + **The one-line fix**, for a refused handler you want shipped as a body: take the context as the one parameter (under any name, or destructured), drop the default, and read the member you need through `ctx.api` or `ctx.previous`. A handler you are content to run bundled needs no change. + + **Unchanged.** No spec key, export, type or stored shape moves, and `extractHookBody`'s published signature and refusal kinds are the same. The sandbox still marshals exactly what it did. An action target's lowering is unchanged. An in-process handler, a top-level `functions:` entry referenced by name, and a hook that carries an explicit `body` are not lowered here and are not judged. + + +- 6364375: fix(cli)!: `objectstack build --strict-body` counts each callable once, and `--json` `issues` lists callables only (#22839) + + Clause-②: no (narrowing) + + `--strict-body` printed "N callable(s) lack a metadata body" with N one higher than the callables it listed: one refused handler read 2, four read 5. The list was the extractor's refusals plus one extra row whenever any callable was not lowered, with `origin: ''` and the reason "N callable(s) lowered to handler ref but produced no body". The header counted that extra row as a callable, and `--json` published it in `issues` beside the real ones. + + The list is now one row per callable that was not lowered into a metadata body, each callable once. It is the population the extra row's number was meant to count, itemized: in the two shapes below that number undercounted it. The header's N is the number of rows. + + **BREAKING — what moves for consumers.** + + - **`--json` `issues`, on the `strict-body: missing body` exit.** Before: the refusals plus the `` row. Now: callables only, and no row has the origin ``. A handler the extractor refused keeps its row unchanged (`origin`, `reason`, `kind`, `freeIdentifiers`). A callable that was never tried now gets its own `{ origin, reason }` row instead of being folded into the aggregate's number: a top-level `functions` entry (`function 'name'`, which has no `body` slot), or a hook or action whose author wrote its own `body` beside an inline function (that function still rides the runtime module). + - **The header.** It reads the number of rows listed. For one refused handler that is 1, not 2. + - **A stack that carries the same callable at the top level and inside a `packages[i].manifest` body.** Such a stack is walked twice, so a refusal was listed twice and a lowered body was counted twice. Each callable is now listed once. A `functions` entry in such a stack beside a lowered hook was hidden by the double count, so the build passed `--strict-body`; it is now refused, and named. + - **A stack that binds one function as the handler of two or more hooks or actions, beside a `functions` entry or a hook or action with an authored `body`.** This happens in a single walk too. The shared function was lowered at each of its slots, and each slot counted as one lowered body, while the function counted as one callable. The surplus cancelled the callable beside it that was never lowered, so the old count read 0, no warning was recorded, and the build passed `--strict-body`. Such a stack is now refused, and the row names the `functions` entry, or the hook or action whose author wrote its `body`. + + **The one-line fix**, for a script that read the `` row: read `issues.length` for the number of callables, and the rows themselves for which ones. + + **Unchanged.** For a stack in which each callable is bound at one slot, `--strict-body` refuses exactly the stacks it refused before. Every refusal keeps the same exit code (1) and the same `error` string. The default build (no `--strict-body`), its warning lines, `bodyExtractionWarnings` in the success payload and the runtime-bundle decision do not move. No spec key, export, type or stored shape moves. + + +- aa94566: fix(cli)!: `objectstack build` writes the runtime module whenever a callable still needs it, and its warning counts each refused handler once (#22853) + + Clause-②: no (narrowing) + + `objectstack build` decided whether to write `objectstack-runtime.*.mjs` from the number of callables minus the number of slots lowered into a metadata body. The first counts each callable once and the second counts each slot, so a callable lowered at two slots cancelled a `functions` entry beside it. The build then printed `Skipping legacy runtime bundle (all 2 callables are body-only)`, exited 0 and wrote `dist/objectstack.json` alone, and the `functions` entry shipped with no implementation. Three shapes reached it: + + - the same hook at the top level and inside a `packages[i].manifest` body, beside a `functions` entry; + - one handler shared by two hooks, beside a `functions` entry; + - one function bound both as a hook handler and as a `functions` entry. + + The module is now decided from the list `--strict-body` counts: one row per callable the runtime module has to carry, each callable once. A slot that has no body, which is a `functions` entry or a hook or action whose extraction was refused, keeps its callable on that list even when another slot lowered the same function. The default build's warning (`N handlers could not be lowered to a metadata body`) and its summary line now count the refused handlers on that list, each once. + + **BREAKING: what moves for consumers.** + + - **`objectstack build`.** It now writes the runtime module for the three shapes above, and for a function bound at a refused slot and at a slot that lowered it. Every stack it wrote the module for before still gets one. + - **`objectstack build --no-runtime-bundle`.** Those stacks are now refused (exit 1). They passed at exit 0 while the `functions` entry, or the refused hook, had no implementation. The message's first number counts the callables that lack a body, and its second counts the refused handlers, each once. + - **`objectstack build --strict-body`.** A stack that binds one function at a slot that lowered it AND as a `functions` entry or at a refused slot is now refused, and the row names the `functions` entry or the refused slot. It passed before. For a function bound at several slots, the row in `--json` `issues` is the extractor's refusal when any slot refused it, then the `functions` entry's row, then the authored-`body` row. Before, it was the first such slot's row. + - **The default build's warning count.** A refused handler shared by two hooks, or carried at the top level and in a package body, counts as one handler. It counted once per slot. + + **The one-line fix**, for a CI step that runs `--no-runtime-bundle` on a stack carrying a `functions` entry: drop the flag. A `functions` entry has no metadata body, so it runs only from the runtime module. + + **Unchanged.** For a stack in which each callable is bound at one slot and walked once, the module decision, `--no-runtime-bundle` and `--strict-body` give the same verdicts as before. Exit codes and `error` strings are the same. `--json` `bodyExtractionWarnings` still carries one record per refused slot. No spec key, export, type or stored shape moves. + + +- a959493: `os migrate meta --write` writes the chain's mechanical changes into the authored source files, in place, at every site it can prove + + Clause-②: yes (widening) + + - A new flag on the authored-source mode: `os migrate meta --from N --write`. Without it nothing changes: the dry run, its report and its `--json` payload are what they were, and `--out` still writes its snapshot. + - What it writes: each mechanical change the chain applied (`applied`), at a site it traces to one object or array literal in one project file — through `define*` calls and the `.create(…)` factories `@objectstack/spec` exports, module-level `const` bindings, relative imports and re-exports, and `Object.values()` over a namespace import — when the loaded value matches that literal and nothing else references the bindings on the way. Only that site's bytes change: a renamed key keeps its value and its comments, a removed key takes its own line(s), and every other byte (comments, formatting, key order) stays as it was. + - What it refuses, each change listed with the reason (`--json`: `write.manual[].kind`): `computed`, `helper`, `spread`, `shared`, `outside-project`, `mismatch`, `injected`, `unspellable`, `layout` and `unattributed`; and `entangled`, because a conversion's edits are written whole or not at all. + - What it never writes: the semantic changes (`todos`), which stay listed exactly as before, and a site a conversion declines, for which no mechanical change exists. + - After writing it re-runs the chain over the written sources. Unless the re-run applies exactly the changes it left, it restores every file it wrote and exits 1. + - `--json` gains a `write` key, only with `--write`: `status`, `files`, `written`, `manual`, `unexplained` and `verification`. + - `--write` is exclusive with `--stored`; `--stored --apply` is unchanged. + +### Patch Changes + +- d112087: docs: the suggested-binding acceptance is deployment-level and takes effect + + Clause-②: no + + Text only. These are the published sentences that describe what confirming a package's `isDefault` suggestion does. They now match the behaviour `@objectstack/plugin-security` ships in this release. + + - `@objectstack/spec`: `ISecurityService.listAudienceBindingSuggestions`, `confirmAudienceBindingSuggestion` and `dismissAudienceBindingSuggestion` keep "writes the binding" for the confirm. The text now also says what that means: a `manage_metadata` holder accepts once for the deployment, `everyone`'s definition is derived again, and a dismiss on an accepted suggestion revokes it. The package-level `isDefault` note keeps "an admin confirms in Setup" and adds who that admin is. + - `@objectstack/client`: the `security.suggestedBindings.confirm` and `.dismiss` TSDoc. + - `@objectstack/rest`: the route summaries of the confirm and dismiss routes. Paths and envelopes are unchanged. + - `@objectstack/cli`: the ownership-inventory citation for `sys_audience_binding_suggestion` now says the record is deployment-level and owned by the Default Organization. +- 6a06e3b: A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots, one dialect per slot, with a remedy for every spelling. The run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. + + **What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. + + **The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. + + **Still accepted, unchanged.** A CEL value envelope and every literal. `{$User.*}` keeps resolving where the single-brace dialect still lives, a CRUD `filter` value. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | + | `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | + | `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | an `assignment` of `uid: { dialect: 'cel', source: 'current_user.id' }`, a `get_record` on `sys_user` with `filter: { id: '{uid}' }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | + | `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | + | a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | nothing when the run has a user; where the flow can run without one, compute `current_user != null ? current_user.id : null`, which renders nothing where the template did | + + **The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** + + **Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}`, a `notify` `recipients` entry, is migrated to `current_user` with the `notify` value slots on this line. Other repositories and deployed metadata were not measured here. + + ### The kit + + - **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. + - **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, the hint at a node's literal names the value itself, and every other position keeps the single brace. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope and `current_user`. +- bafb58b: `os serve` (and `objectstack start`, which runs it) now says at boot when an app's branding logo or favicon will not be served. Before, the runtime assets route was skipped without a word when its directory was absent, so an artifact booted outside its project directory drew a broken logo and favicon and nothing in the boot output said why. + + Clause-②: no + + - Once the boot settles, every loaded app whose `branding.logo` or `branding.favicon` is a root path under `/runtime/assets/` that the route will not serve gets one warning line per file. The line names the apps and keys that use the file, the directory searched, and whether that directory came from `OS_RUNTIME_ASSETS_DIR` or the `assets/` default under the working directory. When the directory does not exist, the line says so and says nothing under `/runtime/assets/` is mounted for this run. + - The apps read are the ones the console is served, through the same metadata protocol read that `GET /api/v1/meta/app` answers from. Config boots and artifact boots are both covered. + - Whether a file is servable is decided by the route's own filename resolution, so the warning and the route cannot disagree. Absolute URLs, protocol-relative URLs, data URIs, relative paths and other root paths are not checked. + - The line goes through the kernel logger at `warn`. It shows in the banner's *Boot diagnostics* block, streams live at `--log-level debug` or `info`, and is hidden at `error` or `silent` like every other boot warning. + - ⛔ What `/runtime/assets/*` serves does not change. No route is added or removed, and the artifact still carries no asset files: ship the `assets/` directory beside it, or point `OS_RUNTIME_ASSETS_DIR` at the files. +- 4935c66: The startup banner prints one line per warning class, each warning appears once, and a localhost boot no longer warns that OAuth is unencrypted. + + Clause-②: no + + - **One line per class.** Flows that declare a trigger but are not bound are grouped by trigger type and reason, with the flows listed: `⚠ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. Before, the full reason (up to ~650 characters) printed once per flow. The banner now shows the reason's first sentence; `--log-level debug` still streams each flow's full reason. A real binding failure, or a missing trigger, keeps its own line, worded as before. + - **Printed once.** *Boot diagnostics* no longer repeats the automation plugin's per-flow `… is NOT bound` and shadowed-flow warnings, which the banner's `Flows:` list already shows. Its header counts them instead: `(8 more already listed above)`. Every other boot warning replays exactly as before. A boot that fails before the banner still replays all of them. + - **OAuth on loopback.** `OAuth is served UNENCRYPTED: …` is logged at `info` when the issuer's host is loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), so it is not shown at the default `warn` level. It stays `warn` on a private or link-local issuer. The sentence and the transport rule are unchanged. + - ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the transport rule, the service-automation warning an embedded host reads, and every public key, export and parameter are unchanged. +- 54ace18: fix(cli): `os migrate meta --out FILE` writes its snapshot on a range that crosses no step (#22116) + + Clause-②: no + + `os migrate meta --from 18 --to 18 --out FILE` exited 0, printed no snapshot line and wrote no `FILE`. The same command with `--json` wrote `FILE`. The human report returned early on a run with nothing to migrate, and that return came before the `--out` write. An operator or a CI step that keeps `FILE` as the record of the run then found no file, or read an earlier run's file as this one's. + + - The human mode now writes `FILE` and prints the line that names it on every run. On a run with nothing to migrate, the line comes after the range answer, before `--write`'s outcome and the data migrations, the same order as on every other run. + - The bytes are the ones `--json` writes for the same run: the stack the chain returned, which for a range with no step is the stack as loaded. + - The fix sits in the branch both "nothing to migrate" answers share. A range with steps that applies and lists nothing takes the same branch, but no range reaches it on this build, because every major carries semantic notices and the chain lists them all. + - Unchanged: the `--json` mode, `--write`, `--stored` and the chain. A range with steps writes `FILE` exactly as before. +- 5accac4: fix(cli): the "Console dist not found" boot warning names the remedy that works where the server runs (#22155) + + When the Console package the server resolves has no built `dist/`, the boot warning used to say `install @object-ui/console (already built) or run pnpm --filter @object-ui/console build in the objectui workspace`, wherever it ran. The first remedy works nowhere, because the resolver never looks for an installed `@object-ui/console`. The second needs an objectui checkout, which a fresh clone of the framework repository does not have; there the Console is built by a root script. + + The warning now names one remedy. It picks it by asking whether the nearest `package.json` above the resolved console package declares an `objectui:build` script: + + - **In the framework repository** (the root manifest declares it), the warning gives the dist path, says that `pnpm build` does not produce it, and names `pnpm objectui:build` with the directory to run it in. + - **Anywhere else**, such as a project that installed `@objectstack/cli`, the warning gives the dist path and says the Console ships prebuilt in `@objectstack/console`, a dependency of `@objectstack/cli`. The remedy is to reinstall that package at the CLI's version. + + Only the text changes. The Console mounts, or is skipped, exactly as before. +- ea4aa5c: `os dev --help` and `os start --help` name `OS_PORT` for `--port`, and `os init --no-install` names the package manager it resolved in its Next steps. + + Clause-②: no + + - **`os dev --port`.** The help text read `Server port (overrides $PORT)`, though `os dev` reads `OS_PORT` first and `PORT` only as its legacy alias. It now reads `Server port (overrides $OS_PORT; $PORT is the legacy alias)`. Which variables are read, and in what order, is unchanged. + - **`os start --port`.** The same drift: the help text read `Port to listen on (overrides $PORT, default 3000)`, though `os start` reads `OS_PORT` first as well. It now reads `Port to listen on, default 3000 (overrides $OS_PORT; $PORT is the legacy alias)`. The port behaviour is unchanged. + - **`os init --no-install`.** The package manager was resolved only when an install ran, so a `--no-install` run always printed `npm install` and `npx objectstack …`, even with `--package-manager pnpm` or when invoked through pnpm. It is now resolved before the install step, the same way as before (the `--package-manager` flag, then the invoking package manager, then npm), so `os init my-app --no-install --package-manager pnpm` prints `pnpm install` and `pnpm exec objectstack …`. A run with no flag, invoked through npm or `npx`, still prints `npm install`: the scaffold supports npm, yarn and bun as well as pnpm. +- 11e6fc5: *Boot diagnostics* puts the one record you have to act on first, with its fix, counts it apart from the informational ones, and no longer prints stack traces at the default log level. + + Clause-②: no + + - **The record that needs you comes first, with its fix.** A script action declared with no handler (a button wired to nothing) prints first in *Boot diagnostics*, highlighted, naming each action, with its fix on the next line. A blank project with one such action shows: + + ```text + ⚠ Boot diagnostics — 1 needs your attention · 2 informational: + ⚠ [action-governance] declared script actions with NO handler — a button wired to nothing (ADR-0078): support_desk_ticket:resolve_ticket + fix: add a `body`, or register a handler under the declared `target` + ``` + + Every other record prints once, dim, as before, and the header counts it as informational. When nothing needs your attention the header reads `ℹ Boot diagnostics — 4 informational:`. This record class is the only one highlighted so far. + - **No stack traces at the default level.** A record that carries an error's stack trace prints with its message and without the trace. The block's last line says how many traces were withheld. At `--log-level debug` the boot streams live, and the same record prints there with its stack. + - ⛔ Nothing you author changes, and no warning's log level changes. The package that logs a warning sets its level. +- 31cd210: A new project's `pnpm-workspace.yaml` is now a short settings file: one comment line above each block instead of 57 to 66 lines of version history and measurements. `npm create objectstack`, `objectstack init` and a standalone `objectstack create` now write the same 21-line file. + + Clause-②: no + + - The settings are unchanged: `packages: []`, the same `onlyBuiltDependencies` and `allowBuilds` lists, and the same `peerDependencyRules.allowedVersions` entries, in the same order. A project installs exactly as before. + - Each block keeps one line that says why it is there: which pnpm versions read each build-approval key, and that the peer rules only silence a warning that was measured harmless. + - The measurements behind each setting now live in the `@objectstack/cli` source next to the values they explain, so they are kept up to date there instead of in every project. + - Already-scaffolded projects are not touched. Their longer comments can be deleted or kept; the settings are identical. +- b9e6e5c: fix(cli): `os doctor` no longer tells a freshly scaffolded project that it is running in production (#22163) + + Clause-②: no + + - **What changed.** With `NODE_ENV` unset, in a directory whose `objectstack.config.ts` boots as itself, the `NODE_ENV` row now reads `✓ Not set — os dev runs this project as development; os start forces production`, with no fix text. That covers a config alone, and a config beside its own compiled `dist/objectstack.json`, which the first `os dev` writes. Before, the row read `⚠ Not set — this environment is being treated as production` and, under `--verbose`, told the operator to set `NODE_ENV=development`, which `os dev` already sets. + - **What did not change.** In every other directory, the `⚠ … treated as production` warning and its fix text are the same as before. That includes a config beside `OS_ARTIFACT_URL` or beside an `OS_ARTIFACT_PATH` naming another artifact, an artifact with no config, and a directory with neither. `doctor` reads the two variables the way `os dev` does: from the process environment over the `.env*` files loaded for development. + - **Nothing else moves.** The `Environment files` row still reports the `.env*` cascade for `node_env=production`. The `/discovery` `environment` default is unchanged, and so is the way `os serve`, `os start` and `os dev` resolve the mode. A `NODE_ENV` that is set still prints no row, and `doctor`'s exit code is unchanged. +- 83e7ae9: `os build`: a multi-package artifact's flat `src/docs/` is now carried by the package that owns the artifact's manifest, so its boot no longer warns about them + + Clause-②: no + + A multi-package artifact (one that carries `packages[]`, ADR-0130 D4) keeps its metadata in its package bodies only. `os build` still wrote the stack's own flat `src/docs/*.md` to the artifact's top level. At every boot, the metadata service registered them under the artifact's `manifest.id` and logged `carries N top-level metadata item(s) that none of its N package bodies declare`. The warning told the author to rebuild the artifact, which did not help: in an ADR-0130 layout the app package's source directory (for example `src/sales/`) is not named after the package, so no docs directory the build reads belongs to it. + + - **What changes.** When exactly one `packages[]` entry has the artifact's `manifest.id` as its id, `os build` writes the flat docs onto that entry's body, after any docs from that package's own `src//docs/`. The artifact's top level no longer carries them. `composeStacks(…, { manifest: 'preserve' })` always produces this case, because the artifact's `manifest` is one of its inputs' manifests. + - **What stays the same.** The docs are served under the same package id, the doc count is the same, and books resolve the same tree. The doc lint still checks their names against `manifest.namespace`, and the `Collecting package docs` step line prints the same count. + - **What a running instance now reports differently.** These docs are now part of the owning package's installed record (`GET /api/v1/packages`) and are protected like that package's other docs (`lock: 'full'`, resettable), the same as the flat docs of a single-package artifact. Before, they were registered outside any package record and read back as freely editable. + - **When nothing moves.** If no entry has the manifest id, if several do, or if the artifact declares no `manifest.id`, the flat docs stay on the top level as before. A single-package artifact (no `packages[]`) is byte-identical to before. + - **To pick it up.** Rebuild with `os build`. An artifact that was already built keeps its shape, and the warning, until it is rebuilt. +- dc4a5c6: fix(cli): `os serve --no-server` with `OS_MIGRATE_AND_EXIT=1` now creates every table the same config's server boot registers, `sys_import_job` included + + Clause-②: no + + - **What was wrong.** `serve` composed the REST API plugin only with the HTTP server on. That plugin's `init()` registers `sys_import_job`, the object the async-import routes write to, and schema sync creates tables only for the objects registered in that boot. So a migrate-and-exit run with `--no-server` created one table fewer than the server boot it prepares for. A deployment that migrated "kernel only" and then served with schema sync off had no import-job table for the async-import routes to write to. + - **What changes.** `serve` composes the REST API plugin on every boot, as it already composes the storage, settings, sharing and auth plugins. With `--no-server` its object registers. With no HTTP server in the boot, its `start()` mounts no route and prints one `warn` that the server is absent, as those plugins do. On `examples/app-todo` the `--no-server` run now creates 70 tables, the same set as the server boot. + - **What does not change.** `--no-server` still adds no HTTP server plugin and no dispatcher. A server boot composes the same plugins in the same order as before. The no-auth boot refusal still applies only with the server on. + - **One composition behaves differently.** A config that puts its own HTTP server plugin in `plugins` and runs `--no-server` now gets the REST routes on that server, as it already got the settings and auth routes. Anonymous data access stays denied there: an unauthenticated `GET /api/v1/data/OBJECT` answers `401`. + - **If you worked around it** by dropping `--no-server` from your migration step, either form now provisions the same schema. +- 9f0de32: fix(cli): `os migrate meta --from N --write` rewrites the declared protocol range the load refuses (#22219) + + Clause-②: no + + The load refuses a manifest whose `engines.protocol` excludes the runtime's protocol major, and the refusal names `objectstack migrate meta --from N` as the command that resolves it. `--write` wrote the chain's mechanical changes and left `engines: { protocol: '^N' }` as it was, so an author who followed the refusal, wrote, and reloaded got the same refusal back. A manifest with nothing to convert was not written at all. + + - `--write` now also rewrites the declared range when the load would still refuse the migrated source under it, in the scaffold's spelling: `'^16'` → `'^17'` on a protocol-17 runtime. It rides the same plan, write and re-check as the chain's changes: a range literal the command cannot trace to one site is left with the reason, and a re-check that disagrees restores it with every other file. + - A manifest with nothing to convert gets the range edit alone. + - The major is `--to`, capped at the protocol this runtime implements. `--to` defaults to the highest major this build carries a step for, which can run ahead of the runtime: on a protocol-17 build, `--from 16` replays 16 → 18 and writes `'^17'`. + - The range is rewritten under the key the load read it from: `engines.protocol`, else `engines.platform`, else the legacy `engine.objectstack` (written as `'^17.0.0'`, the only form that key accepts). + - Left alone: a range the load already admits, an absent or unrecognised range, a range at or above the target major, and a range below `--from` (the report names the `--from` that moves it). + - The dry run (no `--write`) names the range edit `--write` would make. With `--json`, `write.range` reports the edit: `status`, `path`, `from`, `to`, and `file` / `line` when written or `kind` / `reason` when left. +- 86ae119: fix(cli): with no built Console, `/_console/` says the Console is not built and names the remedy, instead of answering a bare 404 (#22225) + + Clause-②: no + + When the Console package `os serve` resolves has no built `dist/`, the server printed the "Console dist not found" boot warning and mounted nothing. `GET /_console/` then answered the router's `ENDPOINT_NOT_FOUND` and `GET /` an empty 404, which look exactly like a wrong URL. + + Now, on that boot, the server mounts the Console's not-built answer on the routes the Console would own: + + - **`GET /_console/` and every path under it** answer `503` with a short plain-text body. It says the Console is not built, then gives the remedy the boot warning names: `pnpm objectui:build` in a framework checkout, or reinstalling `@objectstack/cli`'s `@objectstack/console` dependency elsewhere. The same function picks the remedy for both. The HTTP form leaves out the absolute paths on the host, which only the terminal shows. + - **`GET /` and `GET /_console`** redirect to `/_console/`, as they do when the Console is built. + + A built Console is served exactly as before, and `--no-ui` / `--no-console` still mount nothing. The `--ui` flag's help text and the README row now name `@objectstack/console`, the package the resolver reads, instead of `@object-ui/console`. +- a55fdc4: The Console served at `/_console/` now tells the browser how long it may keep each file, so a self-hosted console load stops downloading the whole SPA again on every visit. + + Clause-②: no + + - **Content-hashed build outputs** (a file directly under `assets/` named `name-hash.ext`, the hash being Vite's 8-character one) are sent with `cache-control: public, max-age=31536000, immutable`. A rebuild that changes the bytes emits a new name, so the browser never needs to ask again. Before this change, every non-HTML file was sent with a `content-type` and nothing else, so the browser could reuse none of the roughly 2,450 chunks. + - **Every other non-HTML file** gets `cache-control: public, max-age=300` and an `etag` computed from the file's bytes, and a matching `If-None-Match` is answered with `304` and no body. This covers the two stable-named maplibre files objectui copies into `assets/` (`maplibre-gl-worker.mjs`, `maplibre-gl-shared.mjs`) and the root files such as `favicon.svg` and `manifest.json`. These names can carry different bytes in the next console release, so they are never marked `immutable`. The `etag` is computed from the bytes, not the file's modification time, because a console dist extracted with `tar` gives every file the same fixed mtime. + - **HTML is unchanged**: the shell, a direct `index.html` and the SPA fallback are still rewritten per request and carry no lifetime. +- 6d2da32: `os validate`, `os build`, `os lint` and `os i18n check` now judge a multi-package app by its package bodies. A multi-package app that passed with a required capability that has no installable provider in this edition, or with a missing translation, now fails, as the same app shipped as one package always did. + + Clause-②: no + + An app composed with `composeStacks([a, b], { manifest: 'preserve' })` carries its metadata, its `translations` and its `requires` only inside each package's body, and nothing a package owns at its top level. These readers looked at the top level alone, so on such an app they found nothing to judge and answered clean: + + - **The capability preflight** (`os validate`, `os build`) now reads each package body's `requires` when the top level declares none. A capability with no installable provider in this edition fails the run with exit 1, and each finding names its package: `package 'com.acme.service' — Capability "ai" resolves to …`. The advisory for an installable but absent provider (for example `hierarchy-security`) comes back the same way. A top-level `requires` is read exactly as before, so a single-package app prints the same text it always did. + - **Translation coverage** (`os lint`, `os i18n check`) now expects the keys of every package, so a missing translation in any package is reported. Under `os lint --strict` it fails the run again. + - **`os i18n extract`** now extracts every package's keys, where it extracted none. + - **The undeclared-authoring-key warnings** that `os validate --json` and `os build --json` carry in `warnings` now cover items inside package bodies too. + + A `packages` value that is present but is not an array is now refused by `os validate`, `os i18n check` and `os i18n extract` with `INVALID_ARTIFACT_PACKAGES`, as the other readers already refuse it. `os i18n check` and `os i18n extract` used to accept such a config with exit 0 and zero keys; `os validate` refused it from the schema parse instead. + + Apart from that refusal, no accepted input, exported symbol or `--json` field changes. A single-package app's output is unchanged. +- 3054516: fix(cli): in the source checkout `os dev` serves, `os doctor`'s `Environment files` row names `os serve` / `os start` as the loaders of the `node_env=production` cascade it reports (#22249) + + Clause-②: no + + - **What changed.** With an `objectstack.config.ts` that boots as itself (alone, or beside its own compiled `dist/objectstack.json`), the `Environment files` row now says whose `.env*` cascade it resolved. A freshly scaffolded project with no `.env*` file reads ``✓ Environment files No .env* files here (node_env=production, the cascade `os serve` / `os start` load) — environment read from this process only — no environment input set``. When files are loaded, the row reads ``.env, … (node_env=production), the cascade `os serve` / `os start` load`` where it used to end ``the cascade `os serve` loads``. Before, the no-file form named no loader at all. That left `node_env=production` sitting directly above the `NODE_ENV` row that says `os dev` runs the project as development. + - **What did not change.** Doctor still resolves the cascade exactly as `os serve` does, for `NODE_ENV || 'production'`. So it reads the same `.env*` files and every env-derived verdict, the tenancy-posture refusal among them, is unchanged in every posture. Outside the source checkout, the row is unchanged byte for byte. That covers an artifact-only project, a config beside `OS_ARTIFACT_URL` or beside an `OS_ARTIFACT_PATH` naming another artifact, and a directory with neither. Doctor's exit code is unchanged. +- abd2545: The startup banner's `Flows:` section prints flows left unbound by the deployment's scheduled-work switch as information, not as a warning. + + Clause-②: no + + - **Information, not a warning.** Flows that declare a time trigger but are not bound because package-authored scheduled work is off on this deployment (`OS_AUTOMATION_SCHEDULED_WORK_ENABLED` unset — the default) now print dim, under `ℹ`: `ℹ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. The line's text, its flow list and its place among the other lines are unchanged. Before, it printed as a yellow `⚠`, though nothing about those flows needs fixing. + - **Every other reason keeps `⚠`.** A binding failure, a missing trigger, an unknown target object and a shadowed flow name still print as yellow warnings. The class is recognised only by the exact sentence the automation engine records for the deployment switch, so a reason that merely mentions the policy stays a warning. A host that turns scheduled work off with its own reason sentence also keeps `⚠`. + - ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the level `@objectstack/service-automation` logs at, *Boot diagnostics* and every public key, export and parameter are unchanged. +- d8d0711: fix(cli): `os migrate meta` lists and writes a conversion the load already applies inside a composed project's package body (#22256) + + Clause-②: no + + - **What was wrong.** In a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, each package body is assembled from the stack its input's `defineStack` call returns. That call runs the load-time ADR-0087 conversions, both when the schema accepts the input and when `os migrate meta` produces a refused input again in `strict: false` mode. So a spelling the load still converts inside a package body, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, and the run exited 0. + - **What changed.** `os migrate meta` now produces every input of the composition again from what its author wrote, with the load-time conversions skipped, before the inputs are composed. Composition then assembles each package body from the authored source. The conversion is listed under the body's path (for example `packages[0].manifest.datasources[0].driver`), `--write` writes it into the file that authored that input, and the next load converts nothing. This holds whether the input's `defineStack` call accepted its argument or was refused. + - **`@objectstack/spec`.** `defineStack` reads one internal parameter for this, which only the CLI passes: an undeclared, symbol-keyed entry on its options, honoured only together with `strict: false`, that skips the conversion pass. It is not part of `DefineStackOptions` and nothing new is exported. A strict call ignores it, and every call without it behaves exactly as before. + - **What did not change.** A project with nothing to convert gives the same `--json` summary and the same `--write` result and written bytes as before. This was measured on the four example apps, one of them composed, at `--from 16` and `--from 17`. Every other command still reads the stacks `defineStack` builds and the composition they make. + - **The `--out` snapshot of a composed project.** Each package body in it is now the body as written, with the chain's changes applied. That is what the snapshot of a one-package stack already is. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys. +- 1cb0edb: fix(cli): `os migrate meta` lists and writes a conversion the load already applies, on a stack the current schema accepts (#22256) + + Clause-②: no + + - **What was wrong.** When the current schema accepts a stack, `defineStack` runs its load-time ADR-0087 conversions while the config loads. `os migrate meta` then replayed its chain over a stack that was already converted. So a spelling the load still converts, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, the notice that sends the author to this command, and the run exited 0. + - **What changed.** The chain now starts from the argument the stack's `defineStack` call was given. It already did this for a stack the schema refuses. The conversion is listed, `--write` writes it into the source, and the next load converts nothing. + - **What did not change.** A stack with nothing to convert gives the same `--json` summary and the same `--write` result as before. This was measured on the four example apps at `--from 16` and `--from 17`. Every other command still reads the stack `defineStack` builds. + - **The `--out` snapshot.** It is now the stack as written, with the chain's changes applied. That is what it already was for a stack the schema refuses. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys and actions merged into their objects. +- 28bff18: The CLI reads a multi-package app's package-owned keys (`requires`, `tiers`, `analyticsCubes`, `flows`, `objects` and the rest) out of its package bodies, as it reads a one-package app's top level. + + Clause-②: no + + A `composeStacks([…], { manifest: 'preserve' })` config carries each of these keys only inside the body of the package that declared it. Its top level carries none of them. These readers looked at the top level only, so they read nothing on such an app, and did so silently: + + - **`os serve` capability providers.** A config boot with no compiled artifact did not mount the provider a package declares in `requires` (`automation`, say). The boot logged `Optional service not present` and the features that need the provider were absent. `os dev`, `os start`, and `os serve` beside a built `dist/objectstack.json` already mounted it, because they boot the artifact. + - **`os serve` tiers.** A package's own `tiers` were ignored on `os serve`, `os dev` and `os start`. The preset's tiers were used instead. + - **`os serve` analytics cubes.** The always-on analytics provider started with none of the cubes a package declares in `analyticsCubes`, on every boot of such an app. + - **`os serve` flow line.** The ready banner's line `N flow(s) declared but the automation engine is not enabled` never counted a package's flows, so it did not appear. + - **`os migrate plan` / `apply`.** A host plugin that hard-depends on a provider a package's `requires` supplies could not be ordered. Both commands exited 1 with `Dependency '…' not found for plugin '…'`. + - **`os generate`.** After writing a flow, it warned that the config does not require `automation` and `triggers` even when a package declares both. `os generate types`, `client` and `migration` emitted none of a package's objects. + - **`os doctor`, `os diff`, `os migrate meta`.** `os doctor` skipped every metadata check and reported a healthy environment. `os diff` reported no change for an object added to a package. `os migrate meta` did not list the data migration a package's file fields need. + + Each now reads by one rule: the top-level value when the stack carries one, otherwise every package body's. This is the rule `os validate` and `os build` already use for `requires`. A stack with no `packages[]` reads exactly as before. + + **Two behaviours narrow.** In each, a two-package app is now refused where the same declarations in one `defineStack` were always refused: + + - A capability a package declares in `requires` with no installed provider (`requires: ['ai']` in the open edition, say) now stops an `os serve` boot of the config, with the same `Capability "…"` error. `os dev` and `os start` already stopped there. + - A package's `tiers` now apply. A package that declares `tiers` without `auth` gets a stack with no auth, which `os serve` refuses to boot with its data API, as it always has for one package. To keep the preset's tiers, remove `tiers` from the package, or list every tier it needs. +- 59fb299: fix(cli): `os migrate meta` runs on a project built with `composeStacks`, and converts the retired spellings inside its package bodies (#22289) + + Clause-②: no + + - **What changed.** On a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, `os migrate meta --from N` used to exit 1 with `STACK_PROVENANCE_MISSING`, telling the author to wrap each input in `defineStack`, as soon as any input carried a spelling the current schema refuses. Every input already was wrapped. The command now loads such a project: an input whose `defineStack` call the current schema refuses is produced again by `defineStack(input, { strict: false })` with the load-time conversions skipped (#22256) before it is handed to `composeStacks`, and composition runs as it does at build time. + - **The package bodies are migrated.** A composed artifact keeps each definition under the package that owns it (`packages[i].manifest`), and the migration chain used to reach only the stack's own top level. It now also runs over each package body, and lists each change under the body's path, such as `packages[0].manifest.objects[0].fields.starts_at.defaultValue`. `--write` writes a change in package i into the file that authored input i only when input i and every input before it in the `composeStacks([…])` list is a stack literal (a `defineStack({ … })` call on an object literal, written in place or reached through a `const` or an import) that writes its own `manifest` and no `packages`. Only then is package i that one input's body. Otherwise `--write` lists the change with the reason, as it does for any site it cannot trace. + - **What did not change.** A one-package project loads, migrates and writes exactly as before. An input that was never built by `defineStack` (a plain object, or a spread of a built stack) is still refused with `STACK_PROVENANCE_MISSING`. +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. +- 97610a5: `bootStack` composes what `objectstack serve` composes from the same configuration: the providers the app's `requires` names, and the plugins in the app's own `plugins` array + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — one composition rule.** For one configuration, `bootStack(config, opts)` now mounts what `objectstack serve` mounts from it, so an app's tests boot the composition its users get: + + - **The providers the app's `requires` names**, by `serve`'s own reader and table (top-level `requires`, otherwise each package body's), plus the always-on providers a mounted plugin hard-depends on. An app no longer lists them in `extraPlugins` by hand. + - **The plugins in the app's own `plugins` array**, by `serve`'s rule for an entry: an instance is mounted as written, a plain bundle is wrapped into `AppPlugin`, a package name is loaded from the app's root (`hostRoot`). + - **The caller wins by identity.** An `extraPlugins` instance takes precedence over a `requires` provider it is (exact `name` or class name) and over an app plugin with the same `name`; that app plugin is not mounted. `security` and `analytics` instances take precedence over an app plugin of the same `name` the same way. + - **`hostRoot` is the app's root** in the two places `serve` uses the config's directory: the automation service's `packageRoot` (where a declarative connector's package-relative file ref is read) — now also when `automation: true` asks for the service — and the root a string `plugins` entry is resolved from. A suite that does not run from the app's directory passes it. + - **Offline CI.** An app whose `plugins` array wires the marketplace-facing `@objectstack/cloud-connection` plugins gets them mounted, pointed at `OS_CLOUD_URL` (by default the public catalog). Set `OS_CLOUD_URL=off` in the test environment, before the configuration module is imported, to keep the suite offline. + + **What now fails that booted before (the narrowing).** + + - **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again; or share one boot with `bootStackOnce(config, opts)`; or, to keep two stacks of one app live at once, boot the second on a configuration built again (call its builder once more, or import a fresh module instance of it). A `{ ...config }` spread is not a configuration of its own: a live boot keeps references into the configuration's nested definitions. + - **An app `plugins` entry that cannot be loaded or registered fails the boot**, naming the entry (`plugins[i]`) and its remedy. `serve` logs such an entry and boots on; a test boot does not, so a plugin the app declares is never silently absent from its tests. + - **A provider or app plugin that refuses to start fails the boot** where the fixed plugin set never mounted it — for example a declarative connector whose package-relative file ref does not resolve from `hostRoot`. + + **`@objectstack/core`** exports the pieces both boots read: `CAPABILITY_PROVIDERS`, `CapabilitySpec`, `CapabilityIdentities` and `providesCapability` (the `requires` token → provider table and its exact identity match); `stackDeclaredCapabilities`, `resolveStackCollection`, `declaredPackageEntries`, `stackPackageBodies` and `collectFromPackageBodies` (the package-owned collection reader); and `materializeStackPlugin` with `StackPluginLoaders` (what a `plugins` entry becomes). + + **`@objectstack/cli`**: `os verify` boots the app anchored at the directory holding its config (`hostRoot`), as `serve` anchors it, so `os verify --app path/to/objectstack.config.ts` run from another directory reads the app's package-relative files (a declarative connector's spec, a string `plugins` entry) and resolves `--multi-tenant`'s organizations package from the app, not from the working directory. `serve` itself does not change: `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. +- 26bf56f: `os serve `: a multi-package config now serves its flat `src/docs/` pages, under the package that owns the stack's manifest + + Clause-②: no + + A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]`, such as one `composeStacks([…], { manifest: 'preserve' })` returns, did not serve the stack's own `src/docs/*.md` pages. The boot put them on the config's top level, and a config boot registers that config's package bodies, never its top level. `GET /api/v1/meta/doc` did not list them, and nothing warned. `os build` of the same project, booted as an artifact, served them. + + - **What changes.** The config boot places the flat pages where `os build` places them: on the body of the one `packages[]` entry whose id is the stack's `manifest.id`, after that package's own `src//docs/` pages. `GET /api/v1/meta/doc` now lists them under that package, as an artifact boot of the same project does. + - **What stays the same.** A single-package config (no `packages[]`) serves its flat pages under its one package, as before. Each package's own `src//docs/` pages are served under that package, as before. Artifact boots (`os dev`, `os start`, `os serve` with `dist/objectstack.json`) do not change. + - **When nothing moves.** If no entry has the manifest id, if several do, or if the stack declares no `manifest.id`, the flat pages stay on the top level, as `os build` keeps them. A config boot of such a stack still does not list them. + - **A name shared by an inline doc and a flat page.** `os build` refuses this (`docs/duplicate-name`); a config boot does not lint docs. On a single-package config the flat page still replaces the inline doc of the same name. On a multi-package config the flat page is now served under the owning package, and an inline doc on that package's body with the same name is served with the flat page's content. +- 4638625: fix(cli): `os dev` prints the server's ready banner whole, then the MCP connect block whole, instead of interleaving the two (#22410) + + Clause-②: no + + - **What a terminal showed.** `os dev` is two processes writing one terminal. The parent prints the MCP connect block (`🤖 MCP server — connect a coding agent:` with its Endpoint, Skill, Connect and Disable rows) when the `serve` child sends `objectstack:listening`, and the child sent that message before it printed its ready banner. Measured under a pty on the Build-with-Claude-Code tutorial project, 6 of 7 boots printed the block above or inside the banner. In 2 of them, banner lines landed between the block's rows, in one case the `➜ Console:` and `➜ MCP:` rows. A setup boot before those seven printed the `🔑 Dev admin` credential lines and the whole plugin section between `Skill` and `Connect`. + - **What changed.** The child now sends `objectstack:listening` after its ready banner and boot diagnostics have printed. The parent prints the block on that message, so it always comes last, under the banner's `Press Ctrl+C to stop` row. The order follows from the sequence itself, with no timer: the banner is written to the terminal before the message is sent. After the change, every measured boot printed the banner whole and then the block whole. + - **What did not change.** The message is still `{ type: 'objectstack:listening', port, url }`, carrying the port actually bound. The runtime state file is still written before either announcement, and `objectstack:seed-settled` still follows `objectstack:listening`. A program that spawns `os serve` with an IPC channel receives `objectstack:listening` slightly later than before: after the banner has printed instead of before. +- d6c3791: fix(cli): a dev server that refuses a drifted Console answers `503` at `/_console/` naming the rebuild, instead of a bare `404` (#22420) + + Clause-②: no + + A refused Console mount answers `503` naming the remedy instead of the router's bare `404` on the same routes, as the not-built position already does. No accepted input, key or export changes. + + `os dev` refuses to mount a Console whose built `dist/.objectui-sha` differs from the repository's `.objectui-sha` pin. The terminal printed the refusal, but nothing was mounted, so `GET /_console/` and `GET /` answered `404 ENDPOINT_NOT_FOUND`, the same answer as a wrong URL. That boot now mounts the same routes as the not-built answer: + + - `GET /_console/` and every path under it answer `503` (`text/plain`, `no-store`). The body is the refusal's own text: both objectui SHAs, why the stale build is refused, `pnpm objectui:build`, and the `OS_ALLOW_CONSOLE_DRIFT=1` escape hatch. The pin file is named without its absolute path. + - `GET /` and `GET /_console` redirect to `/_console/`, as they do when the Console is served. + + The same answer now covers an install where `@objectstack/console` does not resolve at all. That boot used to print nothing and mount nothing. It now prints ``Console package `@objectstack/console` not found``, followed by the reinstall sentence an install with no `dist/` already gets. `/_console/` answers `503` saying the Console is not installed. + + `--no-ui` and `--no-console` still mount nothing, so those routes answer `404`. +- d2e7d6c: fix(cli): a hook that destructures a renamed key off `ctx` is no longer refused as not-lowerable (#22459) + + Clause-②: no + + - **What changed.** The free-identifier scan behind `objectstack lint`'s `hook-body/not-lowerable` rule and `objectstack build`'s hook lowering counted a renamed destructuring key as a free identifier. For `const { event, input, previous: prev } = ctx`, it reported `previous`, a property read off `ctx`, although the only name the pattern binds is `prev`. So `objectstack lint` failed the hook with the advice "reach them through `ctx`", which the hook already did, and `objectstack build` shipped it as a bundled closure instead of a metadata-only body. A non-computed destructuring key is now a key, as the key of `{ key: value }` already was. Nested patterns (`{ a: { b: c } } = ctx`), patterns inside array patterns, the parameters of a callback inside the handler, and `catch` clauses are covered by the same rule. + - **What is still refused.** A destructuring default or a computed key that names something out of scope is still free: `const { key: alias = FALLBACK } = ctx` and `const { [KEY]: v } = ctx` report `FALLBACK` and `KEY`, as before. A genuinely free `previous` that is not read off `ctx` is still refused. + - **What to do.** Nothing. A hook written this way now lowers to a body on the next `objectstack build`, and `objectstack lint` stops reporting it. The un-renamed spelling (`const prev = ctx.previous`) keeps working as before. +- 86f53a4: fix(cli): `os migrate plan` / `apply` / `unmapped-columns` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506) + + `os migrate plan` and `os migrate apply` only examine the objects their own boot registers, and that boot stopped at the stack: its config, its metadata and the platform floor. Nothing `os serve` mounts around a stack was there. On an app declaring `requires: ['auth']`, the plan examined 9 of the 68 tables the serving boot had created. `sys_account` read as an undeclared platform table, so its retired `issuer` column (and that column's unique index) was never a destructive drop. `os migrate apply --allow-destructive` dropped nothing, while the boot's drift line and `os migrate account-issuer` kept prescribing exactly that command. + + The two commands now also compose, each through the rule `os serve` itself reads, and so does `os migrate unmapped-columns`, which reads the plan's own `unmapped_column` findings and therefore needs the plan's object set: + + - the auth family behind `serve`'s auth gate: plugin-auth's identity objects, the security plugin and the audit plugin, when the stack mounts no `AuthPlugin` of its own, the `auth` tier is on, and an auth secret resolves; + - the provider of every capability `serve` mounts: the stack's `requires` and the always-on slate, as `os serve` composes it with no `--preset`; + - the REST API plugin; + - every `plugins` entry by `serve`'s rule for one: a package name is loaded from the app, and a plain bundle is wrapped as an app; + - the pinyin-search decision from the config's locales, so the `__search` companion columns a boot provisions are no longer listed as destructive drops on a config-only project. + + `os migrate unmapped-columns --object sys_account` (or any platform object the plan now declares) resolves the object and reads its unmapped columns, where it used to answer `OBJECT_NOT_FOUND`. + + Each piece is composed for its declarations only. Its `init()` runs, and its `start()` and lifecycle hooks do not, so no dispatcher, scheduler or seed runs inside a dry run. + + **What an operator sees.** On the shape that looped, `os migrate plan` lists `sys_account.issuer` and `uniq_sys_account_issuer_account_id` as destructive drops. `os migrate apply --allow-destructive` drops both, `os migrate account-issuer` reads zero, and the next boot prints no drift line for them. When no auth secret is set and the boot is not a development one, the plan says it did not compose the auth family, and how to compose it: run with the deployment's `OS_AUTH_SECRET` exported. `os migrate` reads no `.env` file. + + **driver-sql.** The deferred-DDL preview answers a rotation-declared object (`lifecycle.storage.strategy: 'rotation'`, such as `sys_activity`) from the rotator's facts: when the current shard and the read view exist, nothing is pending. It used to list `create_table` for such an object on every plan, and `apply` then created nothing. + + **Known limit.** A development boot, or one with `OS_TELEMETRY_DB` set, keeps lifecycle-classed objects in a sibling `telemetry` database. The one-shot migration boot does not provision that database, so it examines those objects against the primary one. +- 0fea05f: `os serve `: a multi-package config now serves the top-level metadata no package declares, under the stack's `manifest.id`, and the boot says so, as an artifact boot of the same project already did + + Clause-②: yes + + A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]` registers each package body and never the stack's top level. A top-level item that no package body declares was served by no door, and the boot said nothing. Three shapes reached it: flat `src/docs/*.md` pages when the stack's `manifest.id` names no `packages[]` entry, inline `docs` spread onto a composed stack's top level, and any other top-level collection (objects, views, …) that no package carries. `os build` of the same project, booted as an artifact, registered those items under the stack's `manifest.id` and warned. + + - **What changes.** The config boot now runs the same residual rule as the artifact boot, `MetadataPlugin.registerUnclaimedTopLevel`. Every top-level item no package body declares is registered under the stack's `manifest.id`, and the boot prints one `warn` line with the count: `config stack '' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy. `GET /api/v1/meta/` lists those items under that id, as on an artifact boot of the same project. This includes the flat `src/docs/` pages of a stack whose `manifest.id` names no package, which a config boot did not list before. This entry supersedes, in the same release, the note in the `os serve ` flat `src/docs/` entry that "A config boot of such a stack still does not list them": it now does, under the stack's `manifest.id`, with the warning. + - **A new refusal, in one corner.** A residual view container is judged like any other: a top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot with ``Invalid `views:` container from manifest '': the container's own `name` is …``. On the previous release that config booted, because its top level was never read; an artifact boot of the same project already refused it. `os validate` and `os build` refuse the same container first, in the same words, so the refusal is never a boot-time surprise. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to. + - **The remedy the line names.** Declare each such item inside the `packages[]` entry of the package that owns it. A flat `src/docs/` page moves under `src//docs/`, where `` is the package id or its last dot-separated segment. + - **What widens, in `@objectstack/metadata`.** Its root entry now exports `unclaimedTopLevel(stack)`, the pure residual decision both boot doors and `os validate` read, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call. + - **What stays the same.** An artifact boot registers the same items and prints the same line, byte for byte. A single-package config (no `packages[]`) has no residual and is unchanged. A composed stack whose top level repeats its package bodies, as `composeStacks([…], { manifest: 'preserve' })` writes it, registers nothing extra and prints nothing. + - **What the residual is, on both doors.** It is metadata: a residual object is listed on `GET /api/v1/meta/object` under the stack's `manifest.id`, and `GET /api/v1/data/` still answers `404`. That was already true on an artifact boot, and the config boot now gives the same answer. +- fd2768a: fix(cli): `os migrate plan` / `apply` plan each object in the database it lives in, the `telemetry` sibling included (#22579) + + A development `os serve` boot on a file-backed SQLite database, and any boot with `OS_TELEMETRY_DB=`, keeps lifecycle-classed system data (audit, telemetry and event objects such as `sys_audit_log`, `sys_activity`, `sys_metadata_audit`, `sys_job_run`, `sys_notification`) in a sibling `telemetry` database. The one-shot boot `os migrate` runs never opened that sibling, so every one of those objects resolved to the primary database: after a development boot, `os migrate plan` listed each as a table to create, and `os migrate apply` created each in the primary — empty tables beside the ones the served boot uses. + + The migrate boot now provisions the sibling exactly when the serving boot would, through the same helper and under the same rule: `--dev` or `NODE_ENV=development` on a file-backed SQLite primary, or `OS_TELEMETRY_DB=`, and never with `OS_TELEMETRY_DB=0`. `plan` and `apply` diff and apply every object against the database it lives in, and print the sibling under the database line (`Telemetry database: …`); in `--json` it is the new `telemetryDatabase` field, present only when a sibling is planned. `apply`'s confirmation names both databases. A `plan` against a sibling that does not exist yet lists its tables to create and creates no file. `os migrate unmapped-columns` reads a lifecycle-classed object from the sibling, and names it as the database. + + A deployment with no sibling — production without `OS_TELEMETRY_DB`, or `OS_TELEMETRY_DB=0` — is unchanged. Run the migration with the `NODE_ENV` the deployment is served with: without `NODE_ENV=development` the plan describes a production `os serve`. Tables an earlier `os migrate apply` created in the primary for these objects are not removed. + + This supersedes the "Known limit" in this release's `os migrate plan` / `apply` composition entry: the migration boot now provisions the `telemetry` database. + + `os serve` loads the project's `.env*` files through the same function `os migrate` does; which files it reads, and in which mode, is unchanged. +- 7c7e750: fix(cli): `os migrate plan`'s unmanaged-tables sweep runs whenever the plan's object set mirrors what `os serve` registers, covers every database the plan diffs, and states only a reason that holds when it does not run (#22580) + + On a project with a compiled artifact and no `objectstack.config.ts`, `os migrate plan` composes what `os serve` registers, platform included. The sweep for platform-prefixed tables that no object declares was still withheld there (`unmanagedTables.status: "unreadable"` in `--json`), with a reason claiming the plan's object set was only the artifact plus the platform floor. On a project with neither a config nor a compiled artifact, the same untrue reason was given. + + The plan's composition now records whether its object set mirrors the served boot, and why when it does not. The sweep runs exactly when it does, the compiled-artifact project included. When it does not run, the `detail` names the composition's own reason: the host config that did not load, or a project with neither a config nor a compiled artifact. + + The sweep also reads every database the plan diffs. Where the serving boot keeps lifecycle-classed objects in the `telemetry` sibling database, a table stranded there is now reported too, where before the sweep answered `read` over the primary database alone. A sibling whose catalog cannot be read makes the whole sweep `unreadable`, and the `detail` names that database. The `--json` shape of `unmanagedTables` is unchanged. `physicalTables` counts the tables of every database swept, and the human line says "in the database(s) this plan covers". +- 0ec4268: fix(cli): `os migrate` reads the project's `.env` files as `os serve` does, and names the database it opened and who named it (#22581) + + `os serve`, `os start` and `os dev` load the project's `.env*` files before they read any variable. `os migrate` read only the process environment. So a project keeping `OS_DATABASE_URL` in `.env` served one database and migrated another: `os migrate plan` reported `.objectstack/data/objectstack.db` while `os serve` opened the `.env` database. An `OS_AUTH_SECRET` kept in `.env` left the auth family (`sys_user`, `sys_account`, …) out of the plan. + + Every command that boots the one-shot schema stack now loads the same files first, through the same `dotenv-flow` call, with `os serve`'s mode rule: `NODE_ENV`, else `production`, and `development` for `os migrate security-catalog-overlays --dev`, which composes as `os serve --dev`. That covers every `os migrate` subcommand, and also `os secret rewrap`, `os secret orphans` and `os storage orphans`. A variable exported in the shell still wins over `.env`, as it does for `os serve`. The SQLite occupancy check that `os migrate apply` and the other write commands run before booting reads the same files, so it checks the database the command is about to migrate. + + This supersedes two phrasings in this release's `os migrate plan` / `apply` auth-family entry: `os migrate` now does read the project's `.env` files, and the deployment's `OS_AUTH_SECRET` composes the auth family whether it is exported or kept in `.env`. + + `os migrate plan` and `os migrate apply` print the database with its source: `--database-url`, a variable from the process environment, a variable from a named `.env` file, the config's default datasource, or the default. In `--json` the source is the new `databaseSource` field (`{ kind: 'flag' | 'process-env' | 'env-file' | 'config-datasource' | 'default', variable?, file?, datasource? }`). `apply` now carries `database` and `databaseSource` on every payload after the boot, including `in_sync`, `nothing_safe_to_apply` and `confirmation_required`. +- f59a73c: fix(cli): `os package install` no longer reports a hot install when the runtime answers `hotLoaded: false` (#22729) + + Clause-②: no + + - **What was wrong.** `os package install` printed "Package installed into the running kernel" for any `200` from `POST /api/v1/marketplace/install-local`. On the endpoint's lenient path (a catalog manifest whose hot-register throws), the runtime writes the package to its ledger and answers `200` with `hotLoaded: false` and the register error's message as `hotLoadError`: the package loads at the runtime's next restart, and the running kernel does not hold it. The command read neither key. + - **What it prints now.** On `hotLoaded: false` it prints a warning that the package is installed but the running kernel could not load it, and that it loads at the runtime's next restart, followed by a `Hot-load error:` line carrying `hotLoadError` as the runtime answered it. When `hotLoadError` is missing, blank or not a string, no reason line is printed. The exit code stays `0`: the install itself succeeded. + - **What did not change.** A hot-loaded install (`hotLoaded: true`) prints exactly as before, and so does an answer without `hotLoaded` (a runtime that predates the key). The command has no `--json` output, and none is added. +- eb20c33: fix(cli): the nine rule ids the CLI produces print one verdict sentence each, `os explain ` carries their reasoning, and the undeclared-handlers boot line is one line + + Clause-②: no + + - **One verdict sentence of at most 200 characters.** Measured on every finding the CLI's suites fire (message length; before → after): + - `os lint`'s hook-body rules: `hook-body/not-lowerable` 565–661 → 165–178, `hook-body/bundled-fallback` 324–947 → 109–171, `hook-body/unparseable` 469 → 157, `hook-body/extraction-failed` 331 → 140. Over every refusal the extractor can throw (14 forbidden constructs and 7 parameter refusals), `hook-body/bundled-fallback` runs 296–1,011 → 113–199: the verdict quotes the extractor's own verdict clause, cut to fit and marked `…` when cut. + - package docs (`os build`, `os validate`, `os lint`): `docs/uncollected-directory` 433–558 → 124–183, `docs/nav-target` 263–299 → 147–184, `docs/frontmatter-tags` 233–285 → 135–195, `docs/duplicate-name` 330 → 167 (its within-package arm is unchanged at 101). A doc finding no longer repeats its path, which every printer already leads with; lists of files, packages and directories name the first three and count the rest. + - `picklist-reference-unverified` (the `os validate` / `os build` notice): 240–245 → 173–175, naming at most two outside dependencies and counting the rest. + - **A `fix:` line for the hook-body rules.** Their remedy moves out of the message into the finding's `fix`, at most 200 characters: `os lint` prints it as `→ fix:` under `--fix`, and `os lint --json` now carries `fix` on these four ids' issues. The remedy is still chosen per case: inline a module-scope name, or keep a check on a host-only global such as `Intl` in a string handler ref or a validation rule. A doc finding carries no separate fix, so its sentence ends with the remedy; the picklist notice's `hint` is unchanged. + - **`os explain ` carries the rest.** Each of the nine ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, the one table `os explain` reads, and `os lint`'s `rule:` line now points at it. They hold what the messages stopped saying: why a bundled handler is a change of deployment shape and how to bundle on purpose, what the sandbox cannot run and where `os build` prints the full refusal, why an instrument limit is not a verdict, where package docs are read from and why the collector never guesses, why a doc name may appear once, what a doc navigation item can open, how doc tags are read, and why a reference into a dependency outside the stack is not judged. + - **The `[action-governance]` boot line for registered handlers with no declaration** (`os dev`, `os serve`, and any host that boots ObjectQL) is one line of 192 characters, where it was 628: it names the three sources it read, states the 404 as expected rather than measured, and says to declare each with `defineAction`. Its structured `count` / `handlers` tail is unchanged, and the dead-button line beside it is unchanged. + - **Unchanged:** every rule id, severity and `path`, the boot lines' level and payload, and what each check accepts or refuses. A tool that matched on this message text should match on `rule` and `path` instead. +- 40bc51f: fix(cli): `objectstack build` reads a lowered hook or action body off the handler's parse, so a `}` in a string, a statement-level inner arrow or a `function` handler with a pattern parameter no longer ships a body that is not the handler's + + Clause-②: no + + When `objectstack build` lowers an inline handler to a metadata `body`, it ships the handler's statements as `body.source`. It found those statements by counting `{` / `}` and `(` / `)` through the handler's text, with no idea of what a string, a template literal, a regular expression or a comment is, and it took the first parenthesised `=>` anywhere in the text as the handler's header. So ordinary handler code shipped a body that is not the handler's, and the build still exited 0. Measured through a real `objectstack build`: + + - **A `}` inside a string, a template literal, a regex or a comment** cut the body off at that brace. `ctx.input.note = 'a}b'` shipped as `ctx.input.note = "a`, a `SyntaxError` on the body's first run. + - **A statement-level inner arrow** in a `function` handler (`const f = (a) => a + '!'`) was read as the handler, and the body was built around it, which is also a `SyntaxError`. With a block-bodied inner arrow, the inner arrow's own statements shipped: a body that runs and does none of the handler's work. + - **A `function` handler whose parameter is a pattern** (`async function ({ input }) { … }`) shipped the pattern as its body. After the parameter rebind that is `var { input } = ctx; input`. It throws nothing and stores nothing, where the in-process handler stores the row. + - **A `{` inside a string** left the count unbalanced, so the handler was refused as unparseable and bundled into the runtime module, with a warning telling the author to rewrite a handler that had nothing wrong with it. + + The only signal for the first three was `os lint`'s `hook-body-source-unparseable` warning on the lowered body. It pointed the author at a syntax error their handler does not have, and the pattern-parameter case raised no signal at all. + + The body is now read off the TypeScript parse of the handler: the statements between its block's own braces, or the expression of an implicit-return arrow. This is the same parse the parameter rebind and the free-identifier check already read. Every shape above now lowers to the handler's own body and runs as the handler does. The `{`-in-a-string handler is lowered instead of bundled. `hook-body-source-unparseable` no longer fires for any of them. + + This applies to both faces, a hook's `handler` and an action's `target`. The action face still gets no parameter rebind. + + **Unchanged.** A handler the old count read correctly lowers to the same bytes, comments and layout included. This was measured over 21,262 function texts from `packages/cli`, `examples` and `packages/qa`, and over the callables the `app-crm` and `app-todo` configs load. No refusal message is reworded. The hook face's separate "could not read the parameter list" refusal is gone: the body and the parameter list are now read off one parse, so a handler that parse cannot read meets the existing "could not parse the body" refusal instead. It is the same kind (`unparseable`) with the same remedy, and no measured handler reached the old one. `extractHookBody`'s published signature and refusal kinds are the same. No spec key, export, type or stored shape moves. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- c32bd82: fix(cli): `objectstack build` ships every `functions` name under its own name, bound to its own code, and every hook and action ref resolves to its own code (#22886) + + Clause-②: no + + The runtime resolves a hook's `handler`, an action's `target` and a `functions` key against one namespace, filled from the runtime module (`objectstack-runtime.*.mjs`). The build and the module used to name its entries twice, and the two disagreed: + + - **A `functions` key that shared its function with a hook or an action was lost.** `functions: { score: tidy }` beside a hook whose handler is `tidy` shipped as `functions: { tidy_hook: 'tidy_hook' }`, and two keys bound to one function shipped as one. + - **A `functions` key equal to a hook's name, or to an action's `OBJECT_ACTION` ref, was re-keyed and its hook ran the wrong code.** The key shipped as `NAME__2`, a bare string with no callable after load, while the hook's ref resolved to the `functions` entry's code. + - **An action's handler never reached the runtime module**, collision or not, so a script action whose handler could not be lowered to a `body` shipped with a ref that resolved to nothing. + - **A `functions` map exported by name** beside the default export shipped as bare strings: the module read the default export only. + + Now an authored `functions` name (a map key, or an array entry's `name`) is always its own ref: it ships under that name and runs its own code. A hook or action ref is internal, so on a collision it is the one that takes a numeric suffix (`hooks[i].handler: 'NAME__2'`); the hook's `name` is unchanged. A hook or action bound to the same function as a `functions` entry re-uses that entry's name. The runtime module is generated from the build's own ref → callable map, locating each callable in the loaded config by identity, so it serves every ref the artifact names. A stack with no collision ships the same artifact refs as before. + + No action is needed: rebuild to pick up the fix. An artifact built before this release keeps the old names until it is rebuilt. +- 85b4ac6: fix(cli): plugin scaffolds declare `vitest ~4.0.0`, so `npm install` works on npm 10 + + Clause-②: no + + `objectstack init -t plugin` and `objectstack create plugin` now declare `vitest ~4.0.0` instead of `^4.0.0`. With `^4.0.0`, a fresh `npm install` of the emitted project crashed on npm 10, the npm that Node 22 bundles, with `Cannot read properties of null (reading 'edgesOut')`. npm 11 was not affected. + + The cause is upstream. vitest 4.1.x accepts vite 8, and vite 8's optional devtools peers lead back to a `vitest@*` peer that npm 10 cannot resolve against an older vitest at the root. vitest 4.0.x accepts only vite 6 or 7, which carry no such peer. A scaffolded project now installs vitest 4.0.18 with vite 7.3.7 on npm 10 and npm 11. + + Projects that were already scaffolded are unchanged. If one of them fails to install on npm 10, change its `vitest` devDependency to `~4.0.0`, or install with npm 11. +- 7f2f081: `os serve` of a host config serves the stack's declared `apis` endpoints, and a composition that still cannot serve them says so once + + Clause-②: no + + A host config is a stack whose `plugins` holds plugin instances: `examples/app-showcase` and every `create-objectstack` scaffold. On `os serve objectstack.config.ts` (and on `os start`, whose `serve` child boots the same way) such a config never got `@objectstack/metadata`'s `MetadataPlugin`, so its `metadata` service was the kernel's in-memory fallback, which has no endpoint matcher. Every declared `apis` path answered `404 ENDPOINT_NOT_FOUND` there, while `os dev` of the same stack matched it. Measured on the showcase with no session: `GET /api/v1/apps/showcase/tasks` answered 404 and now answers `401 UNAUTHENTICATED`; an undeclared path under the same namespace still answers 404. + + `os serve` now composes a `MetadataPlugin` with no artifact on a host-config boot that has none (when `os dev`'s artifact door does not compose and `plugins` holds no `MetadataPlugin` of its own). What an upgrading host author meets on that boot, measured on the showcase before and after: + + - `GET /api/v1/meta/TYPE` answers byte-for-byte the same for every type it served before (22 types read, including `app`, `view`, `page`, `dashboard`, `flow`, `position` and `permission`). + - `GET /api/v1/openapi.json` now documents the declared endpoints the boot serves; before, it listed none of them. + - No new database table: the `sys_metadata` storage tables were already provisioned, and no row is written at boot. + - The door writes nothing into the project directory at boot. Its metadata repository is rooted at the served app's directory, not at the directory the command was run from. + - The door reads none of the stack and primes nothing from the filesystem (`bootstrap: 'lazy'`), so `positions` / `permissions` / `capabilities` / `sharingRules` keep exactly one registrar. + - The dev-only `GET /api/v1/dev/metadata-events` stays unmounted unless `NODE_ENV=development`. Under `os serve --dev` with no compiled artifact it is now mounted, as it is on every other config's dev boot. + - With a remote cluster driver, the metadata service's cross-node cache invalidation (`metadata.changed`) now attaches; the boot warning that it was disabled is gone. + - Boot time and memory did not measurably move (median of 7 boots each: 8.47 s → 8.41 s to listening, 461 MB → 459 MB resident). + + A composition that still has no endpoint matcher (for example a kernel composed without `MetadataPlugin`) now logs one warning at boot per app that declares `apis`, with the count, naming the remedy: compose `@objectstack/metadata`'s `MetadataPlugin`. +- 84c4fff: `os migrate plan` / `apply` cover `sys_packages`, and the boot honours `OS_SKIP_SCHEMA_SYNC` for it + + Clause-②: no + + `sys_packages`, the package registry's table, is created by `PackageServicePlugin.start()` with its own DDL, because it keys on `(id, version)` and a declared object cannot carry that key. So it was never on the managed migration path: `os migrate plan` did not list it and `os migrate apply` did not create it. A deployment that sets `OS_SKIP_SCHEMA_SYNC=1` ("Use after running migrations manually") still had it created at boot, and had no documented way to create it otherwise. + + - **The migration path covers it.** `PackageServicePlugin.init()` declares the table as its `package.storage` kernel service: a read-only catalog probe (SQLite, PostgreSQL and MySQL each ask their own catalog) and the boot's own create statements. `os migrate plan` lists `sys_packages` as `create_table` when it is absent, and `os migrate apply` creates it, with the same statements the boot runs, so the table and its `idx_packages_latest` index come out exactly as a boot creates them. Whether the flag is set does not matter to either command. Where the table's presence cannot be read (a driver that names no SQL dialect), the run says it did not examine the table, and neither lists nor creates it. + - **Then the boot honours the flag.** With the flag set, `PackageServicePlugin` issues no DDL and no existence probe at boot, and logs one `info` line naming `sys_packages` and the flag, and pointing at `os migrate apply`. The `package` service is still registered. The value read is the one `ObjectQLPlugin` resolved (the `skipSchemaSync` option, else the environment variable), as `plugin-audit` and `service-messaging` read it. On a flag deployment, run `os migrate apply` before the first boot that needs the table. + - **What is unchanged.** With the flag unset, the boot creates the table exactly as before. An existing `sys_packages` table is not altered by either command; the plan lists nothing for it. No type or export changes. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [c28f317] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [e7e6544] +- Updated dependencies [cd3d391] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [e078506] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [19805d8] +- Updated dependencies [54f062b] +- Updated dependencies [9f4b652] +- Updated dependencies [965a1bf] +- Updated dependencies [eab7236] +- Updated dependencies [f93bf12] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [8a399b2] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [f0022c4] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [0db5ad5] +- Updated dependencies [8601526] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [29678f2] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [56bf27a] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [6c17a50] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [98998ca] +- Updated dependencies [3aa0c7b] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [5f98778] +- Updated dependencies [036bf1d] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [d8c7d38] +- Updated dependencies [2e11d90] +- Updated dependencies [7dbb89d] +- Updated dependencies [6a7eca6] +- Updated dependencies [63d5db2] +- Updated dependencies [a9bcbc6] +- Updated dependencies [33ae4bf] +- Updated dependencies [ebbb8f9] +- Updated dependencies [07ee6f6] +- Updated dependencies [61bea24] +- Updated dependencies [762db99] +- Updated dependencies [d1c226d] +- Updated dependencies [996aa86] +- Updated dependencies [23419ba] +- Updated dependencies [54d32e5] +- Updated dependencies [2e11d90] +- Updated dependencies [31cd210] +- Updated dependencies [7518ee3] +- Updated dependencies [4049cac] +- Updated dependencies [7ebbc01] +- Updated dependencies [0e9371f] +- Updated dependencies [0b7ba4d] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [d1dbe70] +- Updated dependencies [3513ac7] +- Updated dependencies [35afb15] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [79c35d4] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [f2626c7] +- Updated dependencies [e175479] +- Updated dependencies [59d993c] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [fbb065f] +- Updated dependencies [27f0d83] +- Updated dependencies [a8f24b0] +- Updated dependencies [37c7114] +- Updated dependencies [b4ce5e2] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [e36ee53] +- Updated dependencies [1884948] +- Updated dependencies [3599fef] +- Updated dependencies [8a995b8] +- Updated dependencies [54c3ce1] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [43fc500] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [3403be8] +- Updated dependencies [46692c1] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [3ca71b6] +- Updated dependencies [94379f4] +- Updated dependencies [166a94f] +- Updated dependencies [dd986d8] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [f782f17] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [5495331] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [d25b187] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d1d42ec] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [e194ab4] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [b98e984] +- Updated dependencies [76bc1e0] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [874a38d] +- Updated dependencies [3d0eeef] +- Updated dependencies [cb3bb93] +- Updated dependencies [83b8b80] +- Updated dependencies [1b99388] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [a6f3a13] +- Updated dependencies [f53f14b] +- Updated dependencies [e8c6666] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [87cd458] +- Updated dependencies [86da194] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [156ddfa] +- Updated dependencies [6981246] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [243dd3c] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [7399080] +- Updated dependencies [e04759d] +- Updated dependencies [0f77ff5] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [9f15939] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [a360cee] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [896a434] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [0984817] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [8bd0fcd] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [75b6d6a] +- Updated dependencies [c4e7fa5] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [f313fbc] +- Updated dependencies [9814035] +- Updated dependencies [f9c7588] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [bb0db13] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [eed637c] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [3fdb92d] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [e4ad677] +- Updated dependencies [6a40a09] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [8e4a230] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [84c4fff] +- Updated dependencies [32fa194] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [0aef788] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [0511341] +- Updated dependencies [a3d47c2] +- Updated dependencies [de24373] +- Updated dependencies [21c8644] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [aa5bfdf] +- Updated dependencies [5d63c37] +- Updated dependencies [dc93781] +- Updated dependencies [20949e7] +- Updated dependencies [3d6392f] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/lint@18.0.0-next.0 + - @objectstack/runtime@18.0.0-next.0 + - @objectstack/plugin-security@18.0.0-next.0 + - @objectstack/metadata-protocol@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/plugin-auth@18.0.0-next.0 + - @objectstack/verify@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/service-automation@18.0.0-next.0 + - @objectstack/plugin-sharing@18.0.0-next.0 + - @objectstack/cloud-connection@18.0.0-next.0 + - @objectstack/driver-sql@18.0.0-next.0 + - @objectstack/rest@18.0.0-next.0 + - @objectstack/client@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/plugin-email@18.0.0-next.0 + - @objectstack/metadata@18.0.0-next.0 + - @objectstack/plugin-audit@18.0.0-next.0 + - @objectstack/service-settings@18.0.0-next.0 + - @objectstack/service-realtime@18.0.0-next.0 + - create-objectstack@18.0.0-next.0 + - @objectstack/service-storage@18.0.0-next.0 + - @objectstack/service-messaging@18.0.0-next.0 + - @objectstack/service-datasource@18.0.0-next.0 + - @objectstack/plugin-approvals@18.0.0-next.0 + - @objectstack/service-analytics@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/plugin-hono-server@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + - @objectstack/mcp@18.0.0-next.0 + - @objectstack/plugin-webhooks@18.0.0-next.0 + - @objectstack/service-sms@18.0.0-next.0 + - @objectstack/trigger-api@18.0.0-next.0 + - @objectstack/service-mailbox@18.0.0-next.0 + - @objectstack/service-package@18.0.0-next.0 + - @objectstack/driver-turso@18.0.0-next.0 + - @objectstack/console@18.0.0-next.0 + - @objectstack/account@18.0.0-next.0 + - @objectstack/setup@18.0.0-next.0 + - @objectstack/driver-memory@18.0.0-next.0 + - @objectstack/driver-mongodb@18.0.0-next.0 + - @objectstack/driver-sqlite-wasm@18.0.0-next.0 + - @objectstack/observability@18.0.0-next.0 + - @objectstack/service-cache@18.0.0-next.0 + - @objectstack/service-job@18.0.0-next.0 + - @objectstack/service-queue@18.0.0-next.0 + - @objectstack/trigger-record-change@18.0.0-next.0 + - @objectstack/trigger-schedule@18.0.0-next.0 + - @objectstack/plugin-pinyin-search@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 65856d111bd..f232c4412fb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.7.0", + "version": "18.0.0-next.0", "description": "Command Line Interface for ObjectStack Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/client-react/CHANGELOG.md b/packages/client-react/CHANGELOG.md index 9645096ff13..cb2071b6bff 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,200 @@ # @objectstack/client-react +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/client@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index 1057c8b4adf..ce02336466a 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "React hooks for ObjectStack Client SDK", "main": "dist/index.js", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index bc5a0fd5a13..6697e0f5b9c 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,259 @@ # @objectstack/client +## 18.0.0-next.0 + +### Minor Changes + +- 6a3fe25: feat(spec,service-storage,client)!: one upload-scope list — the upload requests' `scope` and the SDK's `storage.upload` close to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published request contract and on the SDK method that sends it, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. + + The presigned and chunked upload requests declared `scope` an open string, while the stored file record (`sys_file.scope`, a closed select) only ever took `user`, `tenant`, `private`, `temp` and `attachments`. Any other value reached the `sys_file` insert, the data engine refused it as an invalid option, and the upload door answered that caller error as `500 INTERNAL`, with a message telling the operator to restore the data engine. + + ### What changes + + - **`UploadScopeSchema` / `UploadScope`** (`@objectstack/spec`, new, exported from `@objectstack/spec/api`): the upload-scope vocabulary, declared once — `user`, `tenant`, `private`, `temp`, `attachments`. It is a different list from `StorageScopeSchema`, which classifies a storage configuration and is not read by any upload. + - **`GetPresignedUrlRequestSchema.scope` and `InitiateChunkedUploadRequestSchema.scope`** read it. The default stays `user`. A literal outside the list fails `tsc`, and a parse refuses it on the `scope` key. + - **`client.storage.upload(file, scope)`** (`@objectstack/client`): the `scope` parameter is typed `UploadScope` instead of `string`, default `user` unchanged, so a scope outside the list fails `tsc` at the SDK call. `client.storage.getPresignedUrl` and `client.storage.initChunkedUpload` take the request types above and narrow with them. + - **The `sys_file` scope select** (`@objectstack/service-storage`) takes its options from the enum, in its order, with the same labels. The stored values do not change. + - **The presigned upload door and the chunked upload door** answer a scope outside the list — any string, a case variant, `null`, a number — with `400 INVALID_REQUEST`, naming the allowed values, before a file record, a session record, an upload URL or a backend upload exists. `public` is refused by the same gate and keeps its remedy (`acl: 'public_read'` on the stored file record). An omitted scope is the default `user`, as before. A real data-engine fault still answers `500`. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload naming a scope outside the list (a key prefix such as `avatars`, a folder, a record path) | one of `user`, `tenant`, `private`, `temp`, `attachments`, or no `scope` for the default `user` | + | `client.storage.upload(file, scope)` called with a `scope` typed `string` (`@objectstack/client`) | pass one of the five, or type the value `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | + | a caller passing a scope typed `string` into either upload request | type it `UploadScope` | + + **The one-line fix: send one of the five upload scopes, or none.** `attachments` is for a file whose referrers are record attachment rows (it is what orphan tombstoning reads); `temp` for a scratch file; otherwise `user` or `tenant`. + + **No stored file record moves**: no upload naming another scope ever succeeded, so no stored record carries one. + + ### The kit + + - **D3 entry `upload-request-scope-closed`** carries the judgement no rewrite can make: which scope a file that was sent under a free name really belongs under. No D2 conversion: no metadata type carries the upload request, so `os migrate meta` has no authored source to rewrite. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries the upload request. +- 490cb6d: A write answer now carries the advisory validation-rule hits of that write as `warnings` + + Clause-②: yes (widening) + + A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. + + - **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. + - **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. + - **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. + - **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. + - **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. + + **For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. + +### Patch Changes + +- d112087: docs: the suggested-binding acceptance is deployment-level and takes effect + + Clause-②: no + + Text only. These are the published sentences that describe what confirming a package's `isDefault` suggestion does. They now match the behaviour `@objectstack/plugin-security` ships in this release. + + - `@objectstack/spec`: `ISecurityService.listAudienceBindingSuggestions`, `confirmAudienceBindingSuggestion` and `dismissAudienceBindingSuggestion` keep "writes the binding" for the confirm. The text now also says what that means: a `manage_metadata` holder accepts once for the deployment, `everyone`'s definition is derived again, and a dismiss on an accepted suggestion revokes it. The package-level `isDefault` note keeps "an admin confirms in Setup" and adds who that admin is. + - `@objectstack/client`: the `security.suggestedBindings.confirm` and `.dismiss` TSDoc. + - `@objectstack/rest`: the route summaries of the confirm and dismiss routes. Paths and envelopes are unchanged. + - `@objectstack/cli`: the ownership-inventory citation for `sys_audience_binding_suggestion` now says the record is deployment-level and owned by the Default Organization. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/client/package.json b/packages/client/package.json index 73c617326c2..2986467119b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Official Client SDK for ObjectStack Protocol", "main": "dist/index.js", diff --git a/packages/cloud-connection/CHANGELOG.md b/packages/cloud-connection/CHANGELOG.md index dc0f5166ecb..8f73f2ba6ed 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,413 @@ # @objectstack/cloud-connection +## 18.0.0-next.0 + +### Minor Changes + +- 4806b18: Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). + + **Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): + - the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; + - the owner's `install-local` uninstall of that package took the set away from that member; + - the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. + + The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. + - `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. + - `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. + + **Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. + + **The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. +- c7d6037: A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) + + Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) + + A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. + + - **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. + - **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: + - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; + - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; + - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. + An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. + - **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. + - **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. + + Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. +- 2d21b9c: An unmanaged install no longer copies a package's protection lock, in either spelling: every copied item is editable, and the install's answer names each lock it dropped with the package's reason (ADR-0131 D6, ADR-0010) + + Clause-②: yes (widening: the new response field `strippedLocks` on `ImportUnmanagedPackageResponseSchema`, relayed on the install-local answer for `installMode: 'unmanaged'`) + + An unmanaged install copies a package into this environment as the environment's own metadata. A package can lock an item in two spellings: the `_lock` envelope (`_lock`, with `_lockReason`, `_lockSource` and `_lockDocsUrl`), or the author-facing `protection: { lock, reason, docsUrl }` block that a managed install translates into it. Until now an item locked with `_lock` was copied with that lock, so the next save of the copy answered `403 ITEM_LOCKED`; an item locked with `protection` was copied with an inert block that bound nothing, so the vendor's lock vanished without a word. The package permitted the unmanaged install through its `installModes`, so it chose to let environments take editable copies; a lock whose source is the package does not survive the copy. + + - **The copy** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` writes every item without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and, on a type whose schema declares one, without its `protection` block. Its answer gains `strippedLocks`: one entry per lock that bound (`no-overlay`, `no-delete` or `full`), in the order the items were written — `{ type, name, lock, reason? }`, with `reason` the package's reason verbatim and absent when it gave none. Each entry is the lock a managed install of the same item would have bound: an item carrying both spellings is reported once, with the `protection` block's lock and reason, because that is the one a managed install binds. A package that locks nothing answers `strippedLocks: []`. + - **Refusals are unchanged.** A lock the spec's declarations refuse (an unknown `_lock` value, a reason that is not a string, a malformed `protection` block) is not stripped: the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back. So is a `protection` block on a type whose schema does not declare one (`action`, `hook`, `page`). A managed install is unchanged: the package stays the authority, and its locks bind. + - **The declaration** (`@objectstack/spec`). `ImportUnmanagedPackageResponseSchema` declares `strippedLocks`; `lock` takes the three binding values of `MetadataLockSchema`. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'`). The answer carries the import's `strippedLocks` as the import states it. + - **`os package install --install-mode unmanaged`** (`@objectstack/cli`). Each dropped lock prints as one line under `Locks removed`, spelled `.: — `, so the installer reads why the vendor locked an item before editing it. An answer from a runtime that states no list prints no lock line. + + Nothing is removed or narrowed: the answer gains a field, and every refusal of the unmanaged install is unchanged. + +### Patch Changes + +- c8a9783: feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. + + - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. + - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. + - **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. + - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. + + Added (additive, nothing an author writes changes): + + - `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. + - `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. + - `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `bootstrapDeclaredPermissions` + - `normalizeManagedByVocab` + - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` + - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` +- 8601526: `RuntimeConfigPluginConfig.controlPlaneUrl`'s published docblock no longer says that an empty string declares "this runtime IS the cloud". It now says what the constructor does: `''` keeps marketplace and install requests on this origin, and that is all it says. + + Clause-②: no + + - The runtime that passes `''` may serve the catalog itself or proxy a control plane it does not name. The CLI's cloud-connected `os serve` passes `''` while its marketplace proxy forwards to the control plane `resolveCloudUrl()` answers. So `''` reads neither as "this runtime is the cloud" nor as "there is no upstream". This matches the `AppShellRuntimeConfig.cloudUrl` doc in `@object-ui/app-shell`. + - A runtime with no control plane says so with a decline spelling (`'off'` / `'none'` / `'local'` / `'disabled'`), in `controlPlaneUrl` or in `OS_CLOUD_URL`. The docblock now says this too. + - ⛔ No code, type, export or default change. The served `cloudUrl` and the telemetry posture do not change. +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- a360cee: fix(cloud-connection): install-local's `hotLoaded` reports whether the running kernel loaded the package (#22695) + + Clause-②: no + + - **What was wrong.** `POST /api/v1/marketplace/install-local` always answered `hotLoaded: true`. When the manifest came from the cloud catalog and `manifest.register` threw, the install still wrote the package to its ledger and answered `200` (the lenient path). The package loads at the next restart, but the answer said the running kernel held it already, and its `note` said the app was "now available in this runtime". + - **What it answers now.** On that path the answer carries `hotLoaded: false` and a new string key, `hotLoadError`, holding the register error's message. The `note` says the package is installed and cached, that the running kernel could not load it, and that the runtime registers it again at its next restart. A reader can then say "installed, loads at the next restart" instead of reporting a false success. + - **What did not change.** An install whose register succeeds answers exactly as before: `hotLoaded: true`, and no `hotLoadError` key. An inline (file-import) manifest whose register throws is still refused with `422 PLUGIN_REGISTER_FAILED`, and nothing is written. The lenient path still installs: the ledger entry is written, and the package loads at the next restart. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [cd3d391] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [35afb15] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [3ca71b6] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [e5f9f01] +- Updated dependencies [aa5bfdf] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/runtime@18.0.0-next.0 + - @objectstack/metadata-protocol@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index bfd482cc1f5..4dd4f5c755a 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Runtime-side client for an ObjectStack cloud control plane — marketplace browse proxy, install-local, device-code binding, org catalog and installed views, and the /api/v1/runtime/config discovery endpoint. Open mechanism (cloud ADR-0008): the hub service, plan policy, and entitlements stay server-side.", "type": "module", diff --git a/packages/connectors/connector-mcp/CHANGELOG.md b/packages/connectors/connector-mcp/CHANGELOG.md index c9ff05c2a69..e5750a81625 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,210 @@ # @objectstack/connector-mcp +## 18.0.0-next.0 + +### Minor Changes + +- f8566a0: feat(connector-mcp): a declarative stdio MCP transport runs in the declaring app's root, so its relative command and args resolve there wherever the server was started + + Clause-②: yes (widening) + + - **What changes.** For a declarative `provider: 'mcp'` connector with a stdio transport, the `mcp` provider factory now sets the child process's working directory to the app's root. It reads that root from the host through `ConnectorProviderContext.resolvePackagePath('.')`. Relative paths in the entry now resolve against the same root as the app's other relative refs, such as the `openapi` provider's `providerConfig.spec`. That covers a relative `command` path (`./bin/server`) and a script the launched program opens itself (`args: ['./scripts/server.mjs']` with `command: 'node'`). Before this change they resolved against the directory the server was started from. A boot from any other directory, such as `os verify --app examples/app-showcase/objectstack.config.ts` from the repository root, therefore registered the connector degraded, with no actions. + - **What does not change.** A bare executable (`node`, `npx`) is still looked up on `PATH`, and an absolute command or argument resolves as before. The `declarativeStdio` policy judges the `command` string exactly as written, and it runs before the root is resolved. `providerConfig.transport` gains no key: the working directory comes from the host, and an authored `cwd` there is not used. A host that does not provide `resolvePackagePath` starts the child exactly as before, in the host's current directory. A server that opens relative files of its own now opens them under the app's root. + - **New on the exported type.** The stdio variant of `McpTransport` gains an optional `cwd`, which the default client passes to the MCP SDK's `StdioClientTransport`. A hand-wired transport (`new ConnectorMcpPlugin({ transport })` or `createMcpConnector`) may set it. Left out, the child inherits the host's current directory, as before. + - **Nothing to migrate.** An app that started its server from its own directory sees no difference, because both anchors were the same directory. + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 02a8cc55511..05a738787c6 100644 --- a/packages/connectors/connector-mcp/package.json +++ b/packages/connectors/connector-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-mcp", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Model Context Protocol (MCP) connector for ObjectStack — a generic adapter that turns any MCP server's tools into a connector's actions on the automation engine's connector registry (ADR-0024).", "main": "dist/index.js", diff --git a/packages/connectors/connector-openapi/CHANGELOG.md b/packages/connectors/connector-openapi/CHANGELOG.md index bba801f52b6..b1bad218cff 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,199 @@ # @objectstack/connector-openapi +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index 60057fd40e8..67c05366ce7 100644 --- a/packages/connectors/connector-openapi/package.json +++ b/packages/connectors/connector-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-openapi", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "OpenAPI 3.x connector generator for ObjectStack — turns a declarative OpenAPI document into connector actions on the automation engine's registry, with a self-contained static-auth HTTP transport (ADR-0023).", "main": "dist/index.js", diff --git a/packages/connectors/connector-rest/CHANGELOG.md b/packages/connectors/connector-rest/CHANGELOG.md index 67f42565966..1be7c4ffccf 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,199 @@ # @objectstack/connector-rest +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 32cfaf55ffb..49e3fd92be7 100644 --- a/packages/connectors/connector-rest/package.json +++ b/packages/connectors/connector-rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-rest", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).", "main": "dist/index.js", diff --git a/packages/connectors/connector-slack/CHANGELOG.md b/packages/connectors/connector-slack/CHANGELOG.md index b2ab15e5b46..201fc6aff47 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,199 @@ # @objectstack/connector-slack +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index d2f961f2d60..89a32f4d68e 100644 --- a/packages/connectors/connector-slack/package.json +++ b/packages/connectors/connector-slack/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-slack", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Slack Web API connector for ObjectStack — registers `chat.postMessage` / `chat.update` / `call` actions on the automation engine's connector registry (ADR-0018 §Addendum, ADR-0022).", "main": "dist/index.js", diff --git a/packages/console/CHANGELOG.md b/packages/console/CHANGELOG.md index 03d0c0b3fd9..a4fd63b8a2f 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,677 @@ # @objectstack/console +## 18.0.0-next.0 + +### Minor Changes + +- 74c4b34: Console (objectui) refreshed to `088e0f705520`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 3 releasing of 3 changesets added across 3 non-merge commits. + + - **minor** — A record-form field can name one item of a metadata registry by its machine name: a `text` field that declares `widget: 'position-ref'` lists the position registry, and one that d… (objectui `088e0f705`) + - **patch** — A position approver is read through the spec's registry binding, `{ source: 'registry', type }` (objectstack#22710): its readable label and its designer picker come from the posit… (objectui `83b9fade2`) + - **patch** — A rich-text field no longer draws hint lines above its editor (objectui#12110, ruled B). The `markdown`, `html` and `richtext` editors used to show "Format: markdown" (or "Format:… (objectui `2498a0753`) + + Clause-②: no + + objectui range: `e5311165a8d6...088e0f705520` +- e040b8c: Console (objectui) refreshed to `0df67f237c5a`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 28 releasing of 29 changesets added across 21 non-merge commits; omitted: 1 release-nothing changeset, 1 commit carrying no changeset (they ship no package code). + + - **minor** — The Setup catalog for positions and permission sets reads the registry. It is built from the metadata-admin list and editors in their environment scope, not from new pages (object… (objectui `0df67f237`) + - **minor** — A bulk action's field-backed param now renders its real control on the selection bar, and the confirm step names the picked record (objectui#12105). (objectui `29b994902`) + - **minor** — A create form says when the server will fill a field: "Set automatically when saved." under a field whose `defaultValue` the server resolves at insert, while the field is empty (o… (objectui `b7cd5c891`) + - **minor** — A calendar view in a list draws every record of the days it shows, not the first 100 records of the object (objectui#12081 item 2). (objectui `16c8810ac`) + - **minor** — In-place edit on the record page and a list's in-cell edit honour the caller's field write grant, and the owner field honours the transfer grant (objectui#12103). (objectui `5f75cfad1`) + - **minor** — List views read an absent `userActions.editInline` as on, the v18 protocol default (objectui#12086, the consumer half of objectstack#22605). A list view is editable in place by de… (objectui `5330afd1f`) + - **minor** — Five write affordances that read no grant at all now read the affordance-to-grant map, so a caller without the grant is no longer offered a write the server then refuses (objectui… (objectui `5af42dbbd`) + - **minor** — After a marketplace install into the environment the console is rendering, the package page waits until that environment serves the installed package's app, and only then refreshe… (objectui `3c0f8069e`) + - **minor** — `element:repeater` and `element:number` publish the node-level `dataSource` binding as their only query input (objectui#12085). `@objectstack/spec` retires the element layer's fla… (objectui `ce991bd70`) + - **minor** — A create form asks its fields the create question, so a role that may create records but not edit them can fill and submit the form (objectui#12082). Every console affordance that… (objectui `023f00d46`) + - **minor** — **BREAKING** — The row type of the `VersionHistory` timeline is declared as `VersionHistoryEntry` instead of `VersionEntry` (objectui#6349, batch 10). `@object-ui/collaboration` publishes an unr… (objectui `12ff25631`) + - **minor** — The props of this package's `ThemeProvider` are declared and exported as `ThemePreferenceProviderProps` instead of `ThemeProviderProps` (objectui#6349, batch 10). `@object-ui/reac… (objectui `12ff25631`) + - **minor** — `ConnectionState` is a new export: the lifecycle state of one client connection, `'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'` (objectui#6349, batch 10)… (objectui `12ff25631`) + - **minor** — The props of this package's minimal `AppShell` container are declared and exported as `MinimalAppShellProps` instead of `AppShellProps` (objectui#6349, batch 9). `@object-ui/layou… (objectui `1b2d0160f`) + - **minor** — The props of this package's view switcher are declared and exported as `ListViewSwitcherProps` instead of `ViewSwitcherProps` (objectui#6349, batch 8). `@object-ui/plugin-view` pu… (objectui `d758f2f20`) + - **patch** — **The console no longer offers an object to a caller who cannot read it** (objectui#12109). A navigation entry bound to an object (`type: 'object'`, by its `objectName`) is not dr… (objectui `5a65f7d57`) + - **patch** — An object timeline with a `groupByField` draws one group per value, headed by the value's display name (objectui#12104). (objectui `206505c34`) + - **patch** — An org install from the Marketplace catalog's "Your organization" strip, on a cloud-managed environment, now waits until the environment serves the installed package's app before… (objectui `a21ff9a95`) + - **patch** — The console asks for recent activity only when the signed-in user may read it, and a failed read no longer shows as "No recent activity" (objectui#12081, item 8). (objectui `029bdaf45`) + - **patch** — An app that serves the signed-in user no navigation now opens on an app-level empty state, not on the Studio home (objectui#12079). (objectui `417e24567`) + - **patch** — The record chatter stores each reaction as the member's own `sys_comment_reaction` record, and stops writing the whole `sys_comment.reactions` column (objectui#12078; ruling A, am… (objectui `de302c731`) + - **patch** — fix(console): a long flow screen or action parameter list keeps its Submit / Confirm on screen (objectui#12080) (objectui `4d0ff2331`) + - **patch** — `ConnectionState` is re-exported from `@object-ui/types` instead of being declared a second time here (objectui#6349, batch 10). `@object-ui/data-objectstack` declared the same fi… (objectui `12ff25631`) + - **patch** — `ConnectionState` is re-exported from `@object-ui/types` instead of being declared a second time here (objectui#6349, batch 10). `@object-ui/collaboration` declared the same five… (objectui `12ff25631`) + - **patch** — Doc comments only. `UserDataAdapter` now carries the contract notes `@object-ui/app-shell` kept on its own copy of the interface: implementations must be safe to call concurrently… (objectui `1b2d0160f`) + - **patch** — The props of the console's page header are declared as `ConsolePageHeaderProps` instead of `PageHeaderComponentProps` (objectui#6349, batch 8). `@object-ui/layout` publishes `Page… (objectui `d758f2f20`) + - **patch** — Doc comment only. The `PageHeaderComponentProps` comment no longer says `@object-ui/app-shell` shares the name: the console's header props are now `ConsolePageHeaderProps`, so thi… (objectui `d758f2f20`) + - **patch** — The props of the dashboard's read-only record drill drawer are declared as `DashboardRecordDetailDrawerProps` instead of `RecordDetailDrawerProps` (objectui#6349, batch 8). `@obje… (objectui `d758f2f20`) + + ⚠️ 1 of these carries a breaking change: 1 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(scripts): an injected tree hashes apart from an installed one, so a local Spec Main Shape Gate reproduction cannot leave verdicts a sibling worktree replays (objectui#12114) (… (objectui `a194b4e37`) + + Clause-②: yes (widening: the html-page save gate and `os validate` read the SDUI manifest this console ships, and at this pin `element:repeater` and `element:number` publish the node-level `dataSource` binding as their only query input. A page source with `` and no flat `object` was refused with `missing-required-prop` against the previous manifest and passes against this one; a flat `object` / `filter` / `sort` / `limit` still passes, now with a non-gating `unknown-prop` warning.) + + + + objectui range: `20c6d351ad74...0df67f237c5a` +- f451a59: Console (objectui) refreshed to `1fb1ced36243`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 10 releasing of 11 changesets added across 11 non-merge commits; omitted: 1 release-nothing changeset, 2 commits carrying no changeset (they ship no package code). + + - **minor** — The author of a comment on a record page can now edit and delete it from the record feed (objectui#12268). (objectui `7af0ab378`) + - **minor** — **BREAKING** — Both dashboard surfaces draw a widget's legacy `component` envelope the way it is authored: a `metric-card` node draws as before, and any other node draws the retired-format promp… (objectui `e8fc452c3`) + - **minor** — The console's assistant button no longer covers the record page's inline-edit Save or an attachment's Download and Delete, and deleting an attachment asks first (objectui#12248). (objectui `51c661e38`) + - **minor** — **BREAKING** — The exported field metadata types stop declaring the members `@objectstack/spec`'s `FieldSchema` refuses by name and no reader honours, and two type values become the spec's `Fiel… (objectui `10aba37d1`) + - **patch** — A record page's header totals now update when you edit or delete a row in one of its related lists (objectui#12244). Before, a quote's total in the header highlight strip kept its… (objectui `30c2e4e1d`) + - **patch** — fix(plugin-report): a report lays out a select dimension in the field's declared option order (objectui#12243) (objectui `e5c8ea7fe`) + - **patch** — Home's activity card names each row's object by its label in the reader's language (objectui#12238). (objectui `ee5c49171`) + - **patch** — A record activity feed shows a call, meeting or email logged on the record, instead of hiding it as a completed task (objectui#12228). On an object with no authored record page, a… (objectui `0cd322d78`) + - **patch** — A currency field's fixed currency, declared as `currencyConfig`, shows on the record quick-look panel and in related lists (objectui#12242). (objectui `10aba37d1`) + - **patch** — The record form stops reading a field's snake_case `visible_on`. A field's visibility is `visibleWhen`, the key `@objectstack/spec`'s `FieldSchema` declares (objectui#12242). (objectui `10aba37d1`) + + ⚠️ 2 of these carry a breaking change: 2 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 2 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ ci(test): the coverage lane runs eight shards, then both test matrices shard by measured duration (objectui#12208, objectui#12256) (#12263) (objectui `1fb1ced36`) + - _(no changeset)_ ci(type-check): the turbo cache replays unchanged packages, and pull-request and queue runs type-check only what the change affects (objectui#12222) (#12251) (objectui `1ce8db8d9`) + + Clause-②: no + + + + objectui range: `7578323a544e...1fb1ced36243` +- ba95607: Console (objectui) refreshed to `20c6d351ad74`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 33 releasing of 33 changesets added across 24 non-merge commits; omitted: 1 commit carrying no changeset (they ship no package code). + + - **minor** — The approval decision panel draws a short notice instead of nothing when it is placed anywhere other than an approval request's record page (objectui#12072). (objectui `20c6d351a`) + - **minor** — The per-group aggregation type that `useGroupedData` takes is declared and exported as `GroupAggregationConfig` instead of `AggregationConfig` (objectui#6349, batch 7). `@object-u… (objectui `c0c0a0d56`) + - **minor** — The view switcher's views are typed as `@object-ui/core`'s `ListViewVisualization`, and this package no longer re-exports that union as `ViewType` (objectui#6349, batch 7). `@obje… (objectui `c0c0a0d56`) + - **minor** — **BREAKING** — The console sidebar no longer lets a user drag the app's menu into a private order; the Pinned section is now the one place a user orders entries (objectui#12059). Arranging an ap… (objectui `8a55f0ccd`) + - **minor** — **BREAKING** — feat(types)!: `UnifiedViewConfig.chart` retires with a tombstone that names the spec's list chart block, and the three `object-chart` legacy-axis refusals stop calling those keys… (objectui `c7b30bd66`) + - **minor** — An `element:repeater` node may omit `properties.object` when its `dataSource.object` names the object. The zod arm `ElementRepeaterBlockSchema` now applies the same `object` waive… (objectui `2a48bd408`) + - **minor** — A pointer field draws the record it points at, the console reads an approval request's record page through the approvals routes, and the approval decision panel is built for the r… (objectui `a368ccb1a`) + - **minor** — **BREAKING** — feat(plugin-list)!: a chart list view binds only an ADR-0021 `dataset`; the legacy inline chart axes and their `name` / `value` floors are retired on every route (objectui#6152, r… (objectui `025341692`) + - **minor** — **BREAKING** — `createObjectStackUploadAdapter` no longer sends an upload's `path` as the storage scope (objectui#12055). (objectui `7282c6a51`) + - **minor** — **BREAKING** — The result type of `useConfirmDialog` is declared as `DesignerConfirmDialogState` instead of `ConfirmDialogState` (objectui#6349, batch 6). `@object-ui/app-shell` declares an unre… (objectui `1f1c4b526`) + - **minor** — **BREAKING** — feat(plugin-list)!: the renderers stop reading the list-view keys every door refuses: the pre-#2231 aliases, `calendar.defaultView`, and the undeclared keys a per-kind block carri… (objectui `3fd862510`) + - **minor** — **BREAKING** — `objectui validate` and `objectui check` judge a document through the strict authoring face (objectui#5250) (objectui `e4c0b5432`) + - **minor** — The runtime chat contracts have their own names, apart from `@object-ui/types`' authoring `ChatMessage` / `ChatToolInvocation` (objectui#6349, batch 5). The shape ` + + objectui range: `47b1f0bb7174...20c6d351ad74` +- ea9fd99: Console (objectui) refreshed to `4997995022f8`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 5 releasing of 5 changesets added across 7 non-merge commits; omitted: 2 commits carrying no changeset (they ship no package code). + + - **minor** — A Studio form field that declares the `ref-multi:permission` widget now renders a picker of the declared permission sets instead of the JSON editor fallback (objectui#12126). (objectui `499799502`) + - **minor** — The Marketplace's install answers now say what the runtime served (objectui#12098). (objectui `107139379`) + - **minor** — A paged grid's column footer reads the whole matched set, or says it reads the page (objectui#12081 item 3). (objectui `69bca0af6`) + - **patch** — A boolean field whose `defaultValue` is a CEL expression or a runtime token now gets that default on create (objectui#12121). (objectui `c9fc10b1e`) + - **patch** — More write affordances read the affordance-to-grant map, so a caller without the grant is no longer offered a write the server then refuses (objectui#12082, leg A of the write-cen… (objectui `752504520`) + + **In this console build, declared nowhere** — objectui merged 2 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ docs: stop teaching keys the strict authoring face refuses (objectui#12131, phase 1) (#12134) (objectui `66eb5604e`) + - _(no changeset)_ fix(examples): repair the class (i) keys the strict authoring face refuses (objectui#5250 slice C) (#12128) (objectui `802f0bc41`) + + objectui range: `0df67f237c5a...4997995022f8` +- 1e44436: Console (objectui) refreshed to `7578323a544e`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 29 releasing of 31 changesets added across 25 non-merge commits; omitted: 2 release-nothing changesets, 2 commits carrying no changeset (they ship no package code). + + - **minor** — **BREAKING** — A page route's breadcrumb no longer carries a 「Pages」 crumb (objectui#12198, executing the maintainer's ruling on objectstack-ai/cloud#2768). **BREAKING** for `@object-ui/i18n`: t… (objectui `7578323a5`) + - **minor** — A flow action whose run pauses for input now closes with the action's own `successMessage` (objectui#12234). (objectui `360cf3215`) + - **minor** — **BREAKING** — A dashboard widget's legacy `component` envelope takes a `metric-card` node only, and refuses every other node by name with the fix, on both zod faces and on the TypeScript type (… (objectui `6fb281d64`) + - **minor** — `BaseFieldMetadata` declares a field's help text as `inlineHelpText`, typed by reference to `@objectstack/spec`'s `FieldSchema.inlineHelpText`, and no longer declares `help` (obje… (objectui `923504f4a`) + - **minor** — A record whose `approval_status` reads `pending` no longer opens locked when the approvals read answered and found no request (objectui#12220). (objectui `5c2982adf`) + - **minor** — **BREAKING** — A free-plan organization's environment list no longer offers a primary button that opens a paywall dialog (objectui#12190). The maintainer's rule is that no primary button opens a… (objectui `fa778e67f`) + - **minor** — A `type: 'url'` action or an `element:button` whose target is `mailto:` now opens the mail client, where it used to fail at click (objectui#12191, the objectui half of ruling A+;… (objectui `334f0cff9`) + - **minor** — Console: the app's identity moves into the top bar (objectui#12201, the maintainer's ruling of 2026-10-12). (objectui `38bb37333`) + - **minor** — The `lookup` widget reads `@objectstack/spec`'s `allowCreate`, `lookupColumns` and `lookupPageSize` alone. Their snake_case twins are retired, with no alias (objectui#12157). (objectui `466a219eb`) + - **minor** — `LookupFieldMetadata` declares the lookup picker's `allowCreate`, `lookupColumns` and `lookupPageSize` in `@objectstack/spec`'s spelling, each typed by reference to the spec's `Fi… (objectui `466a219eb`) + - **minor** — Every built-in locale pack gains one key, `form.maxFileSize` (objectui#12156). It is the size-limit hint the flat `ObjectForm` appends to the description of a `file` or `image` fi… (objectui `b2316074a`) + - **minor** — **BREAKING** — A `file` or `image` field's size limit is now `maxSize`, the one spelling the widgets read and the spec accepts (objectui#12156). (objectui `b2316074a`) + - **minor** — **BREAKING** — The console boot screen says one honest line, and shows no English before the user's locale (objectui#12187, the first of its three fixes). (objectui `54159d329`) + - **patch** — The console builds again on a case-insensitive filesystem, which is macOS's default (objectui#12184). Two modules in the console's settings pages, the workspace-timezone prompt di… (objectui `08b5d97d1`) + - **patch** — A field-backed action param's help line reads the field's `inlineHelpText`, in the order the record form reads it (objectui#12213). (objectui `923504f4a`) + - **patch** — The record form draws a field's `inlineHelpText` under its control (objectui#12213). (objectui `923504f4a`) + - **patch** — Lookup links on a record page and on a custom page now carry the console's base path, so the same link works from a left click, a new tab, a middle-click and a copied link (object… (objectui `97e7d4edd`) + - **patch** — A console page load reads `sys_user_preference` once, not once per preference key (objectui#12187). (objectui `d48071e3d`) + - **patch** — The bell's Activity tab names each row's object by its label in the reader's language (objectui#12212). (objectui `fc1bc6664`) + - **patch** — An empty gallery draws the same empty state as an empty grid, translated, instead of "No items to display" in English (objectui#12217). (objectui `f5b66bf97`) + - **patch** — The lifecycle badge (`ManagedByBadge`) draws only for a caller who can author metadata (objectui#12194). An end user sees it nowhere: not on the record page, the object list heade… (objectui `f38a42827`) + - **patch** — A console page route stops loading the Studio's designers, the interface list and one file per icon before its content (objectui#12187). (objectui `6eca5b5a7`) + - **patch** — The `position-ref` grant picker no longer offers the `everyone` and `guest` audience anchors (objectui#12200). Every authenticated or anonymous principal holds an anchor implicitl… (objectui `a491b64a6`) + - **patch** — A record's Edit form saves the owner the user changes or clears (objectui#12205). The form dropped `owner_id` from every save, so a reassignment or a clear made in the Edit dialog… (objectui `6c437b07e`) + - **patch** — Two producers hand the `lookup` widget `@objectstack/spec`'s spellings, moving with the widget's own retirement of the snake_case reads (objectui#12157): (objectui `466a219eb`) + - **patch** — The detail view's inline-edit field enrichment no longer copies `allow_create`, `lookup_columns` or `lookup_page_size` off the object schema. The lookup picker reads `allowCreate`… (objectui `466a219eb`) + - **patch** — The internal ledger behind `ObjectGrid`'s relational copy set drops its `allow_create`, `lookup_columns` and `lookup_page_size` rows, because `LookupField` no longer reads those s… (objectui `466a219eb`) + - **patch** — An inline edit of a date-only field on a record page saves the day the user picked, in every timezone (objectui#12206). (objectui `803f678b3`) + - **patch** — The flat `ObjectForm`'s file-size hint now reads the spec's `maxSize`, and it is shown in the session language (objectui#12156). (objectui `b2316074a`) + + ⚠️ 5 of these carry a breaking change: 5 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 2 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ ci: Spec Main Shape Gate leaves the required set — advisory on PRs, hourly on main; the required-check pin declares the seven ruled names (objectui#12240) (#12250) (objectui `5039e8117`) + - _(no changeset)_ ci: two-speed CI — affected tests on PR and queue runs, full suite and coverage on main's push and hourly schedule, spec gate reuse by objectstack sha, fifteen gate workflows fold… (objectui `21fdeb34d`) + + Clause-②: no + + + + objectui range: `9646e2ed2bf3...7578323a544e` +- 2acff87: Console (objectui) refreshed to `9646e2ed2bf3`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 13 releasing of 14 changesets added across 9 non-merge commits; omitted: 1 release-nothing changeset, 1 commit carrying no changeset (they ship no package code). + + - **minor** — The FilterBuilder's row and group types are exported as `FilterBuilderRow` and `FilterBuilderRowGroup` instead of `FilterBuilderCondition` and `FilterGroup` (objectui#6349, batch… (objectui `26b0bd2d1`) + - **minor** — `FilterBuilderOperator` is no longer exported from this package (objectui#6349, batch 12). It was `@objectstack/spec`'s `ViewFilterOperator`, the protocol's canonical view-filter… (objectui `26b0bd2d1`) + - **minor** — The result type of this package's `toDomProps` is declared and exported as `FieldDomProps` instead of `DomProps` (objectui#6349, batch 11). `@object-ui/core` publishes a different… (objectui `35a2b59a5`) + - **minor** — The node type of the tree this package's parser produces is declared and exported as `ParsedSchemaNode` instead of `SchemaNode` (objectui#6349, batch 11). `@object-ui/types` publi… (objectui `35a2b59a5`) + - **patch** — The Studio fallback form for an email template no longer offers `category` or `isSystem`, and the fallback skill form no longer offers `triggerPhrases` (objectstack-ai/objectstack… (objectui `9646e2ed2`) + - **patch** — A gallery card whose title field is a lookup now shows the referenced record's name, not `[object Object]` (objectui#12188). (objectui `5dd583f5e`) + - **patch** — The list view's URL filter state reads the FilterBuilder's group by `@object-ui/components`' new name for it, `FilterBuilderRowGroup` (it was exported as `FilterGroup`; objectui#6… (objectui `26b0bd2d1`) + - **patch** — `ListViewProps.onFilterChange` and `ListViewProps.initialFilters` now name the advanced-filter group by `@object-ui/components`' new name for it, `FilterBuilderRowGroup` (it was e… (objectui `26b0bd2d1`) + - **patch** — `toFilterGroup` now declares its return type by `@object-ui/components`' new name for the FilterBuilder's group, `FilterBuilderRowGroup` (it was exported as `FilterGroup`; objectu… (objectui `26b0bd2d1`) + - **patch** — Under a tenancy wall (`isolated` or `group`), the security catalog's pages are read-only for everyone but the platform operator, and say why (objectui#12183). (objectui `d928c8394`) + - **patch** — `DataSource` inside this package is now a re-export of `@object-ui/types`' `DataSource`, the one adapter contract, instead of an older and looser second declaration of it (objectu… (objectui `35a2b59a5`) + - **patch** — On a metadata type the `OS_METADATA_WRITABLE` hatch elevates, an item a package ships now opens read-only in the metadata editor and in the permission matrix, and an item no packa… (objectui `5b20aed39`) + - **patch** — Studio's landing page and the permission matrix name the overlay that exists: an environment overlay, not an org overlay (objectui#12171). (objectui `b0c5f9ddf`) + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ ci(site): deploy the docs from a GitHub Actions workflow every 6 hours, and turn Vercel's Git-triggered builds off (objectui#12178) (#12182) (objectui `7e30d0b6e`) + + Clause-②: no + + objectui range: `088e0f705520...9646e2ed2bf3` +- 299a2c6: Console (objectui) refreshed to `a58626c88dc8`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 36 releasing of 36 changesets added across 36 non-merge commits. + + - **minor** — The timeline's unused "Overdue" bucket label is removed: `timeline.bucket.overdue` is gone from all ten language packs and from the timeline's built-in English defaults (objectui#… (objectui `c910630cf`) + - **minor** — **BREAKING** — Inside a dashboard widget's legacy `component` envelope, a `metric-card` is now judged by the slot's component arm alone, so every key that arm refuses is refused there too, with… (objectui `89cc738da`) + - **minor** — **BREAKING** — "Recently Accessed" shows each item's own label, in the current language, and a visit that does not change the list no longer writes the `ui.recent` preference (objectui#11678). (objectui `48c82c9d5`) + - **minor** — The calendar and the timeline start the week on the first day of the week of the user's locale (objectui#11675), instead of a fixed Sunday (calendar) and a fixed Monday (timeline)… (objectui `9ca3cacbe`) + - **minor** — The console's settings pages resolve icons through the shared `getLazyIcon` helper and no longer log `[lucide-react]: Name in Lucide DynamicIcon not found` (objectui#11679). (objectui `abd374bb2`) + - **minor** — `DefaultLoginPage` and `DefaultRegisterPage` offer a generic sign-up only where the server would accept one (objectui#11705). Under the default `invite_only` audience posture the… (objectui `7e2d5b0ee`) + - **minor** — Objects with a picklist-bound field save again from the OWD overview, the Setup fields and objects pages, the metadata-admin embedded-item editor, and `MetadataService.saveFields`… (objectui `da3545375`) + - **minor** — The keyboard-shortcuts dialog (`?`) lists only shortcuts that do something (objectui#11674). (objectui `de96f3d0c`) + - **minor** — **BREAKING** — A `metric-card` placed in a dashboard's widget slot refuses `label` by name and points at `title`, the key its heading is drawn from (objectui#4425). (objectui `eb4552e71`) + - **minor** — The console's login and register pages offer a generic sign-up only where the server would accept one (objectui#11691). `/api/v1/auth/config` states the sign-up rule as two keys,… (objectui `daa7caff4`) + - **minor** — An object-bound timeline no longer heads every past date "Overdue": a day before today goes under a neutral "Earlier" bucket, translated in every language (objectui#11676). (objectui `ce464d958`) + - **minor** — Studio reads shared picklists: a select field can use one, an object with a picklist-bound field saves again, and the picklist page is read-only (objectui#10202, the objectui half… (objectui `c3623eb11`) + - **minor** — The AI chat's tool-approval card and its "Open in Builder" handoff card are translated in every language (objectui#11667). (objectui `57d82cb34`) + - **minor** — The chat launchers show a marker while a proposed plan awaits the user's approval (objectui#11666, item 6 of objectui#2458). A user who closed the chat with a blueprint still wait… (objectui `848ba0e12`) + - **minor** — Five fixes on the AI build surface, from the 2026-10-05 cloud acceptance run (objectui#11658). (objectui `fc3c2cc1c`) + - **minor** — `action:group` and `action:menu` no longer read a member's `properties.params` (objectui#11638). A container member's `properties.params` no longer reaches the action runner. (objectui `73b5d7764`) + - **minor** — `LoginForm` shows its "Don't have an account? Sign up" row only when the caller passes `registerUrl` (objectui#11634). The prop used to default to `'/register'`, so a caller that… (objectui `f1a177c41`) + - **minor** — A field group's `visibleWhen` now gates its section on the record detail page, the same way it gates the section on the entry form (objectui#11630). Take an object whose `fieldGro… (objectui `76993f8ef`) + - **patch** — fix(plugin-tree): tree cells draw the same field faces as list cells, so a boolean no longer shows as `true` (objectui#11686) (objectui `a58626c88`) + - **patch** — **The record header's highlight chips share the row's free width and truncate only when it runs out (objectui#11684).** A Product with SKU "QA Widget 1" read "QA Wid…" in the reco… (objectui `7300fcafe`) + - **patch** — An authored `view:calendar` or `view:timeline` node loads its plugin and renders the calendar or the timeline, and a console boot no longer logs the registry's race warning for th… (objectui `e6dcd85cc`) + - **patch** — The approvals inbox's request drawer shows its record summary the way the record page shows the record (objectui#11677). (objectui `3409fe89e`) + - **patch** — The flow designer saves a screen field's `Min` and `Max` as numbers (objectui#11664). (objectui `5ba255538`) + - **patch** — The metric cards no longer write authored keys they do not read onto the page (objectui#4425). Rendered through `SchemaRenderer`, which is how every dashboard draws its KPI tiles… (objectui `a600924f2`) + - **patch** — Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (objectui#11659). (objectui `f9f4a62d5`) + - **patch** — The flow designer's SLA escalation switch on an approval node no longer writes a refused `escalation: { enabled: false }` block, and switching it off keeps the values the author e… (objectui `9f3ed7bca`) + - **patch** — Cancelling a background import shows the rows the server already committed, with Undo (objectui#11650). `ImportWizard`'s Cancel used to show "Import cancelled · 0 imported" withou… (objectui `846f98251`) + - **patch** — Drag-to-reorder works on a grouped sidebar menu, within each level (objectui#11626). `NavigationRenderer` had a sortable path only in its group-free arm. Every stock app's menu is… (objectui `b88937b65`) + - **patch** — The `object-kanban` board totals the view's `summarizeField` in each column header (objectui#11629). (objectui `f4370f426`) + - **patch** — On an object that declares no list view, a grid toolbar change no longer sends a save the door refuses (objectui#11643). Such an object opens on the "All Records" tab the console… (objectui `43671468d`) + - **patch** — The console's `/verify-email?token=…` page verifies the address again (objectui#11633). It used to send the token as `POST /api/v1/auth/verify-email` with a JSON body. better-auth… (objectui `8057a8b14`) + - **patch** — On a runtime with no marketplace that still mounts install-local (an offline boot, `OS_CLOUD_URL=off`), a local install's Details page now offers its local menu: re-seed sample da… (objectui `0baf86f1b`) + - **patch** — A dataset-bound dashboard widget names its comparison window from `compareTo.kind` (objectui#11632). `previousPeriod` reads "vs previous period" and `previousYear` reads "vs last… (objectui `e398a54f0`) + - **patch** — Two grid toolbar changes to one view in one session now both survive a reload (objectui#11642). Before, the second change overwrote the first: changing density and then sorting by… (objectui `531b26c8f`) + - **patch** — A lookup whose `dependsOn` names a parent field drops its selection when that parent changes or is cleared (objectui#11631). (objectui `c00039842`) + - **patch** — A grid toolbar change on a served view is stored where the save door keeps it, so it survives a reload (objectui#11625). This covers density, a column-header sort and the hide-fie… (objectui `59917c4b2`) + + ⚠️ 3 of these carry a breaking change: 3 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + + + objectui range: `0abd4f9f8769...a58626c88dc8` +- e23eccb: Console (objectui) refreshed to `e5311165a8d6`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 21 releasing of 23 changesets added across 24 non-merge commits; omitted: 2 release-nothing changesets, 1 commit carrying no changeset (they ship no package code). + + - **minor** — **BREAKING** — Studio and the metadata admin read a metadata type's overlay flag under its new name, `allowEnvironmentOverlay`, and the read-only banners name `OS_METADATA_WRITABLE` (objectui#12… (objectui `e5311165a`) + - **minor** — The marketplace package page's **Update** button opens an update dialog, and a package README's raw HTML is no longer printed as text (objectui#12146). (objectui `82bb1d584`) + - **minor** — **BREAKING** — A form field declares `step` and `className` on both faces, so the strict authoring face stops telling authors to remove two keys that work. A built-in form branch now joins an en… (objectui `d2500d8be`) + - **minor** — "Clone to customize" on a packaged permission set calls the server's clone door (objectui#7611, objectstack ADR-0126 §7.1). (objectui `6d5879fe9`) + - **minor** — **A dashboard or a list view the server refuses you, opened by its URL, now says it was refused** (objectui#12112, the console half of objectstack#22611's ruling A). (objectui `43e150518`) + - **minor** — Setup's "Assigned users" on a permission set writes a new grant by the set's name alone (objectui#7611, after objectstack#22863, ADR-0131 stage 6b-2-pre). (objectui `faf4ce952`) + - **minor** — **BREAKING** — A form field declares `avatarField`, the record field each candidate of the search picker draws its avatar from, on both faces, so the strict authoring face stops telling authors… (objectui `c0c2f792b`) + - **minor** — A `position` decision output lists the position registry and commits position names (objectui#7611, objectstack ADR-0131 D3/D4). (objectui `8cc7158b4`) + - **minor** — The capability select lists the capability registry, and the permission-set editor's provenance badge reads the layered envelope (objectui#7611, objectstack ADR-0131 D3/D7). (objectui `53ee2f854`) + - **minor** — **BREAKING** — A form field declares the rest of the field metadata its widget reads off the `fields[]` entry, on both faces, so the strict authoring face stops telling authors to remove keys th… (objectui `0665c3be7`) + - **minor** — Screen readers hear the names of more controls in the viewer's language (objectui#12145). (objectui `e723f58ee`) + - **minor** — The suggested-bindings panel shows each suggestion's real state and can revoke an acceptance (objectui#7611, after objectstack#22854). (objectui `f962cdc09`) + - **minor** — The record feed and a related list's Add existing and fallback remove read the affordance-to-grant map, so a caller without the grant is no longer offered a write the server then… (objectui `158c75d90`) + - **minor** — A paged table's first, previous, next and last page buttons have accessible names (objectui#12124). (objectui `dc6d28e86`) + - **patch** — The Studio app's root now opens the app's first navigation entry, like every other app's root, and `StudioHomePage`, Studio's old metadata overview, is retired (objectui#12113). (objectui `ed12d48ec`) + - **patch** — A form field's own `defaultValue` is no longer drawn in its control, so the form never shows a value it does not save (objectui#12155). (objectui `f3d540814`) + - **patch** — A grouped grid's own first, previous, next and last page buttons have accessible names (objectui#12143). (objectui `2381c07f3`) + - **patch** — Console rendering fixes from the HotCRM browser pass (objectui#12110): (objectui `5330038f4`) + - **patch** — The record page's discussion shows stored replies under their comment, and every comment offers the reply input where the caller may post one (objectui#12144). (objectui `a7d492011`) + - **patch** — A record activity feed shows an object's `activityMilestones` rows of type `completed`, such as "Case resolved", instead of hiding them as completed tasks (objectui#12106). (objectui `1d1cfe0c1`) + - **patch** — A record-source refusal (`RECORD_SOURCE_REQUIRED`, raised by every arm that requires a record source) now reports the same path every time (objectui#5250). (objectui `9b7550999`) + + ⚠️ 4 of these carry a breaking change: 4 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ feat(scripts): the JSON-fence gate reads the published READMEs (objectui#12131, phase 2) (#12139) (objectui `ce9811c96`) + + + + objectui range: `4997995022f8...e5311165a8d6` +- bf492c8: Console (objectui) refreshed to `f0268ad78485`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 173 releasing of 175 changesets added across 170 non-merge commits; omitted: 2 release-nothing changesets, 4 commits carrying no changeset (they ship no package code). + + - **minor** — The chat build panel reads cloud's post-apply verification loop, and an unknown build phase no longer reads as "Building" (objectui#11988, the receiver objectstack-ai/cloud#2172's… (objectui `6d5eb3430`) + - **minor** — **BREAKING** — feat(types)!: a list view's legacy `options` bag is the `@objectstack/spec` list overlay's bag by reference, `ListViewTimelineConfig` is the list view's own `timeline` block, and… (objectui `5d77c099b`) + - **minor** — The built-in metadata designers leave the console's first load: `@object-ui/app-shell` registers them from a chunk it loads on its own, once the package entry has run (objectui#11… (objectui `4babf4035`) + - **minor** — The Studio landing (`/studio`) gets the console's own header (objectui#11863). Its old header held one control, the product wordmark linking back to Home. It now mounts `AppHeader… (objectui `7ebff3991`) + - **minor** — **BREAKING** — feat(types)!: the list view's `kanban`, `calendar`, `gallery` and `timeline` blocks are the `@objectstack/spec` 17.7.0 `ListViewSchema` slots by reference, and their `.passthrough… (objectui `cf62edf68`) + - **minor** — `element:repeater` reads the node-level `dataSource` binding first (objectui#11880, the repeater half of the objectui step of objectstack#11509, ruled A-narrow). (objectui `aaba8655a`) + - **minor** — The Studio landing (`/studio`) lists the packages the author was last in, above its package cards (objectui#11863). A visit to a package's pillar builder (`/studio/PKG/TAB`) is re… (objectui `054fd8441`) + - **minor** — The Import Wizard's preview checks a `time` column, and the user import's email column, the way the server's import judges them, so the import button no longer counts rows the ser… (objectui `fbad07885`) + - **minor** — **BREAKING** — feat(types)!: `ObjectGridSchema.defaultFilters` and the flat `ObjectGanttSchema` / `ObjectMapSchema` `filter` follow their `@objectstack/spec` rows (objectui#6152, round 10) (objectui `b13ea3c67`) + - **minor** — A console object list's toolbar grouping is now in the URL, beside its Filter panel conditions, search term and sort, so a grouped list can be shared as a link or bookmarked (obje… (objectui `75bacf93b`) + - **minor** — **BREAKING** — A form field declares `scale` and `currencyConfig` on both faces, by reference to `@objectstack/spec`'s `FieldSchema`, so the strict authoring face accepts the decimal places and… (objectui `18d7b489c`) + - **minor** — The list's Sort picker lists a field it keeps only for the current sort as removable, never as a new choice (objectui#11943). (objectui `4f4fc03f7`) + - **minor** — **BREAKING** — feat(types)!: the `object-calendar` element's `calendar` container is the `@objectstack/spec` 17.7.0 slot by reference, and its `.passthrough()` is gone (objectui#6152, round 9) (objectui `1473757bf`) + - **minor** — **BREAKING** — feat(types)!: `object-grid` `operations` and the `object-grid` / `object-kanban` / `object-calendar` `filter` follow the `@objectstack/spec` 17.7.0 rows (objectui#6152, round 8) (objectui `d7e9e9ab6`) + - **minor** — The metadata designer registries are observable, so a designer registered after a surface rendered now appears in it (objectui#11939, step 1 of objectui#6795). (objectui `a835c3149`) + - **minor** — **BREAKING** — The `sidebar` node declares `side` on both faces, so the strict authoring face accepts the edge the sidebar is drawn against (objectui#11070). (objectui `172acc313`) + - **minor** — New SDUI widget `cloud:workspace-timezone-notice`: one line on the Cloud welcome page naming the timezone a workspace was seeded with at creation (objectui#11930, the objectui hal… (objectui `45d585363`) + - **minor** — A new workspace now takes its creator's browser timezone when it is created, so its first administrator is no longer asked for a zone the browser already knows (objectui#11908). (objectui `dfa15c860`) + - **minor** — **BREAKING** — The metric sub-caption is retired on the reader side (objectui#11389, ruling C, the objectui half after `@objectstack/spec` 17.7.0). **BREAKING** for any dashboard that drew a cap… (objectui `d92b2a15d`) + - **minor** — `action:button` delivers `undoable` where a record is in scope, and publishes it (objectui#11168, ruling B on objectui#11754). (objectui `f0496bdf1`) + - **minor** — **BREAKING** — feat(types)!: retire `object-grid`'s `resizableColumns` on both faces, and stop `ObjectGrid` reading it (objectui#6152, round 7) (objectui `d73d98770`) + - **minor** — A record action greyed out by its declared `disabled` predicate now says why (objectui#11811). It shows the reason "Not available for this record", and the same text is its access… (objectui `455c64665`) + - **minor** — The list filter builder starts on the view's first column, hides hidden fields, and offers one empty check where "empty" and "null" mean the same records (objectui#11810). (objectui `1e1f09e99`) + - **minor** — Dates and times render in the time zone the server answers for the signed-in workspace (objectui#11693). (objectui `d50f7241b`) + - **minor** — The console asks an administrator once to set the workspace timezone while it is still the platform default, pre-filled with the browser's zone (objectui#11758, the gate the objec… (objectui `3bf8894ef`) + - **minor** — Installed Apps now reads a package the runtime refused to load at startup as "Not loaded", says why, and keeps its Uninstall (objectui#11645). (objectui `7b1770590`) + - **minor** — `DashboardWidgetSchema` chains `@objectstack/spec`'s `checkDashboardWidgetChartMeasureArity` after the metric-family check (objectui#11334, objectui#11417), so `safeValidateSchema… (objectui `c0862c1cc`) + - **minor** — `element:text` drops the two pre-convergence `variant` spellings `heading` and `subheading`, which `@objectstack/spec` 17.7.0 retires (objectstack#21015, objectui#11717). The regi… (objectui `c0862c1cc`) + - **minor** — The `page` node and the spec page-kind nodes (`record` / `home` / `utility`) chain `@objectstack/spec`'s `checkPageRequiresKind` (objectui#11717), as they already chain `checkPage… (objectui `c0862c1cc`) + - **minor** — Screen readers can name and reach the controls of the view tab bar, the settings form, the sidebar menus, the table's selection column and the percent cell (objectui#11690). axe-c… (objectui `9fc68aadb`) + - **minor** — A record delete confirmation now names what it deletes and confirms with a destructive "Delete" button (objectui#11695). (objectui `17acfbb9b`) + - **minor** — The organization Settings page no longer offers a slug edit the framework will refuse (objectui#11720). From framework 17.7.0, better-auth's organization update refuses a new slug… (objectui `ded4494b6`) + - **minor** — Object list pages are titled with the object's plural label (objectui#11696). (objectui `6be0f7a46`) + - **minor** — Read-only boolean values, the boolean list face and the Home greeting's punctuation follow the language (objectui#11689). (objectui `22b503c30`) + - **minor** — An empty list's copy no longer contradicts the page it sits on (objectui#11687). (objectui `834c5594b`) + - **patch** — Studio's "New automation" dialog opens on common starting points in plain words, with the trigger form under "Advanced" (objectui#11861: the automations entry, the last of the thr… (objectui `f0268ad78`) + - **patch** — A permission set's advanced facets pick with the shared `Select`, the control the rest of Studio picks with (objectui#11865, the facets' part of that card): a Row-Level Security p… (objectui `1ad0c0824`) + - **patch** — A quick-filter selection restored from the URL now applies when the list's dropdown chips come from the object definition (objectui#12001). (objectui `e616327ab`) + - **patch** — The list toolbar's hide-fields popover, Group editor and Row color select, and its user-filter chips, no longer offer a field the user may not read (objectui#11984). They now ask… (objectui `164f02cf6`) + - **patch** — Studio on a smaller or resized window (objectui#11795). Studio stays a desktop tool; three things change below desktop width: (objectui `70f1b2755`) + - **patch** — An `object-view` grid now exports the filter it shows, and returns to page 1 when that filter changes (objectui#11880, item 5). (objectui `5bc55c0c5`) + - **patch** — The designer forms pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the designer forms' part of that card): `FieldDesigner`'s type fi… (objectui `e8f9977f5`) + - **patch** — The console's Public Forms, Flow Runs and Profile pages pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the console pages' part of t… (objectui `28557855f`) + - **patch** — The dashboard designer's widget property panel picks a widget's Type and Color Variant with the shared `Select`, and the designers' property panel (`PropertyEditor`) draws a `sele… (objectui `5ad818310`) + - **patch** — The list toolbar's field-list read check is rolled back to restore the console's first-load budget, and it re-lands with objectui#11984 after objectui#11939's reclaim. (objectui `902fbe190`) + - **patch** — Studio's Interfaces create dialog picks a new page's source kind, and a new report's dataset and measure, with the shared `Select`, the control the rest of Studio picks with (obje… (objectui `fb361efe0`) + - **patch** — Studio's package record sharing overview picks each object's internal and external sharing model with the shared `Select`. That is the control Studio's object Settings tab already… (objectui `b1f0dcc00`) + - **patch** — A console list's view-tab switch starts the next view with the quick-filter selections its own URL carries (objectui#11992). The object page read the `uf_FIELD` selections once pe… (objectui `9c3798249`) + - **patch** — Studio's object Settings tab picks the sharing model, the external sharing model, the name field, the lifecycle field and the next highlight field with the shared `Select`, the co… (objectui `d21bd083e`) + - **patch** — A rolled-back Studio publish shows its "Nothing was published" banner again (objectui#11985). (objectui `1b8388bef`) + - **patch** — Studio's *Explain access* panel picks the object and the operation with the shared `Select`, the control Studio's validation rule editor already picks with (objectui#11865, the *E… (objectui `0bbb67b9a`) + - **patch** — **BREAKING** — `element:record_picker` and `element:number` bind data through the node-level `dataSource` only (objectui#11880, part of the objectui half of objectstack#11509, ruled A-narrow). (objectui `74add0c12`) + - **patch** — The console header no longer shows a "Search ⌘K" trigger where it would open nothing (objectui#11912). (objectui `a920d8c29`) + - **patch** — A refused package publish names each refused item with the server's reason, on the package sheet and on Home's *Publish all* and the draft-preview bar (objectui#11922). (objectui `c6e1b8c6b`) + - **patch** — Studio's action "New" menu opens on common actions in plain words, with the blank action under "Advanced" (part of objectui#11861: the actions entry). (objectui `d8be368c7`) + - **patch** — Studio's flow-node inspector, in its fallback form, writes only config keys the node's contract declares (objectui#11968). (objectui `0ab2b9885`) + - **patch** — Studio's validation rule editor picks a rule's type, field, built-in format and severity with the shared `Select`, the control the condition builder beside them already uses (obje… (objectui `f19b91540`) + - **patch** — Studio keeps a dashboard widget as an unsaved edit while the author changes its measure, instead of showing a red error the moment the last measure is removed (objectui#11951). (objectui `989b19082`) + - **patch** — Studio's field inspector gives `rating`, `slider` and `progress` fields a number *Default value* control, so the default saves as a number (objectui#11966). (objectui `31741d5f4`) + - **patch** — Studio Interfaces: a leaf save that goes through now clears the red "Changes not saved" strip an earlier refused save showed (objectui#11950). (objectui `402adc8aa`) + - **patch** — Studio's field type catalog now holds every field type the spec declares: it gains `user`, `secret` and `record` (objectui#11909). (objectui `0c9fb8ea3`) + - **patch** — Studio's Interfaces pillar can now create a page in a writable package, and opens it on its source beside a live preview (objectui#11823, step 3). (objectui `7546570a4`) + - **patch** — Studio's validation-rule "New" menu opens on common rules in plain words, with the rule types under "Advanced" (part of objectui#11861: the validation-rule entry). (objectui `7546570a4`) + - **patch** — Studio keeps a new dashboard widget as an unsaved edit until a dataset and a measure are picked, instead of showing a red error right after *Add widget* (objectui#11910). (objectui `a523a3e2e`) + - **patch** — Studio's action editor writes a script body that names its language, so the first keystroke is no longer refused (objectui#11921). (objectui `7129cd575`) + - **patch** — Studio's field inspector offers no *Default value* control on a `record`, `location` or `address` field, as it offers none on a `composite` or `repeater` field (objectui#11947). (objectui `bcd3f0803`) + - **patch** — The record picker (`RecordPickerDialog`) is laid out from the design system's own primitives (objectui#11903). It is the dialog behind a multi-select lookup's "Browse all records"… (objectui `5ab2f19b3`) + - **patch** — A create form preselects the option a field's option list marks `default: true` (objectui#11914). (objectui `ccddd1186`) + - **patch** — The list's Sort picker no longer offers a field the user may not read (objectui#11943). Choosing such a field sent a sort the server refuses, and the list went blank on "no access… (objectui `e44f9bc43`) + - **patch** — A read-only package's form designer no longer tells a screen reader how to drag a field card (objectui#11924). (objectui `94f48a28b`) + - **patch** — Studio's condition builder no longer writes `!field` or a bare `field` for a field that is not a boolean, when it knows the field's type (objectui#11894). (objectui `247f50349`) + - **patch** — Studio's page previews draw the page's own kind, as the running app does (objectui#11933): the live preview beside an `html` or `react` page's source, and the Interfaces page canv… (objectui `ccc2824cf`) + - **patch** — The list Group panel picks its field with the shared `Select`, the control the Filter and Sort panels beside it pick a field with (objectui#11865, the Group panel's part of that c… (objectui `7241a81e0`) + - **patch** — Studio's validation-rule "Runs on" boxes follow the spec's events (objectui#11923). (objectui `70e3d7721`) + - **patch** — Studio's form designer can be arranged from the keyboard on a canvas of any width (objectui#11898). With a field card picked up, each arrow key now moves it one place in the form'… (objectui `60d11cd61`) + - **patch** — Studio's field pickers offer the fields of an object that is still an unpublished draft (objectui#11895). (objectui `2fec2e007`) + - **patch** — The list's Filter panel offers only the fields the user may read (objectui#11925). Its field list now asks the same field-level read check the list's columns already use, `checkFi… (objectui `077d19825`) + - **patch** — The console is back under its first-load budget. Two recent Studio changes are rolled back to get there: the common-rule starting points in an object's Validations "New" menu (obj… (objectui `3c888c6e6`) + - **patch** — The console's record page now says what its record read actually answered: no access, not found, or could not load (objectui#11902). It used to answer every failed read with "Reco… (objectui `f1781be3a`) + - **patch** — Studio's landing page now points a read-only package at the routes that exist for customizing it (objectui#11808). (objectui `e81c84693`) + - **patch** — Four more editors stop asking for the layers of an item that was never published, so the browser console no longer logs a 404 for it (objectui#11799). The framework answers `GET /… (objectui `a025f4d12`) + - **patch** — Studio's flow designer connects two nodes the flow already has (objectui#11905). (objectui `4ebc25001`) + - **patch** — Studio's automation canvas header names the open flow by its label, not by the metadata type word (objectui#11665, the residual of objectui#11659 ruling 6). (objectui `d52d90855`) + - **patch** — Studio's form designer announces a field card's role in the author's language (objectui#11872). (objectui `fc82a31a6`) + - **patch** — Studio, and the app designer's navigation canvas, show a label first and the machine name or id as secondary text, in the places that showed only the machine name or id (objectui#… (objectui `0feabe8f6`) + - **patch** — Studio's field inspector picks a field type from a searchable list grouped by category, where it was one flat select of every type (objectui#11793). (objectui `72f5c608e`) + - **patch** — Studio's new validation rules, hooks and actions start from safe defaults (objectui#11820). (objectui `b4693b7b7`) + - **patch** — A console object list's Filter panel conditions, search term and sort are now in the URL, so a filtered, sorted list can be shared as a link or bookmarked (objectui#11860). (objectui `340bfabf8`) + - **patch** — Studio's publish review and publish confirmation say what changes (objectui#11807). (objectui `b9d9397b3`) + - **patch** — The Import Wizard preview marks a cell exactly where the server's import refuses it on dates, on rating, slider, progress and toggle columns, and on email columns, so the import b… (objectui `9fc6ad784`) + - **patch** — Studio's Interfaces pillar can now create a dashboard or a report in a writable package (objectui#11823, step 2). (objectui `a49c4bd27`) + - **patch** — Metadata-form code fields stay editable when Monaco cannot load (objectui#11858). (objectui `368359179`) + - **patch** — The Approvals Inbox no longer links to a record when the approver has no read of its object at all (objectui#11878, ruling C on objectstack-ai/objectstack#7497). Such an approver… (objectui `230e4b230`) + - **patch** — The login and register pages say when the server cannot be reached, and offer sign-up only once the server has said whether it accepts sign-ups (objectui#11806). This applies to t… (objectui `09821b1db`) + - **patch** — The console's first page load no longer downloads the documentation reader's markdown extras (objectui#11854). (objectui `7afcea06e`) + - **patch** — Both *Duplicate* forms judge a new package id by the spec's id rule, the one the server refuses by (objectui#11855). (objectui `475bda1a2`) + - **patch** — Grid inline edit marks a row modified only when a value really changed, draws one "Actions" column, and the "Edit inline" toggle reports its state (objectui#11816). (objectui `9844bbf92`) + - **patch** — Studio's form designer can be arranged from the keyboard (objectui#11871). Picking a field card up with Space or Enter, moving it with the arrow keys and dropping it with Space or… (objectui `a476dee29`) + - **patch** — The profile page states the access the console resolves, not the auth library's raw `user.role` (objectui#11866). (objectui `0855fc671`) + - **patch** — Studio stops asking questions whose expected answer is an error, so the browser console no longer fills with red lines that hide real failures (objectui#11799). (objectui `5dff0272d`) + - …and 73 more releasing changesets in this range (list capped at 100; see the objectui range below). + + ⚠️ 10 of these carry a breaking change: 10 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 4 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(plugin-list): the hide-fields, Group and Row color lists and the user-filter chips ask the field read (objectui#11984) (#11999) (objectui `aa67386a5`) + - _(no changeset)_ chore(scripts): raise the console eager-closure ceiling by a fixed 5,120-byte allowance for Studio repairs while the designers are eager (objectui#11942) (#11949) (objectui `1c51e973b`) + - _(no changeset)_ feat(app-shell): Studio Interfaces pillar creates a page and opens it on source plus live preview (objectui#11823 step 3) (#11932) (objectui `f3a0488c9`) + - _(no changeset)_ feat(app-shell): Studio's validation New menu opens on common rules, with the rule types under Advanced (part of objectui#11861) (#11931) (objectui `19a734866`) + + + + objectui range: `a58626c88dc8...f0268ad78485` + +### Patch Changes + +- b53b949: Console (objectui) refreshed to `47b1f0bb7174`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 13 releasing of 14 changesets added across 14 non-merge commits; omitted: 1 release-nothing changeset (they ship no package code). + + - **patch** — The console's sign-in page no longer reads `/api/v1/i18n`, and the application's translations and locale list load with the session's credentials once signed in (objectui#12034). (objectui `47b1f0bb7`) + - **patch** — The record approvals panel draws its decision-progress tally through one module-internal indicator, `DecisionProgressIndicator` (objectui#12033, part of objectui#2763). The tally… (objectui `ba9e82026`) + - **patch** — The Studio header's *More* trigger keeps its own name (objectui#11794). While *Access*, the pillar it holds, was open, the trigger renamed itself to "Access", which hid the word t… (objectui `8de8ba280`) + - **patch** — The embedded item editor ("Save into object", opened from a metadata item's Related drawer) now saves into the parent's draft (objectui#12027). (objectui `ea79b7777`) + - **patch** — A compact record-preview card for any `(object_name, record_id)` pair, kept inside the package for the approval surfaces to compose (objectui#12029, the first child of objectui#27… (objectui `049012bf0`) + - **patch** — Studio navigation details (objectui#11794): (objectui `8f815f4fe`) + - **patch** — Three more controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the list view and the chatbot): `ListView`'s "Color by field" an… (objectui `8f8f760fa`) + - **patch** — Studio saves one way on a package: the permission matrix and hooks autosave to the package draft like the other pillars, every create dialog says *Save as draft*, and the Changes… (objectui `6694abe75`) + - **patch** — Five of the Studio design surface's pickers use the shared `Select`, the control the rest of Studio picks with (objectui#11865, the design surface's part of that card): in the nav… (objectui `5382a865f`) + - **patch** — Four plugin controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the plugins' single selects): `SharedViewLink`'s "Expires after… (objectui `2063f7a96`) + - **patch** — A quick-filter value restored from the URL now gets its field's type once the object definition loads, when the field is declared without its type (objectui#12008). (objectui `16b9d440d`) + - **patch** — Four metadata-admin pickers use the shared `Select`, the control the rest of the console picks with (objectui#11865, the metadata-admin previews and inspectors' part of that card)… (objectui `f2bff5ce8`) + - **patch** — The Create View dialog, the AI build panel's Excel import bar and the API console's method selector pick with the shared `Select`, the control the rest of the console picks with (… (objectui `869d0bfdf`) + + objectui range: `f0268ad78485...47b1f0bb7174` + ## 17.7.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 53220c69b79..96e29b6b819 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.7.0", + "version": "18.0.0-next.0", "description": "Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: @object-ui/console (https://github.com/objectstack-ai/objectui).", "license": "Apache-2.0", "homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/console", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index e828dab7928..d9d610c34fc 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,612 @@ # @objectstack/core +## 18.0.0-next.0 + +### Minor Changes + +- c28f317: feat(core): one by-name read of the security catalog (`createSecurityCatalogReader`) + + Clause-②: yes (widening) + + - **What is new.** `createSecurityCatalogReader({ registry, metadata })` returns a reader with two members: `resolve(type, name)`, the definition a position, permission set or capability name resolves to (or `undefined`), and `list(type)`, one entry per name. `type` is `'position' | 'permission' | 'capability'`. Each entry is `{ type, name, definition, source, packageId? }`. The types `SecurityCatalogType`, `SecurityCatalogSourceName`, `SecurityCatalogRegistry`, `SecurityCatalogMetadataService`, `SecurityCatalogSources`, `SecurityCatalogEntry` and `SecurityCatalogReader` are exported with it. + - **Where it reads.** ObjectQL's `SchemaRegistry` (`engine.registry`) first, then the kernel `metadata` service for the names the registry does not hold. Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items, stack-declared positions included, and the metadata service carries the security collections an app registers in memory but not the platform's permission sets. Both are required; construction refuses a missing one. + - **A name two packages ship** resolves the way the registry's by-name read does today: a stored override first, else the first-registered package's body. + - **What it does not answer.** Whether an item is in effect: the row `active` flag stays the authority, and no definition carries it. The position → permission-set binding. Organization scope: the catalog is environment-level. + - **Failures are loud.** A reader that throws, or a metadata read that lost a loader and found nothing, raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) instead of answering "no such item". A definition owned by a disabled package answers neither member. + - Nothing calls the reader yet; no grant changes. +- 2e10c9a: feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id. + + - **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing. + - **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`. + + **What stops conferring.** + + - **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report. + - **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing. + - **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again. + + **What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name. + + **What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect. + + **Nothing to migrate** in code or metadata. +- 67b669e: The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. + - **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. + - **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. + - **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate`), which applies `convertPositionBindingRows`. A name that organizations bound differently is reported `conflicting` and is never merged. +- 2ff0825: feat(plugin-security)!: the security plugin's own readers (set resolution, the delegated-administration gate, the permission explainer and the position-assignment refusal) read the security catalog and the activation ledger, not the catalog rows (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (enforcement stops reading `sys_permission_set` rows), shipped as `minor` under the launch-window convention for breaking changes. This is the second step of the cutover that `position-permission-sets-declared` registers: the authorization resolver already reads the catalog; now the security plugin's readers read the same catalog. + + **Permission-set resolution (`@objectstack/plugin-security`).** + + - FROM: a set name the metadata service and the bootstrap sets did not answer was loaded from a `sys_permission_set` row (the caller's organization's, else an organization-less one), its object map and field map included, and dropped when the row's `active` was false. + - TO: it is loaded from the security catalog (the environment registry the security plugin binds to its engine): every package's sets and every set an author saved through the metadata door. A set that only a row carries resolves nothing. The resolver already granted nothing through such a set; now its object and field permissions stop applying too. + - Fix: declare the set through the metadata door (`PUT /api/v1/meta/permission/NAME`) or in a package. + - A permission set that resolves only because a POSITION of the same name was folded into the request is dropped when the activation ledger (`sys_metadata_activation`, type `permission`) switched it off. The row's `active` column is not read. + + **Delegated administration (ADR-0090 D12).** + + - FROM: the sets a position distributes were its `sys_position_permission_set` rows in the caller's organization; `delegatable` was the organization's `sys_position` row's; the assignable-positions listing (`describeDelegableScope`) listed `sys_position` rows; a direct grant's set (and its `adminScope`) was its `sys_permission_set` row. + - TO: all four read the security catalog by name. A position distributes the sets its definition's `permissionSets` names, so the containment check and the listing see a binding declared only on the definition. `delegatable` is the definition's. The listing offers every position the catalog holds. A direct grant's set is the catalog's definition of the name the grant carries; a grant naming a set the catalog does not hold is refused for a delegate (a tenant administrator is not judged). + - A name a position's `permissionSets` carries that the catalog does not hold must be on the delegate's allowlist too. It grants nothing today, but it starts granting as soon as a set of that name is authored. + - A catalog read that fails refuses the delegate's write. Before, it approved it as "distributes nothing". + + **The permission explainer.** "deactivated" is read from the activation ledger through the resolver's own read, so explain and enforce agree. A direct grant whose set the catalog does not hold is reported neither as expired nor as deactivated: there is no set to lose. + + **Position assignments (`sys_user_position.position`).** + + - FROM: refused `400 VALIDATION_FAILED` unless a `sys_position` row (the writer's organization's, or an organization-less one) carried the name. A position declared only in a package or saved only through the metadata door was refused. + - TO: refused unless the security catalog holds a position of that name. A registry-declared position is accepted. A name only a `sys_position` row carries (with no definition) is refused, because it grants nothing. A deactivated position is still accepted. + + **One position name per deployment under `single`.** + + - FROM: a Setup create of a position, or a rename into a name, that an environment definition already held overwrote that definition (another organization's label and description, an empty `permissionSets`). + - TO: refused `409 UNIQUE_VIOLATION`, the answer a second row of the name in one organization already gets, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`. + + **`@objectstack/core`.** `readDisabledCatalogNames(ql, positions, sets)` is exported: the resolver's one read of the activation ledger. It returns the names switched off for this deployment and is now shared by the explainer and the security plugin. +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- 4578c56: fix(import): `createMissingOptions` now keeps an unmatched option value through the engine's option check + + Clause-②: yes (widening) + + - **What was wrong.** With `createMissingOptions: true` on an import (objectui's Import Wizard sends it for *Keep unknown option values*), the import coercion kept a `select` / `radio` / `multiselect` cell that matched none of the field's options. The engine's write-path option check then refused the same value ("Priority must be one of: high, low"), so on every writable field the row still failed. Only the stage that refused it changed. + - **What happens now.** The import stores the kept value: a single-value cell trimmed, and each unmatched item of a multi-value cell. The dry run admits the same rows the write does. The engine admits exactly the values the import kept, on that import's writes. Every other value outside the options is still refused, and a field bound to a shared picklist that did not resolve is still refused. + - **The field's options do not change.** No option is added to the field. A later write that sends the field is judged against the options again, so sending the kept value back is refused and picking an option is accepted. A write that leaves the field out, such as an edit form saving only the fields it changed, is not judged on that field. + - **`@objectstack/spec`.** `ExecutionContext` declares `keptOptionValues`: per field name, the option values an import kept. It is server-constructed only, like `skipStateMachine` and `preserveAudit`, and is never client-supplied. The `ImportRequest.createMissingOptions` description now states exactly the behaviour above. + - **`@objectstack/core`.** `coerceRow` returns `keptOptionValues` beside `data` and `errors`, and `coerceFieldValue` reports the values it kept. `runImport` puts exactly those values on each write's context. A per-row write carries its own row's values, and a batched create carries the union of its rows' values. A row that kept nothing is written with the import's context unchanged. + - **`@objectstack/objectql`.** `ValidateRecordOptions.keptOptionValues` lets the single-value and multi-value option arms admit a listed value. The engine fills it from the write's `ExecutionContext.keptOptionValues` on insert, update and `validate`. +- f6e1d49: feat(core): `ObjectKernelConfig.bootPhaseHooks`, a declared option to bootstrap without dispatching `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` + + Clause-②: yes (widening) + + - **What is new.** `new ObjectKernel({ bootPhaseHooks: false })` runs dependency ordering, every plugin's `init()`, the core-service fallbacks, every plugin's `start()` and the system-requirement check exactly as before. It then stops without dispatching the three boot-phase hooks, and the kernel is `running`. Only an explicit `false` withholds them. An absent option or `undefined` keeps today's boot, unchanged. `Runtime` forwards the option through its existing `kernel` config. + - **Who it is for.** A host that needs a kernel's registered definitions and started services, and nothing the boot phase does. The example is a repair kernel for an environment whose normal boot cannot finish because a boot-phase handler throws, never settles, or outlasts the host's timeout. Such a host no longer has to wrap the kernel's private `context.hook` to drop those names. + - **What it guarantees.** Every service and every definition a plugin registers in `init()` or `start()` is present, a driver it connects there included. `shutdown()` is unchanged: `kernel:shutdown`, every `destroy()` and every `onShutdown()` handler run. + - **What it does not guarantee.** That the definitions equal a full boot's. A definition a plugin registers in a boot-phase handler is absent, and nothing a plugin does in those handlers runs: seeds, reconciles, schedulers, audits, and the HTTP listener a server plugin opens on `kernel:listening`. Whether a given set of plugins registers anything there is a reading of that set, not a property of the option. The option withholds the kernel's own dispatch, not the hook names. + - **Loud, once.** A withholding boot logs one `warn` naming each withheld hook and how many registered handlers it left uncalled, with the counts also in the line's `withheldHandlers` field. Its completion line says the hooks were withheld. +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. +- 97610a5: `bootStack` composes what `objectstack serve` composes from the same configuration: the providers the app's `requires` names, and the plugins in the app's own `plugins` array + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — one composition rule.** For one configuration, `bootStack(config, opts)` now mounts what `objectstack serve` mounts from it, so an app's tests boot the composition its users get: + + - **The providers the app's `requires` names**, by `serve`'s own reader and table (top-level `requires`, otherwise each package body's), plus the always-on providers a mounted plugin hard-depends on. An app no longer lists them in `extraPlugins` by hand. + - **The plugins in the app's own `plugins` array**, by `serve`'s rule for an entry: an instance is mounted as written, a plain bundle is wrapped into `AppPlugin`, a package name is loaded from the app's root (`hostRoot`). + - **The caller wins by identity.** An `extraPlugins` instance takes precedence over a `requires` provider it is (exact `name` or class name) and over an app plugin with the same `name`; that app plugin is not mounted. `security` and `analytics` instances take precedence over an app plugin of the same `name` the same way. + - **`hostRoot` is the app's root** in the two places `serve` uses the config's directory: the automation service's `packageRoot` (where a declarative connector's package-relative file ref is read) — now also when `automation: true` asks for the service — and the root a string `plugins` entry is resolved from. A suite that does not run from the app's directory passes it. + - **Offline CI.** An app whose `plugins` array wires the marketplace-facing `@objectstack/cloud-connection` plugins gets them mounted, pointed at `OS_CLOUD_URL` (by default the public catalog). Set `OS_CLOUD_URL=off` in the test environment, before the configuration module is imported, to keep the suite offline. + + **What now fails that booted before (the narrowing).** + + - **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again; or share one boot with `bootStackOnce(config, opts)`; or, to keep two stacks of one app live at once, boot the second on a configuration built again (call its builder once more, or import a fresh module instance of it). A `{ ...config }` spread is not a configuration of its own: a live boot keeps references into the configuration's nested definitions. + - **An app `plugins` entry that cannot be loaded or registered fails the boot**, naming the entry (`plugins[i]`) and its remedy. `serve` logs such an entry and boots on; a test boot does not, so a plugin the app declares is never silently absent from its tests. + - **A provider or app plugin that refuses to start fails the boot** where the fixed plugin set never mounted it — for example a declarative connector whose package-relative file ref does not resolve from `hostRoot`. + + **`@objectstack/core`** exports the pieces both boots read: `CAPABILITY_PROVIDERS`, `CapabilitySpec`, `CapabilityIdentities` and `providesCapability` (the `requires` token → provider table and its exact identity match); `stackDeclaredCapabilities`, `resolveStackCollection`, `declaredPackageEntries`, `stackPackageBodies` and `collectFromPackageBodies` (the package-owned collection reader); and `materializeStackPlugin` with `StackPluginLoaders` (what a `plugins` entry becomes). + + **`@objectstack/cli`**: `os verify` boots the app anchored at the directory holding its config (`hostRoot`), as `serve` anchors it, so `os verify --app path/to/objectstack.config.ts` run from another directory reads the app's package-relative files (a declarative connector's spec, a string `plugins` entry) and resolves `--multi-tenant`'s organizations package from the app, not from the working directory. `serve` itself does not change: `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. +- 3089848: fix(core)!: a `gracefulShutdown: false` kernel no longer exits the process when its teardown times out + + Clause-②: no + + + + **BREAKING** for a host that builds `ObjectKernel` with `gracefulShutdown: false` and counted on the kernel to end the process when a teardown hung: a behaviour change, shipped as `minor` under the launch-window convention for breaking changes. + + - **What changes.** When `shutdown()` overruns `shutdownTimeout`, a kernel built with `gracefulShutdown: false` no longer calls `process.exit(1)`. It logs the timeout at `error`, marks itself `stopped`, and `shutdown()` resolves, so the host decides whether the process exits. The hung teardown is not cancelled: it keeps running in the background and holds whatever it has not released until it finishes or the process ends. + - **Why.** `gracefulShutdown: false` already meant that the host, not the kernel, handles the process signals, and the kernel lifecycle docs say such a kernel stays out of the process-management business. The timeout branch did not read the option. So a host that runs several `false` kernels in one process, one per environment, lost all of them when one environment's teardown hung. + - **Unchanged.** With `gracefulShutdown: true` (the default; the kernel installs its own SIGINT, SIGTERM and SIGQUIT listeners) a genuine timeout still logs `Shutdown timed out — forcing exit` and calls `process.exit(1)`. A teardown that throws instead of hanging still never exits the process, under either setting. `shutdown()` still never rejects, and no option, key or export is added. Every kernel `@objectstack/cli` builds takes the default, so its commands behave as before. + - **Who must act.** A host that sets `gracefulShutdown: false` (a server framework with its own signal handlers, or a host running one kernel per environment in one process) and relied on that exit to end a process whose teardown hung. Such a host already handles the signals itself; it now also ends the process itself. Call `process.exit()` in that host once `await kernel.shutdown()` returns, instead of waiting for the event loop to drain: `shutdown()` returns within `shutdownTimeout` whether or not the teardown finished, but a hung teardown can keep the loop alive after it. The timeout is reported in the kernel's `error` log line. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- 35ef501: An `update`-mode `validate()` preview judges the stored row merged with the patch, as the by-id update does, so an import dry run of a matched row admits the rows the import's update admits (#22445) + + Clause-②: yes + + The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. + + - **Before.** An `update`-mode preview (`engine.validate(object, rows, { mode: 'update' })`, `validateData`, and the import dry run of a row that matched a stored record) read no stored row, so the record its rules judged held only the patch. A `requiredWhen`, an option `visibleWhen` or a `validations[]` rule that reads a column the patch omits faulted there, and the preview refused the row with `rule_violation` / `unevaluable`, although the real update reads the stored row first and admits it. The refusal also said the omitted column was one "which this object does not declare", sending the author looking for a field that exists. + - **Now.** A row that carries its `id` (the address every update door already folds into the payload) is judged against the row that id names: the stored row is the rules' `previous`, and their record is the stored row merged with the patch, exactly as the by-id update judges it. A reference field a traversing rule reads is resolved from that merge too. The import dry run of a matched row sends the matched record's id this way, so nothing new crosses the protocol. + - **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it). A stored column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a value the caller could not read in full. File fields are compared in their stored form, so a readable file reference is judged as the id it holds. Measured on `driver-sql` (SQLite) and `driver-memory`, a plain readable column (number, currency, percent, boolean, date, datetime, JSON, multiselect, select, text) reaches the rules as its stored value. + - **Unchanged.** A row with no `id`, or whose id names no row the caller can read, is judged on the patch alone, as before. `required` and value checks still look only at the supplied keys, matching a PATCH. A merge that really violates a rule is refused, as the write refuses it. The preview still runs no `readonlyWhen` or primary-key strip, so it can report fewer dropped fields than the update. + - **The refusal names the real cause.** Where a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row), the refusal now says the value was not supplied and no stored row was read, instead of saying the object does not declare the column. A column the object really does not declare keeps the old sentence. + + `@objectstack/core`: the import runner's dry run passes the matched record's id with an update row. `@objectstack/spec`: the `ValidateDataRequest.mode` description and the `ValidateDataResponse` note say what an `update` preview now reads. + + The pending entry for the option-gate change in this release says an `update`-mode preview reads no stored row and refuses a cascade pick whose parent column the patch omits. That describes the preview before this change: with the row's `id`, the preview now reads the stored row and judges the pick as the write does. +- 874a38d: feat(core): the ADR-0069 auth-gate allow-list admits the ADR-0043 approval action route, `approvals/act`, as one exact route + + Clause-②: yes + + - **What the allow-list admits now.** `isAuthGateAllowlisted` answers `true` for the approval action route `approvals/act`, at the mount bases the gate already recognises (`/api/v1/approvals/act`, `/api/approvals/act`, and the dispatcher's prefix-stripped `/approvals/act`), unscoped or under one `/environments/` (or legacy `/projects/`) scope, with the query string and trailing slashes stripped as for every other entry. Nothing else moves: `/approvals/act/`, `/approvals/actx`, the approvals base and every `/approvals/requests/…` route stay gated. + - **Why this is not a loosening.** ADR-0043 authenticates the action page by its single-use token alone, and `plugin-approvals`' self-hosted mount (`GET` renders, `POST` redeems on the host app) has never passed through this gate. The entry gives the dispatcher's spelling of the same route the same token-only authentication, so a session held by an authentication policy (expired password, required MFA) is no longer refused there with that policy's `403` before the token is read. + - **Effect on a kernel whose dispatcher serves no `/approvals/act` domain.** A held session that reaches the route gets the dispatcher's `404 ROUTE_NOT_FOUND`, the answer every other caller already gets there, instead of the policy's `403`. Nothing on the REST surface is affected: no REST route serves `approvals/act`, and no shipped seam passes a path to `shouldDenyAnonymous`. +- c74d843: fix(core)!: an import's reference resolution asks the lookup TARGET its declared exposure before matching a cell (#22739) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `POST /api/v1/data/:object/import` and the async `POST /api/v1/data/:object/import/jobs` judge the ADDRESSED object's `enable` block. A lookup cell is resolved by matching its text against the lookup's TARGET object — an object nobody addressed. + + **FROM.** + + - For an administrator and a member alike, a cell was matched against a target whose declaration refuses the read on every data route (`enable.apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `list`): a cell naming an existing record stored its id, and a cell naming none answered `reference_not_found` — so the row report told the two apart. + - A field of the legacy type spelling `'reference'` (not a `FieldType`, so `ObjectSchema` refuses it, but still listed in the import, export and import-template doors' own type tables) took its target from the raw `reference` key: import matched its cells against that object and export expanded it. + + **TO.** + + - The runner asks the spec's one decision (`canServeApiOperation`) of the TARGET's `enable` block, for `list` — matching a cell is a predicate read over the target, the runtime `find` the spec maps to `list`. A target the decision does not serve answers every cell, naming a record or not (a pasted id included), `reference_not_found` — the answer a target the caller cannot read (no read permission, or a record its row scope hides) already gets. Nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. A declaration that cannot be read withholds the target too (fail-closed). + - A field is a reference on those doors exactly when it declares a `reference` string, as before, and its target is read through the spec's arbiter `referenceTargetOf`, which names none for a type outside `lookup` / `master_detail` / `user` / `tree`: such a `'reference'`-typed field's cell is stored as written, and export leaves its stored id. Every other non-reference type was never resolved by these doors and is unchanged. Measured over every git-tracked non-test source under `packages/` and `examples/` (`.ts`, `.js`, `.json`; fixtures excluded) at `0d326bfb12`: no shipped field declares `reference` on a non-reference type (0 object literals, 0 `Field.*` helper calls; the one literal pairing `type` with `reference` is a key-classification table, not a field), against a control of 30 reference-typed literals and 80 `Field.lookup` / `masterDetail` / `user` / `tree` calls; a planted literal and a planted helper call were both found. + + **Bindings.** + + - **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it is not judged (plugin-auth's identity import carries the reader since #22800). + - **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers. + - **Which fields count as references does not widen.** A `user` field written without `reference` still has no target on these doors (its cell reaches the write as written), although the arbiter gives it `sys_user`; adopting that target would widen what the import accepts and is not this change. + + **Fix, if an import relied on the old answer:** the target's own declaration decides — grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import. + + **Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` (spec and qa excluded) and `examples/`: twelve objects refuse `list` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (its own import door already answers 404); the control count of lookups into `sys_user` is 75. So no shipped object's import answer changes. Deployed and cloud-held object definitions were NOT MEASURED. +- 7b0a9c9: feat(core,trigger-api): a timestamped `X-Objectstack-Signature` form with a five-minute tolerance window, accepted by the inbound flow hook beside the body-only form + + Clause-②: yes + + - **The new form.** `x-objectstack-signature: t=,v1=`, where the hex is the lowercase HMAC-SHA256 of `.` under the flow's secret. The timestamp is inside the signed material, so it cannot be changed without the secret. The inbound hook (`POST /api/v1/automation/hooks/:flowName/:hookId`) refuses the value once `t` is more than 300 seconds from the server's clock, in either direction, so a captured post stops verifying five minutes after it was signed. + - **Refusals.** A value that starts with `t=` is judged by the timestamped form alone. If it is malformed, carries the wrong HMAC, carries the body-only HMAC, or has a `t` outside the window, the answer is the same `401 INVALID_SIGNATURE` a bad signature gets, and nothing is enqueued. + - **Body-only signatures keep working.** `sha256=` is still accepted, so no existing sender breaks. It signs no time, so a captured post can be replayed, and it is now deprecated: the trigger logs one `warn` line per armed hook that names the flow and the hook, the first time that hook accepts a body-only post. + - **`@objectstack/core` exports.** `signHttpBodyAt(body, secret, timestampSeconds)` signs the new form. `verifyHttpSignature(body, secret, header)` verifies both forms and returns an `HttpSignatureVerdict` (`{ valid: true, scheme: 'v1' | 'body-only' }`, or `{ valid: false, reason }`). `HTTP_SIGNATURE_TOLERANCE_SECONDS` is `300`. They sit beside `signHttpBody` and `HTTP_SIGNATURE_HEADER` in the one definition of the scheme. `@objectstack/trigger-api`'s `verifySignature` keeps its signature and now verifies through it, so it accepts both forms. + - **Unchanged.** ObjectStack's own senders, the messaging HTTP outbox (webhooks) and the flow `http` node, still send the body-only form. Receivers outside ObjectStack verify exactly that today. + + **To adopt it as a sender.** Sign at send time, not at enqueue: `t = Math.floor(Date.now() / 1000)`, then send `t=,v1=.">` in `x-objectstack-signature`. From TypeScript, call `signHttpBodyAt(rawBody, secret, t)`. +- fe58bbc: feat(runtime,core): inbound automation hooks work on a kernel with no raw HTTP app — the dispatcher admits `POST /automation/hooks/:flowName/:hookId` with no session, through to the trigger's own signature check + + Clause-②: yes + + On a self-hosted server, `@objectstack/trigger-api` serves a flow's inbound hook by mounting `POST /api/v1/automation/hooks/:flowName/:hookId` on the raw Hono app, where a third-party sender (a payment provider, a code host) posts a signed event with no session. A hosted tenant kernel has no raw app, so on that shape the path belonged to the dispatcher's `/automation` domain, whose anonymous floor refused every sender `401 UNAUTHENTICATED`. + + **What widens: one route passes the anonymous floor and the ADR-0069 gate with no session.** `@objectstack/core`'s auth-gate allow-list gains its first parameterised row: exactly `POST /automation/hooks/:flowName/:hookId` in the dispatcher's spelling (four segments, optionally under one `/environments/:id` scope), and nothing else. `/automation/hooks`, `/automation/hooks/x`, `/automation/hooks/x/y/z`, every other method on the route and every other `/automation` path stay gated exactly as before. + + - `@objectstack/core`: `isAuthGateAllowlisted(path, method?)` and `evaluateAuthGate(user, path, method?)` take an optional method. The existing rows are exempt for every method, as before, so a caller that passes no method gets the answer it always got. The new row is exempt for `POST` only, and only when a method is passed. `shouldDenyAnonymous` now passes its `method` to the allow-list beside its `path`. New export: `matchInboundHookRoute(path, method)`, the one reading of the route that the row matches through, returning the two path segments raw. + - `@objectstack/runtime`: the `/automation` domain's anonymous floor now asks the allow-list with its path and method. On the very next statement it hands the admitted route to the request kernel's `trigger-api` service (`ITriggerApiService.handleInboundHook`), ahead of every other `/automation` gate and service probe. The request goes with its body unread and the two path parameters decoded, and the service's `Response` comes back as it is. The service verifies the HMAC signature, so a correctly signed post is accepted and a wrongly signed or unsigned one is refused by that service, with its own answer. The membership gate skips the route for `POST` only, so a signed-in caller is judged exactly as an anonymous one, as on the self-hosted mount. + - No `trigger-api` service registered, or one without `handleInboundHook`: `501 NOT_IMPLEMENTED`, naming which, never `ROUTE_NOT_FOUND`. A service that throws, or a request whose body a transport already read: a sanitised `500 INTERNAL_ERROR`, logged. + + Nothing changes on a self-hosted server: the trigger's raw-app mount still serves the path there and never reaches the dispatcher, and the dispatcher mounts nothing that answers it. +- c4e7fa5: feat(core): export `relationConditionSites`, the one walk that finds a filter's nested-relation conditions and each one's target object (#22777) + + `relationConditionSites(fields, filter)` returns every `{ : { : … } }` condition a filter holds, at the top level and under `$and` / `$or` / `$not`, with the reference field, the target object (resolved by the spec's `referenceTargetOf`) and the condition. An id comparand, an operator bag and a non-reference field are not conditions. + + It moved unchanged from `@objectstack/metadata-protocol`, where it was module-private, so that the MCP stdio data bridge (`@objectstack/mcp`) and the generic data door ask each condition's target its exposure over the same sites. `@objectstack/metadata-protocol` now imports it; its behaviour does not change. +- b7cd1af: fix(core)!: an import reads WHICH fields are references through the spec's arbiter, so a `user` field written without `reference` resolves against `sys_user` (#22785) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing and a change to published output), shipped as `minor` under the launch-window convention for breaking changes. Two populations are affected, both only for a `user` field written without `reference`: + + - an import row whose cell holds the id of a user the caller cannot see (a user outside the caller's organizations): stored before, `reference_not_found` now, as on a `Field.user` field; + - a downstream reader of that column in `GET /api/v1/data/:object/export`: the cell held the stored user id and now holds the user's name, as a `Field.user` column already does. + + A `user` field's target is fixed by its type: the spec's arbiter `referenceTargetOf` gives `{ type: 'user' }` the target `sys_user` whether or not `reference` is written (`Field.user()` writes it; hand-written JSON, a form or an AI author may not), and the data door's `$expand` already reads it that way. `buildFieldMetaMap`, the field map behind the import, export and import-template doors, counted a field as a reference only when it carried a `reference` string, so those doors disagreed with the arbiter for such a field. It now asks the arbiter both whether a field is a reference and what its target is. + + **Door by door, before and after.** Measured on a real boot, as an administrator and as a member, on a fixture object holding a `user` field written without `reference` beside a `Field.user` one; the connector pull through its executor over a stub protocol. + + - **Import: `POST /api/v1/data/:object/import`, `POST /api/v1/data/:object/import/jobs`, and a mapping's `connectorSource` pull (same runner).** + - FROM: a cell holding a user's email or name was written as the field's id, so the row failed `reference_not_found` ("no sys_user record has id …") and nothing was stored. + - TO: the cell is matched against `sys_user` exactly as on a `Field.user` field, so an email or a name stores the user's id. A pasted id of a user the caller can see still stores. A value matching no user still fails `reference_not_found`, now in the import's own words ("no record matches …"), and a name matching several users fails `reference_ambiguous`. + - `sys_user` is asked its declared exposure first, as every lookup target is: where its `enable` declaration refuses `list`, the cell is refused. + - **The one population that narrows, to `Field.user`'s answer.** A pasted id of a user the caller's row scope hides (a user outside the caller's organizations) used to store, since the write's own reference check runs elevated. It now fails `reference_not_found`, as it already did on a `Field.user` field. The data door's `POST /api/v1/data/:object` is unchanged and still accepts that id. + - **Export: `GET /api/v1/data/:object/export`.** FROM: the column carried the stored user id (JSON and CSV). TO: it is expanded like a `Field.user` column and renders the user's name, so an exported file imports back through the name. + - **Import template: `GET /api/v1/data/:object/export?template=true`.** FROM: the column's instructions named no target ("The name of a record, or its id."). TO: they name `sys_user` by its label ("The name of a User record, or its id."). + - **Unchanged:** + - `Field.user` and every field declaring `reference`. + - A `lookup`, `master_detail` or `tree` field without `reference`: the arbiter names no target for it. + - A non-reference type declaring `reference`: still no target. + - A present `reference` that is not a string: still no target, and no throw. + - plugin-auth's identity import, which reads `sys_user`'s own map. That map has no reference-typed field without `reference`, so it is identical. + + **Fix, if a pipeline relied on the old answer:** none is needed for one that wrote visible user ids. A pipeline that imported ids of users outside the caller's organizations runs as a caller who can see them, as a `Field.user` field already required. A reader that needs the stored user id takes it from the record read (`GET /api/v1/data/:object`, without `expand`), not from the export. +- 51f36e7: The action activation door switches an action no package ships, its row names no package, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts an action the environment authored** (`@objectstack/runtime`). `POST /api/v1/actions/_activation/:object/:action` declares that it does not require the action to be packaged. FROM: an object-less action saved through the metadata door (`PUT /api/v1/meta/action/:name`), or one bound to an object no package ships, was refused `503` with `{ code: 'SERVICE_UNAVAILABLE' }` and the store's sentence about an empty package id, and nothing was written. TO: it is switched like a packaged one, `200`, its row carries no package (`package_id` is `null`), and dispatch then refuses it `409 ACTION_DISABLED` like any switched-off action. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared action `404`, an ambiguous name `409`, an engine with no ledger `501`). + - **An action is credited only to the package that ships it** (`@objectstack/runtime`). FROM: an action with no package of its own was credited to its route object's package, so an action the environment saved against a packaged object wrote that object's package into its row. TO: the row names the action's own package; for an action declared inside its object's `actions`, the object's package; otherwise no package. A `sys_metadata` placeholder or a tenant-authored action's package binding is not a shipping package. Rows of shipped actions are unchanged, including the platform's own actions declared inside their objects. No reader decides anything from `package_id`, so nothing that is switched on or off changes. + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, an error that declares its own pair keeps it (no ledger attached: `503 SERVICE_UNAVAILABLE`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The in-memory activation store refuses what the durable store refuses** (`@objectstack/core`). FROM: `InMemoryMetadataActivationStore.setActive` (the base of `InMemoryActionActivationStore` and `InMemoryFlowActivationStore`) accepted `packageId: ''`, which `ObjectStoreMetadataActivationStore.setActive` refuses. TO: it refuses `packageId: ''` with the same `TypeError` and the same sentence, before it keeps anything. If a test passes `''`, omit the key instead. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- e1deaef: feat(core): two pure row → definition mappers for the ADR-0131 upgrade ceremony (`permissionSetDefinitionFromRow`, `positionDefinitionFromRow`) + + Clause-②: yes (widening) + + - **What is new.** `permissionSetDefinitionFromRow(row)` turns one `sys_permission_set` row into the permission-set definition it carries, and `positionDefinitionFromRow(row)` turns one `sys_position` row into the position definition it carries. The row types `PermissionSetRowForDefinition` and `PositionRowForDefinition` are exported with them. Both sit beside `convertPositionBindingRows`. The upgrade ceremony (`os migrate`) is to apply them, to save Setup-authored rows through the metadata door. ⛔ Neither runs at boot. + - **The permission-set mapping.** The JSON-text facet columns become the camelCase body keys: `object_permissions` → `objects`, `field_permissions` → `fields`, `system_permissions` → `systemPermissions`, `row_level_security` → `rowLevelSecurity`, `tab_permissions` → `tabPermissions`, and `admin_scope` → `adminScope` only when the column is truthy and reads to a truthy value. `label` falls back to the name only when the column is `null` or absent; an empty-string label is kept (unlike the position rule below). A facet column that is `null`, absent, an empty string, JSON `null` or unparseable text answers its default; a facet a driver already parsed is kept as it is. Only the definition columns are read, so `active`, `managed_by`, `customized` and the timestamps never enter the body. The body is then filtered to the keys `PermissionSetSchema` declares. + - **The position mapping.** `{ name, label, description, delegatable }`: `label` falls back to the name unless it is a non-empty string (so `''` is replaced by the name). `description` travels only when it is a non-empty string: `null`, `''`, an absent column and a non-string are absent, and a whitespace-only string travels as stored. `delegatable` is `true` only for a stored `true`, `1`, `'1'` or `'true'`. `active` and `is_default` are row state and never travel. + - **No behaviour changes.** Both functions are pure (one row in, one body out). They produce the same output as the security plugin's own copies, `permissionSetBodyFromRow` and `positionBodyFromRow`, and nothing calls them yet. Neither validates: the metadata door still refuses a body or a name it does not accept. + +### Patch Changes + +- e9a1f5c: fix(core): a stopped `ObjectKernel` removes its SIGINT/SIGTERM/SIGQUIT listeners and never exits the process + + Clause-②: no + + - **What was wrong.** With `gracefulShutdown: true` (the default) the constructor added one listener per signal and kept no handle, so `shutdown()` never removed them. Every kernel a process built left three listeners behind, and each one called `process.exit(0)` on the next signal. A process that had built and stopped five kernels and still ran a sixth exited six times on one SIGTERM, and the first exit landed before the live kernel had drained. Eleven built-and-stopped kernels also tripped Node's `MaxListenersExceededWarning`. + - **What a kernel does now.** It holds its listeners from construction until it reaches `stopped`, and removes them at that point, whichever way it got there: `shutdown()` completing, the shutdown timeout, or a failed `bootstrap()`. A stopped kernel has no listener, so it cannot handle a signal. While a kernel is `stopping` its listener stays installed, so a repeated signal is absorbed and does not trigger Node's default action mid-drain. + - **Who exits the process.** Only the handler whose signal started the drain, and only once. A signal that arrives while the host is already stopping the kernel through `shutdown()` is logged and left to the host. The kernel no longer exits the process in that case. + - **Unchanged.** A running kernel still drains and exits with code 0 on SIGINT, SIGTERM or SIGQUIT (code 1 if `shutdown()` throws). `gracefulShutdown: false` still installs nothing. With `gracefulShutdown: true`, a genuine shutdown timeout still calls `process.exit(1)`; a `gracefulShutdown: false` kernel no longer exits (#22335). +- 91f46d0: An import row answers a sandboxed hook's refusal in the hook's own words, not the sandbox debug wrapper + + Clause-②: no + + When a hook body refused a row during `POST /api/v1/data/:object/import` (or the async `/import/jobs` job), the row's `error` read `hook 'NAME' threw: Error: SENTENCE`, while `POST /api/v1/data/:object` and `/createMany` answered the same refusal as `SENTENCE`. The import runner now reads the row's sentence the way those routes do: the hook's sentence, unchanged, with any `code` the body declared still on the row. A hook body that crashes (for example with a `TypeError`) is not a refusal, and its row reads as it did before. +- d7b26df: An import row for a sandboxed hook body that crashed, or that refused with a declared 5xx status, reads as a server fault instead of relaying the fault's text + + Clause-②: no + + When a hook body crashed during `POST /api/v1/data/:object/import` (or the async `/import/jobs` job), the row's `error` read `hook 'NAME' threw: TypeError: …`, and a hook refusal that declared a 5xx status relayed its own message. `POST /api/v1/data/:object` and `/createMany` answer both as server faults and withhold that text. The row now carries the same sentence those routes answer (`Internal server error`), and its `code` is unchanged: `IMPORT_ROW_FAILED` for a crash, the declared code for a declared 5xx. A hook refusal with no status or a 4xx status still reads in the hook's own words, and a database error's row text is unchanged. +- 6d5191e: An import row for the sandbox's own fault reads the create door's fault sentence, not the runner's diagnostic text (#22741) + + Clause-②: yes (widening) + + The QuickJS runner raises some `SandboxError`s itself: a hook or action body that runs past its CPU budget or past the wall-clock ceiling, calls a capability it was not granted, or meets a failed install or marshal. These carry no `innerMessage`. `POST /api/v1/data/:object` and `/createMany` answer them `500 INTERNAL_ERROR` `Internal server error`. The sync `/import` row and the `/import/jobs` stored row answered the runner's text instead, for example `hook 'guard' exceeded CPU budget of 50ms (after 3 pump iterations)`. + + - **`@objectstack/types`** exports `isSandboxOwnFault(error)`. It is `true` for an error whose `name` is `'SandboxError'` and whose `innerMessage` is not a non-empty string, which is how the runner marks the faults it raises itself. A body's refusal or crash carries `innerMessage` and answers `false`. + - **`@objectstack/core`**'s import row applies the rule a crashed body already gets: when the sandbox raised the error and the create door answers it with a 5xx, the row's `error` is the door's sentence. The row's `code` is unchanged (`IMPORT_ROW_FAILED`). A driver error, and an error a code-defined hook threw, keep the row text they had. +- c7a3234: perf(core,plugin-auth): an authenticated request resolves its caller's grants once, not twice + + Clause-②: no + + `resolveAuthzContext` learns who the caller is from the transport's `getSession`. Against `@objectstack/plugin-auth` that is better-auth's `getSession`, whose `customSession` hook resolves the principal's grants for the session payload's `positions[]` and `isPlatformAdmin`; `resolveAuthzContext` then resolved the same grants again, with the same arguments, for the request's envelope. Every authenticated request on a door that resolves identity through `resolveAuthzContext` with a better-auth session read (the runtime dispatcher and the REST server among them) paid every grant read twice: eight reads per resolution for a caller in an organization. + + `resolveAuthzContext` now runs inside a request-scoped grants memo. A resolution that already completed inside the same call, with the same user, organization and seeds, is served to the next caller that asks for exactly that resolution, so the hook's resolution serves the resolver's own. The decision a request is authorised with is unchanged: + + - The memo lives for one `resolveAuthzContext` call (an `AsyncLocalStorage` scope, closed when the call settles). Nothing is cached across requests; the cross-request grants cache keeps its own default-off switch, `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. + - An entry is served only when a fresh read would agree with it. No write may have started, been executed at the driver, or still be in flight on the engine since the first resolution opened: the engine's write epoch (bumped when a write starts) and a write observer the memo registers as an engine middleware (counting writes into the chain and, after the driver step, out of it) must both read what they read at the open, with nothing inside the chain. The observer sees statement execution, not commit visibility. A write inside an `engine.transaction()` becomes visible only at its COMMIT, outside every chain. On driver-sql (not on the Turso remote face, which opens no transaction), a request whose step 2 falls inside that one commit round trip is authorised as of its first resolution, and the next request reads fresh — the same answer as a write from another process. No grant validity boundary may lie between the two clocks. The organization must be the same (the session arm's dropped-claim re-resolution is its own entry). A `bypassGrantsCache` caller is never served, a failed resolution is never stored, and an engine without the write-epoch seam or `registerMiddleware` declines entirely. + - `plugin-auth`'s hook now passes the session's email as its seed email, exactly as `resolveAuthzContext` does for the same session, so the two calls ask for the same resolution. The seed reaches only the envelope's `email`, which the hook does not read: the payload's `positions[]` and `isPlatformAdmin` are unchanged, and platform-admin standing still compares the stored `sys_user.email`, never a seed. + + Where the saving does not apply, the request reads the grants twice, as before — always the safe direction: + + - a request that meets a concurrent write on its engine; + - a request whose session read itself writes: the first request on a fresh auth instance generates its signing key, and with an idle timeout configured `enforceSessionControls` stamps `sys_session.last_activity_at` about once a minute per session; + - the first `resolveAuthzContext` call on an engine, which registers the write observer and stores nothing for it. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 9ed424c7930..b67263d1441 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Microkernel Core for ObjectStack", "type": "module", diff --git a/packages/create-objectstack/CHANGELOG.md b/packages/create-objectstack/CHANGELOG.md index a28c884a093..c8f11df7691 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,54 @@ # create-objectstack +## 18.0.0-next.0 + +### Minor Changes + +- 19805d8: The blank starter ships a test story: `pnpm test` boots the scaffolded app's own stack with `bootStack` from `@objectstack/verify` and asserts one business fact through its in-process handle + + Clause-②: no + + A project scaffolded with `npx create-objectstack` used to start with no `test` script and no way to assert its own behaviour, so the first test an author wrote was a hand-assembled kernel or a faked `ctx`. The blank template now carries: + + - **`note.test.ts`**, one starter test. It boots the app the project declares (its objects, its plugins, the services it requires) on the real engine, in-process, with an in-memory database and no server, and asserts that a note without a title is refused with the engine's own `VALIDATION_FAILED` error naming `title`, beside the same note with a title being saved. It addresses the object as `Note.name`, so it follows the namespace scaffolding writes. + - **`vitest.config.ts`**: a 60 s hook timeout for the boot, vitest's console capture switched off, so a log line the stack writes while a worker shuts down cannot fail a green run, and `OS_REGISTRY_LOG: 'warn'`, which drops the engine's one-line-per-registered-item boot log. + - **`package.json`**: a `test` script (`vitest run`), and `@objectstack/verify` (at the project's `@objectstack/*` range) and `vitest` as devDependencies. The vitest range is `~4.0.0`, the one `objectstack init -t plugin` and `objectstack create plugin` declare, and it is generated from that shared policy rather than written by hand, so the three scaffolders cannot drift apart. `~4.0.0` is the range that installs on npm 10, the npm Node 22 bundles; `^4.0.0` does not. + - **`tsconfig.json`** includes every root `*.ts` (`"*.ts"`, as `objectstack init` writes it), so `pnpm typecheck` checks the starter test and the vitest config. + - **`README.md`** lists `pnpm test` beside `validate`, `lint` and `typecheck`. + + The project's CI workflow is unchanged: its steps are the script set every scaffolder's project must declare, and `objectstack init -t app` declares no `test` script. + +### Patch Changes + +- 5f98778: fix(create-objectstack): the scaffolded Dockerfile installs from the lockfile the project carries + + Clause-②: no + + Where pnpm is on PATH, `create-objectstack` installs with pnpm and the project gets `pnpm-lock.yaml` and no `package-lock.json`. The `Dockerfile` it wrote copied `package*.json` and ran `npm ci`, so `docker build` stopped with `EUSAGE` before `os build` ran. + + - The build stage now copies `package.json` and whichever of `pnpm-lock.yaml`, `pnpm-workspace.yaml` and `package-lock.json` exist, then installs from the lockfile: `corepack pnpm@10 install --frozen-lockfile` for `pnpm-lock.yaml`, `npm ci` for `package-lock.json`. With neither, the build stops and says to install once and commit the lockfile. + - pnpm is pinned to major 10, the major the template's `.github/workflows/ci.yml` installs with. An unpinned Corepack takes the registry's newest pnpm, which the Corepack bundled with Node 22 could not run when this was measured. + - Unchanged: `os build` in the image, the runtime stage and its pinned image tag. + - A project scaffolded before this release keeps its own `Dockerfile`. To fix one, replace its `COPY package*.json ./` and `RUN npm ci` lines with the build stage shown in the self-hosting guide. +- 31cd210: A new project's `pnpm-workspace.yaml` is now a short settings file: one comment line above each block instead of 57 to 66 lines of version history and measurements. `npm create objectstack`, `objectstack init` and a standalone `objectstack create` now write the same 21-line file. + + Clause-②: no + + - The settings are unchanged: `packages: []`, the same `onlyBuiltDependencies` and `allowBuilds` lists, and the same `peerDependencyRules.allowedVersions` entries, in the same order. A project installs exactly as before. + - Each block keeps one line that says why it is there: which pnpm versions read each build-approval key, and that the peer rules only silence a warning that was measured harmless. + - The measurements behind each setting now live in the `@objectstack/cli` source next to the values they explain, so they are kept up to date there instead of in every project. + - Already-scaffolded projects are not touched. Their longer comments can be deleted or kept; the settings are identical. +- e4ad677: fix(create-objectstack): a project name that starts with the template's namespace scaffolds instead of being refused + + Clause-②: no + + A project whose namespace begins with the template's own namespace plus `_` (for example `blank-probe`, namespace `blank_probe`, under the `blank` template) was refused with exit 1: "1 object name(s) still carry the template's 'blank_' prefix … This is a bug in the scaffolder". The rewrite was correct (`blank_note` became `blank_probe_note`), but the check that verifies it tested each name against the template prefix alone, and `blank_probe_note` starts with `blank_`. + + - The check now reports a name as stale only when it carries the template's prefix and not the new namespace's prefix, so `blank-probe` scaffolds with objects named `blank_probe_*`. + - A name the rewrite really missed (`blank_note` under namespace `blank_probe`) is still refused, as before. + - The same rule applies to every template namespace; no name is treated specially. + - No migration: a project that scaffolded before this release is unaffected, and a name that was refused before can be scaffolded again. + ## 17.7.0 ### Minor Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 30284d7034f..63d2cc78f91 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.7.0", + "version": "18.0.0-next.0", "description": "Create a new ObjectStack project — npx create-objectstack", "bin": { "create-objectstack": "./bin/create-objectstack.js" diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index 6308f29eeec..b5dcfda7cdd 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -36,7 +36,7 @@ export default defineStack({ // match the ObjectStack version you installed — change it when you // deliberately move to a new protocol major, not to silence a mismatch. // Guide: https://objectstack.ai/docs/upgrading - engines: { protocol: '^17' }, + engines: { protocol: '^18' }, }, // `automation` backs flow execution and materializes any declarative diff --git a/packages/create-objectstack/src/templates/blank/objectstack.manifest.json b/packages/create-objectstack/src/templates/blank/objectstack.manifest.json index 7ce5cc7f1c1..0956082cfd8 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.manifest.json +++ b/packages/create-objectstack/src/templates/blank/objectstack.manifest.json @@ -2,7 +2,7 @@ "$schema": "https://schemas.objectstack.dev/template-manifest.json", "name": "blank", "namespace": "blank", - "specVersion": "^17.0.0", + "specVersion": "^18.0.0", "displayName": "Blank Starter", "description": "Minimal ObjectStack environment with a single object — a clean slate for building.", "category": "starter", diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 74bf16a22de..525b315283b 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -16,16 +16,16 @@ "test": "vitest run" }, "dependencies": { - "@objectstack/spec": "^17.0.0", - "@objectstack/runtime": "^17.0.0", - "@objectstack/plugin-hono-server": "^17.0.0", - "@objectstack/connector-rest": "^17.0.0", - "@objectstack/connector-openapi": "^17.0.0", - "@objectstack/connector-mcp": "^17.0.0" + "@objectstack/spec": "^18.0.0", + "@objectstack/runtime": "^18.0.0", + "@objectstack/plugin-hono-server": "^18.0.0", + "@objectstack/connector-rest": "^18.0.0", + "@objectstack/connector-openapi": "^18.0.0", + "@objectstack/connector-mcp": "^18.0.0" }, "devDependencies": { - "@objectstack/cli": "^17.0.0", - "@objectstack/verify": "^17.0.0", + "@objectstack/cli": "^18.0.0", + "@objectstack/verify": "^18.0.0", "typescript": "^5.3.0", "vitest": "~4.0.0" } diff --git a/packages/drivers/driver-memory/CHANGELOG.md b/packages/drivers/driver-memory/CHANGELOG.md index 426064182fd..d90e468e49b 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,204 @@ # @objectstack/driver-memory +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index f12fb1f6c9f..2bcf18541cb 100644 --- a/packages/drivers/driver-memory/package.json +++ b/packages/drivers/driver-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-memory", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "In-Memory Driver for ObjectStack (Reference Implementation)", "main": "dist/index.js", diff --git a/packages/drivers/driver-mongodb/CHANGELOG.md b/packages/drivers/driver-mongodb/CHANGELOG.md index c32cedc3d25..a696204a438 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,204 @@ # @objectstack/driver-mongodb +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index 6ab6b4b9ae9..7a9789bb05a 100644 --- a/packages/drivers/driver-mongodb/package.json +++ b/packages/drivers/driver-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-mongodb", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "MongoDB Driver for ObjectStack - Native document database driver via official mongodb client", "main": "dist/index.js", diff --git a/packages/drivers/driver-sql/CHANGELOG.md b/packages/drivers/driver-sql/CHANGELOG.md index e97bf8b5b47..8f206425199 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,259 @@ # @objectstack/driver-sql +## 18.0.0-next.0 + +### Patch Changes + +- 9e9e6f9: feat(cli)!: `os meta resync` is deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + + In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. +- 3d886ee: A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows + + Clause-②: yes (widening) + + - **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. + - **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. + - **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). + - **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). + - **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). + - **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. +- 86f53a4: fix(cli): `os migrate plan` / `apply` / `unmapped-columns` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506) + + `os migrate plan` and `os migrate apply` only examine the objects their own boot registers, and that boot stopped at the stack: its config, its metadata and the platform floor. Nothing `os serve` mounts around a stack was there. On an app declaring `requires: ['auth']`, the plan examined 9 of the 68 tables the serving boot had created. `sys_account` read as an undeclared platform table, so its retired `issuer` column (and that column's unique index) was never a destructive drop. `os migrate apply --allow-destructive` dropped nothing, while the boot's drift line and `os migrate account-issuer` kept prescribing exactly that command. + + The two commands now also compose, each through the rule `os serve` itself reads, and so does `os migrate unmapped-columns`, which reads the plan's own `unmapped_column` findings and therefore needs the plan's object set: + + - the auth family behind `serve`'s auth gate: plugin-auth's identity objects, the security plugin and the audit plugin, when the stack mounts no `AuthPlugin` of its own, the `auth` tier is on, and an auth secret resolves; + - the provider of every capability `serve` mounts: the stack's `requires` and the always-on slate, as `os serve` composes it with no `--preset`; + - the REST API plugin; + - every `plugins` entry by `serve`'s rule for one: a package name is loaded from the app, and a plain bundle is wrapped as an app; + - the pinyin-search decision from the config's locales, so the `__search` companion columns a boot provisions are no longer listed as destructive drops on a config-only project. + + `os migrate unmapped-columns --object sys_account` (or any platform object the plan now declares) resolves the object and reads its unmapped columns, where it used to answer `OBJECT_NOT_FOUND`. + + Each piece is composed for its declarations only. Its `init()` runs, and its `start()` and lifecycle hooks do not, so no dispatcher, scheduler or seed runs inside a dry run. + + **What an operator sees.** On the shape that looped, `os migrate plan` lists `sys_account.issuer` and `uniq_sys_account_issuer_account_id` as destructive drops. `os migrate apply --allow-destructive` drops both, `os migrate account-issuer` reads zero, and the next boot prints no drift line for them. When no auth secret is set and the boot is not a development one, the plan says it did not compose the auth family, and how to compose it: run with the deployment's `OS_AUTH_SECRET` exported. `os migrate` reads no `.env` file. + + **driver-sql.** The deferred-DDL preview answers a rotation-declared object (`lifecycle.storage.strategy: 'rotation'`, such as `sys_activity`) from the rotator's facts: when the current shard and the read view exist, nothing is pending. It used to list `create_table` for such an object on every plan, and `apply` then created nothing. + + **Known limit.** A development boot, or one with `OS_TELEMETRY_DB` set, keeps lifecycle-classed objects in a sibling `telemetry` database. The one-shot migration boot does not provision that database, so it examines those objects against the primary one. +- e5687cb: An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` + + Clause-②: yes (widening) + + - **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. + - **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. + - **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. + - **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. + - **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. + - **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/observability@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index dfb89f0b533..eb4d8d7e134 100644 --- a/packages/drivers/driver-sql/package.json +++ b/packages/drivers/driver-sql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sql", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "SQL Driver for ObjectStack - Supports PostgreSQL, MySQL, SQLite via Knex", "main": "dist/index.js", diff --git a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md index b84df8f6e3d..b693dd6aec5 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,202 @@ # @objectstack/driver-sqlite-wasm +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/driver-sql@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index bdcf524b2cb..d647c17e987 100644 --- a/packages/drivers/driver-sqlite-wasm/package.json +++ b/packages/drivers/driver-sqlite-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sqlite-wasm", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "WASM SQLite Driver for ObjectStack — runs in browser/WebContainer (StackBlitz) without native bindings", "keywords": [ diff --git a/packages/drivers/driver-turso/CHANGELOG.md b/packages/drivers/driver-turso/CHANGELOG.md index 842e60a9515..fa34ae9e934 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,213 @@ # @objectstack/driver-turso +## 18.0.0-next.0 + +### Patch Changes + +- de70e97: Retirement prescriptions name `os migrate meta --write`: "… to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." + + Clause-②: no + + Wording only: no schema, key, type, export or error code changes, and every input that parsed or was refused before gets the same verdict at the same path. Only the closing sentence of the message changes. + + - A prescription covered by an ADR-0087 conversion used to close with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand." It now closes with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." The default run still only lists. `os migrate meta --write` rewrites in place each edit it can trace to one literal in one project file and lists every other edit with the reason it was not written. The sentence never says the tool rewrites your sources automatically, because `--write` does not write every edit. Text up to and including "for existing sources;" is unchanged, so a caller matching that prefix still matches. + - The three prescriptions for conversions that cover only part of a value (dashboard `compareTo.offset`, the script flow node's `config.actionType` and the package manifest `permissions` case) carry the same `--write` clause before their second clause. + - The `record:chatter` / `record:discussion` `position` value prescriptions (`'sidebar'`, `'inline'`, `'drawer'`) used to tell the author to run a bare `os migrate meta`, which the command refuses because `--from` is missing. They now name `os migrate meta --from 17` and close with the same sentence. + - `@objectstack/lint`: the script node's retired dispatch-key diagnostic and the dataset widget's `chartConfig.xAxis.field` hint close with the new sentence. + - `@objectstack/driver-turso`: the `timeout`, `localPath` and `wasm` config tombstones close with the new sentence. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/driver-sql@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index e042378aefd..a170a852fa6 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-turso", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Turso/libSQL Driver for ObjectStack — Edge-first SQLite with embedded replicas", "keywords": [ diff --git a/packages/formula/CHANGELOG.md b/packages/formula/CHANGELOG.md index a228a7206d5..5c7da943201 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,263 @@ # @objectstack/formula +## 18.0.0-next.0 + +### Minor Changes + +- 2b61f2d: A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. + + **What changes.** + + - **The renderer.** The text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. + - **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. + - **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. + - **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. + - **Unchanged:** a CRUD `filter` value keeps the single-brace dialect, and the value slots are CEL's. + + **Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | + | `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | + | `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | + | `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node's CEL value envelope — `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, by: { dialect: 'cel', source: 'current_user.id' } }` — then `'Due {{ due }}'`, `'By {{ by }}'` | + + **The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** + + **Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. +- 345d3f3: feat(formula): `isoDate(t)` and `isoDatetime(t)`, the string form of a CEL timestamp + + Clause-②: yes (widening) + + - **What is new.** Two CEL stdlib functions that turn a timestamp into ISO text on the UTC calendar. `isoDate(t)` returns `YYYY-MM-DD` and `isoDatetime(t)` returns `YYYY-MM-DDTHH:mm:ss.sssZ`, always with three-digit milliseconds and a `Z`. Both are in `CEL_STDLIB_FUNCTIONS`, so `introspectScope` advertises them and the build check accepts them. + - **The bytes the flow template dialect wrote.** `isoDate(today())` is the text `{TODAY()}` wrote, `isoDatetime(now())` is the text `{NOW()}` wrote, and `isoDate(daysFromNow(n))` / `isoDate(daysAgo(n))` are the text `{TODAY() + n}` / `{TODAY() - n}` wrote, in a flow value envelope. This holds at month, year, leap-day and DST-transition instants, whatever the host's zone. + - **`isoDate(today())` is the reference-timezone day.** `today()` is that day at UTC midnight, and `isoDate` reads the UTC calendar. Under a non-UTC reference zone, `isoDate(now())` can be a different day: it is the UTC day of the instant. + - **Only a timestamp is accepted.** Text, a number or `null` is refused, at build when the argument's type is known and at run otherwise. It is never coerced or rendered. For ISO text, parse it first: `isoDate(date(s))`. An invalid timestamp, or one outside 0001-01-01 to 9999-12-31, is refused at run. + - **`string(timestamp)` is still refused.** CEL defines that conversion as RFC 3339 text that drops a zero fraction, which is not the template's `.000Z`, so each shape has one spelling. + - **No write path changes.** An envelope that returns a timestamp (`today()`, `now()`, `daysFromNow(n)`, `addDays(…)`) still evaluates to a `Date` and writes what it wrote before. +- 9af0005: The shared build validator (`@objectstack/lint`'s expression rule over `@objectstack/formula`) judges `record..` against an object's declared read attachments (`ObjectSchema.attachedOnRead`) + + - **`@objectstack/lint`.** The field index the expression rule builds now adds each block an object declares under `attachedOnRead` to the names `record.` resolves to, and hands the shared validator the block's declared leaves. A predicate such as `record.viewer.can_act` on an object that declares the `viewer` block is accepted where it was refused as an unknown field. + - **`@objectstack/formula`.** `ExprSchemaHint` gains an optional `attachedOnRead` (block name → declared leaf keys). When a `record.` or `previous.` reference names a declared block, its next segment must be a leaf the block declares. Anything else is refused with the existing `unknown-field` code: `field` is the dotted path as written (`viewer.can_actt`), `suggestion` the nearest declared leaf (`viewer.can_act`), and the new optional `block` and `leaves` params carry the leaves the block declares, which the message names. No new refusal code. + - **`@objectstack/metadata-core`.** The ADR-0106 field-level-security masker classifies the new top-level key as passed through unchanged (`OBJECT_REFERENCE_POSITIONS.attachedOnRead`): a block name and its leaf keys never name a field, so no caller's served object definition loses or gains anything. + - **Unchanged.** An object that declares no block keeps every verdict it had, and a second segment off any other field is not judged. Index access, a method call on a block and a third segment stay unjudged. +- 94379f4: fix(formula)!: the unknown-field check judges every spelling of a `record` / `previous` member, not only the dot, so `os build` and the object save door refuse `record['typo']`, `previous['typo']`, `record.?typo` and `record[?'typo']` (#22428) + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set change, in both directions, of the `os build`, `os validate` and `os lint` verdicts and of the object save door, shipped as `minor` under the launch-window convention for accept-set narrowings. It narrows: an expression that names an undeclared field of its object in a non-dot member spelling (`record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']`, or any of those on `previous`), or an undeclared leaf of a declared read attachment in such a spelling, is now refused where it was accepted. It widens: three shapes the dot-only regex misread as a member of the root, and refused as unknown fields, are now accepted, because none of them names a member. They are text inside a string literal (`record.name == 'record.typo'`), a root name after another root (`vars.record.x`), and a method call on the root itself (`record.size()`). No refusal code is added. + + `validateExpression`'s field-existence pass judges each member an expression reads on `record` or `previous` against the object's fields, and refuses an undeclared one with the `unknown-field` code. It found those members with a regex over the dot spelling, so it judged `record.typo` and `has(record.typo)` and nothing else. `record['typo']`, `record["typo"]`, `previous['typo']`, `record.?typo` and `record[?'typo']` name the same column, and they reached the evaluator with no verdict at every slot the pass judges, among them a select option's `visibleWhen`, a field's `requiredWhen` and a validation rule's condition. There the expression faults on a key the record never carries, or reads a value that never exists. + + The pass now reads members through the same AST member reader the relationship-traversal analysis is built on, so every spelling gets the dot spelling's verdict, did-you-mean included: + + - `os build`, `os validate` and `os lint` refuse an expression that names an undeclared field of its object as `record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']` or any of those on `previous`, at `error`, with the message they already give for `record.FIELD`. + - The object save door runs the same pass, so an object write in publish mode that carries such an expression is now refused with an `expression-invalid` issue located at the slot, the build's own finding. + - A declared read attachment's leaf (`ObjectSchema.attachedOnRead`) is judged in the same spellings: `record.viewer['can_actt']` and `record.viewer.?can_actt` are refused like `record.viewer.can_actt`. An earlier entry in this release says index access on a read attachment stays unjudged; that describes the check before this change. + + **Remedy.** Declare the field on the object, or fix the typo to the field the message suggests. The spelling itself is not the defect: `record['status']`, `record.?status` and `record[?'status']` on a declared `status` are accepted, as before. + + **Unchanged.** + + - A computed key (`record[record.kind]`, `record[someVar]`) names no member before evaluation, so it is not judged. + - A method call on a member (`record.name.startsWith('A')`) still judges the member (`name`). + - Stored rows are not migrated or refused on read; an object stored before this change keeps loading until it is next saved, and that save is judged. Drafts are not gated. + - Measured before crossing: the stacks this repository ships give the same expression findings before and after the change. That covers `examples/app-todo`, `examples/app-crm`, both `examples/app-multi-package` sub-stacks, the objects, actions, flows, views and pages of `examples/app-showcase`, the 51 objects `@objectstack/platform-objects` exports, and `plugin-approvals`' `sys_approval_request`, whose eight action predicates read the leaves of its declared `viewer` read attachment and pass. No in-repo producer writes a non-dot spelling of an undeclared field. + - No public export or signature moves; only the doc comments of `ExprSchemaHint.fields` and `ExprSchemaHint.attachedOnRead` change. `analyzeRelationshipTraversals` answers exactly what it answered before: measured identical, set order included, on 4,528 analyses of the 2,264 `record` / `previous` expressions in this repository's TypeScript sources. +- e865300: A formula field can declare a currency result: `returnType: 'currency'`, with its currency in its own `currencyConfig` + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec`.** `FieldSchema.returnType` accepts `'currency'` beside `'number'`, `'text'`, `'boolean'` and `'date'`. A `currency` result is an amount of money. Its value is a bare number, as a currency field's is. Its currency is the formula field's own `currencyConfig`, the same `CurrencyConfigSchema` a currency field carries, with the same defaults and the same refusals: no `currencyConfig` means dynamic mode (the tenant default currency), and `{ currencyMode: 'fixed', defaultCurrency: 'USD' }` fixes it. Nothing is read at display time from the fields the expression references: the currency is a declaration on the formula. + - **Readers.** The filter doors judge a formula returning `currency` as the currency field it reads like: a text operator on it is refused (`INVALID_FILTER`), and the number door judges its comparands. A formula is still not title-eligible unless it returns `text`, and is still refused for `sum` / `avg` / `min` / `max` (it has no stored column). + - **Designer forms.** The field designer and the object designer's field grid both offer Currency as a return type. The field designer shows its Currency Config row for a formula whose return type is Currency, as it does for a currency field. + - **What is new in `@objectstack/formula`.** `inferFormulaReturn(expression, fields)` returns the declaration authoring stamps onto a formula field, judged over the host object's declared field map: `{ returnType: 'currency' }` when the expression is provably an amount of money, plus `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when its source amounts are in one fixed currency. "Provably money" is unit checking over the expression: amounts in one currency added, subtracted, scaled by a number or a percent, divided by a number, rounded, or picked by `min` / `max` / `coalesce` / a ternary. Two different currencies, an amount multiplied by an amount, or an amount plus a plain quantity are not money. For everything it cannot prove to be money it answers exactly what `inferExpressionType` answers, so a numeric formula stays `number`. Its types `FormulaReturnDeclaration` and `FormulaSourceField` are exported with it. `inferExpressionType` is unchanged. + - **`@objectstack/platform-objects`.** The designer help text for the return type and the currency config rows names the currency result, in all four locales. + - **Nothing to migrate.** Every value accepted before is accepted now. To declare a formula over money as money, declare `returnType: 'currency'` and copy the source currency field's `currencyConfig` onto the formula, or run `inferFormulaReturn` and write what it returns. + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 3902ee4c076..be70af5b1ac 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "ObjectStack canonical expression engine — CEL (cel-js) + ObjectStack stdlib + dialect registry", "main": "dist/index.js", diff --git a/packages/lint/CHANGELOG.md b/packages/lint/CHANGELOG.md index 382ada55624..ca6bb41bf19 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,1639 @@ # @objectstack/lint +## 18.0.0-next.0 + +### Major Changes + +- 317cddd: feat(spec)!: an element binds data through the node-level `dataSource` only — the flat `object` / `filter` / `sort` / `limit` of `element:record_picker`, `element:number` and `element:repeater`, and `object-grid.defaultFilters`, are retired (#11509) + + Clause-②: no (narrowing: the ten element-layer flat data-binding keys and `object-grid.defaultFilters` leave the accept set, and the component-props gate's `dataSource.object` waiver becomes a refusal) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). `@objectstack/lint` is `major` with it: its component-props rule refuses what it used to waive. + + **Why.** One page element carried two doors onto one query. Each of these flat keys was the same query as a key of the node-level `dataSource` binding (`ElementDataSourceSchema`), and the three renderers resolved them by three contradictory rules: the record picker let the binding win, `element:number` AND-combined the two filters, and the repeater read the flat keys alone and ignored the binding — while the component-props rule waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed `os validate` and drew "No records". The console moved all three elements onto the binding first (objectui#11880); from this release the binding is the one door. `object-grid.defaultFilters` was the legacy second spelling of `filter`, read only when `filter` lowered to nothing. + + **What changes.** + + - **`@objectstack/spec`.** `ElementRecordPickerPropsSchema` `object` / `filter` / `sort` / `limit`, `ElementNumberPropsSchema` `object` / `filter`, `ElementRepeaterPropsSchema` `object` / `filter` / `sort` / `limit` and `ObjectGridPropsSchema` `defaultFilters` are `retiredKey()` tombstones: each is refused at its key with the prescription, and its input type is `never`. The repeater's other spellings of its query keys (`objectName`, `where`, `orderBy`, `top`, …) now point at the binding. The `object-*` blocks keep their own keys, and the relationship-scoped blocks are unchanged. + - **`@objectstack/lint`.** `validate-component-props` (`component-props-invalid`, warning) reports an `element:record_picker`, `element:number` or `element:repeater` node with no `dataSource.object` at that path, instead of waiving the flat `object` for any type whose binding names one. A flat key is reported by its tombstone. + - **Conversions (stored rows, artifacts, `os migrate meta --from 17`).** `element-flat-data-binding-to-data-source` moves a flat key the binding lacks onto it, deletes one the binding already set where the binding won, and appends `element:number`'s flat filter to the binding's (they always AND-combined). It leaves for the author, as a TODO: a record-picker key beside a `dataSource.view` that sets no such key of its own, a repeater key the binding sets to a different value or beside a `view`, and an `element:number` filter pair that is not two rule arrays. `object-grid-default-filters-removed` moves `defaultFilters` onto an empty `filter` (absent, `null`, `[]` or `{}`) and deletes it beside a `filter` that has rules. Both run before `page-component-filter-record-to-rule-array`, which then converts a moved record-form filter at its new door. Both are retired from the load path: an author is refused at the parse. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ type: 'element:record_picker', properties: { object: 'deal', limit: 20, labelField: 'name' } }` | `{ type: 'element:record_picker', dataSource: { object: 'deal', limit: 20 }, properties: { labelField: 'name' } }` | + | `{ type: 'element:number', properties: { object: 'deal', aggregate: 'count', filter: [...] } }` | `{ type: 'element:number', dataSource: { object: 'deal', filter: [...] }, properties: { aggregate: 'count' } }` | + | `{ type: 'element:repeater', properties: { object: 'deal_note', sort: [...], titleField: 'subject' } }` | `{ type: 'element:repeater', dataSource: { object: 'deal_note', sort: [...] }, properties: { titleField: 'subject' } }` | + | `object-grid` `properties: { defaultFilters: [...] }` (no `filter`) | `properties: { filter: [...] }` | + | `object-grid` `properties: { filter: [...], defaultFilters: [...] }` | `properties: { filter: [...] }` — the fallback was never read beside a `filter` with rules | + + **The one-line fix: move each key from `properties` to the node's `dataSource` (a sibling of `type`), unchanged; rename `defaultFilters` to `filter` where `filter` is empty, and delete it where it is not.** + + **For a consumer that pins both repositories:** its objectui pin moves past objectui#11880 no later than its objectstack pin moves past this release — the converted shape is one only that objectui reads. + + **Who is affected, measured.** This repository authors none of the eleven keys: its one element-layer author (the showcase record picker) already binds through `dataSource`, and a tree-scoped absence pin (`packages/spec/src/ui/element-flat-binding-retirement.test.ts`) keeps it that way. Other repositories and deployed metadata were not measured here. + + ### The retirement kit + + - `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ElementRecordPickerProps:object|filter|sort|limit`, `ui/ElementNumberProps:object|filter`, `ui/ElementRepeaterProps:object|filter|sort|limit`, `ui/ObjectGridProps:defaultFilters`. D3 entries `element-flat-data-binding-retired` and `object-grid-default-filters-retired`, each with a step-18 rationale fragment. They absorb the three protocol-18 narrowings of the same keys to the rule array (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`, `object-grid-default-filters-rule-array`), whose keys are gone in the same major; `page-component-filter-record-to-rule-array` drops the retired doors from its reach. + - Generated: `authorable-surface/ui.json` (11 rows `[RETIRED]`) and the `ui/component` reference page. Hand-edited ledger: `dropped-refinements.baseline.json` loses the three rows whose only dropped refinement was a retired `filter`. + - Pins: the tombstones, the binding, both conversions and the tree-scoped absence walk in `element-flat-binding-retirement.test.ts`; the missing-binding refusal and the repeater trap in `validate-component-props.test.ts`; the docs gate's twin in `check-yaml-examples.ts --self-test`. +- 615c696: Each operand of a `get_record`, `update_record` or `delete_record` node's `filter` is a value slot now. A field's value, an operator's comparand and each element of a list comparand take a CEL value envelope, `{ id: { dialect: 'cel', source: 'record.id' } }`, which the executor evaluates in the run's scope before the query, or a literal handed to the query as written. A query placeholder — `{current_user_id}`, `{current_org_id}`, `{today}`, `{30_days_ago}` and the rest of the date macros — is such a literal: the query engine resolves it, and it passes untouched, as before. Every other single-brace `{…}` token at an operand is refused with its envelope: at `objectstack validate`, at `registerFlow`, at the metadata save door and by the executor's parse. An operand envelope that evaluates to `null` refuses the node, as a token that resolved to nothing did. + + Clause-②: yes (narrowing: a flow {…} token at a get_record, update_record or delete_record filter operand is refused, a whole-string query placeholder excepted, an object naming a string dialect at an operand must be a valid CEL value envelope, an operand envelope that evaluates to null refuses the node, and @objectstack/lint no longer exports validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN or FlowFilterTokenFinding and retires the flow-filter-token-unknown rule id; widening: a filter operand gains the CEL value envelope arm, the exported FlowNodeExpressionPath type gains the optional filterOperands field, ValueSlotTemplateOptions gains filterOperand, FLOW_NODE_EXPRESSION_PATHS gains the three filter rows, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains get_record and delete_record, the published GetRecordConfig, UpdateRecordConfig and DeleteRecordConfig JSON Schemas mark filter as a value slot, and flowFilterOperands, FlowFilterOperand, classifyFlowFilterOperandString and FlowFilterOperandStringKind are new exports) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL — its contract table names "filter values" — and Decision 3 deletes the single brace. A CRUD filter was the last value position that read the single-brace dialect: the executor interpolated the whole filter, resolving a flow token against the run's variables and handing a token no variable resolved on to the query engine when it was a query placeholder. Two `{…}` vocabularies met in one string. The query placeholders are the query-filter surface, which ADR-0032 puts out of its scope, so they stay; the flow token goes. + + **One classifier.** `classifyFlowFilterOperandString` (`@objectstack/spec/automation`) draws the line: a string that is one whole wrapped token the query engine resolves on the server (`isKnownFilterToken`) is a placeholder and passes; any other string carrying a `{…}` token is refused; the rest is a literal. The node contracts, `registerFlow`, `objectstack validate` and the executor's parse all read it, through the value-slot judge. + + **What an operand is.** The operands are the positions a value sits at, read the way the filter grammar reads a `FilterCondition` (`flowFilterOperands`): a field's value, each entry of an operator map (`$gt`, `$in`, `$between`, …), each element of a list comparand, inside `$and`, `$or` and `$not` and inside a nested-relation condition. An envelope there is one operand, and may evaluate to a list (`{ id: { $in: { dialect: 'cel', source: 'record.ids' } } }`). The filter's keys, operators and combinators are its structure, never evaluated. The top-level `filter` is never an operand. + + **What changes where a value may be absent.** The template erased a condition whose token resolved to nothing, and the node was refused. Under CEL an absent variable or key fails the run before any query, naming it, and an envelope that evaluates to `null` — a guarded form, a field that holds no value — refuses the node with the guard's message: a condition whose comparand is `null` or nothing does not narrow a query, it widens it. So route around the node with a `decision` where the value may be absent, rather than guarding it to `null`. To match an empty field, write the literal `null` (or `{ $null: true }`), never a computed one. + + **A flow variable named like a placeholder no longer shadows it.** Under the template a flow variable named `today` or `current_user_id` won over the placeholder of the same name. No flow token is read at an operand now, so the placeholder is always the query engine's; read the variable with an envelope, `{ dialect: 'cel', source: 'today' }`. No flow, variable or output variable in this repository is named like a placeholder. + + **A related field named `dialect`.** An object at a field's value that names a string `dialect` is read as an envelope. A nested-relation condition on a related object's field named `dialect` is written through an operator map, `{ rel: { dialect: { $eq: 'x' } } }`. No object in this repository declares such a field. + + **The lint rule `flow-filter-token-unknown` retires.** It reported a `{FUNC()}` token neither the flow template dialect nor the query placeholders define at a flow filter. Every token it reported is refused now by the value-slot judge at the same position and severity, with the envelope that replaces it, so the rule, its `os explain` entry and its exports (`validateFlowFilterTokens`, `FLOW_FILTER_TOKEN_UNKNOWN`, `FlowFilterTokenFinding`) are removed rather than left reporting the same defect a second time with the retired dialect's remedy. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `filter: { id: '{record.id}' }` | `filter: { id: { dialect: 'cel', source: 'record.id' } }` | an absent `record.id` fails the run before the query instead of refusing the node | + | `filter: { amount: { $gt: '{record.cap}' } }` | `filter: { amount: { $gt: { dialect: 'cel', source: 'record.cap' } } }` | a `null` cap refuses the node, as before | + | `filter: { id: { $in: ['{a}', '{b}'] } }` | `filter: { id: { $in: [{ dialect: 'cel', source: 'a' }, { dialect: 'cel', source: 'b' }] } }`, or one envelope evaluating to the list | — | + | `filter: { owner: '{$User.Id}' }` | `filter: { owner: { dialect: 'cel', source: 'current_user.id' } }` | in a run with no user `current_user` is `null` and the read fails: skip the node with a `decision` on `current_user != null` | + | `filter: { due: { $lt: '{TODAY() - 3}' } }` | `filter: { due: { $lt: { dialect: 'cel', source: 'isoDate(daysAgo(3))' } } }` | none for a whole number of days | + | `filter: { owner: '{current_user_id}' }`, `{ due: { $lt: '{today}' } }` | unchanged | a query placeholder stays the query engine's | + | `filter: { owner: '{today}' }` meaning a flow variable named `today` | `filter: { owner: { dialect: 'cel', source: 'today' } }` | the placeholder no longer shadows the variable | + + **The one-line fix: write each filter operand the flow computes as a CEL value envelope, and keep the query placeholders as they are.** + + **Who is affected, measured.** In this repository, at the base: seven flow tokens at a filter operand in the shipped examples (`app-crm` 2, `app-showcase` 3, `app-todo` 2 — `app-todo`'s two query placeholders stay), five in the docs (`getting-started/common-patterns`, `kernel/runtime-services/examples` 2, the `service-automation` README 2), the flows guide's and the spec's own remedy texts (a `$User.Email` read named `filter: { id: '{uid}' }`; a `recordId` refusal named `filter: { id: '{record.id}' }`) and the `FlowSchema` example; all are migrated in this change. The value-slot retirement's run-user changeset on this line names `filter: { id: '{uid}' }` for a `$User.Email` read; this one supersedes that line: read the user record with `filter: { id: { dialect: 'cel', source: 'current_user.id' } }`. hotcrm was not measured. + + **Still accepted, unchanged.** Every literal operand, every query placeholder, a filter's keys, operators and combinators, an empty filter, and a filter on `create_record`, which has none. The `filters` alias still converts to `filter` at load. + + ### The kit + + - **The contract.** `GetRecordConfigSchema`, `UpdateRecordConfigSchema` and `DeleteRecordConfigSchema` judge each filter operand: an envelope must be a valid CEL value envelope, and a `{…}` token in a literal is refused by the value-slot judge in its filter mode, naming the operand's envelope and what `null` does there. Each `filter`'s `describe()` and TSDoc is restated, and so are the `recordId` guidance and the run-user remedy, which named a single-brace filter value. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains a `filter` row per node, with the new `filterOperands` flag: both walks emit each operand at its own path (`filter.amount.$gt`, `filter.id.$in[0]`), never the filter itself. `GetRecordConfigSchema` and `DeleteRecordConfigSchema` join `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`. + - **The executor.** The CRUD executors parse their config as authored, evaluate each operand envelope through `AutomationEngine.evaluateValueEnvelope` before the query, hand every other operand on as written, and refuse the node — as a guard a `fault` edge cannot route — when an operand evaluates to `null`. The single-brace `interpolateFilter` is deleted. + - **The lint.** `objectstack validate` refuses a token at each operand position and judges an operand envelope as CEL through the shared judges; the wrong-syntax hints at a filter operand name its envelope; `flow-filter-token-unknown` retires. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 0e9e7b7: A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2). + + **What is refused.** In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare `{ : }` config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve. One judge says it everywhere (`flowNodeValueTemplateRefusals` / `valueSlotTemplateRefusals` in `@objectstack/spec/automation`): `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema`, `UpdateRecordConfigSchema` and `AssignmentConfigSchema` refuse it at the value's path; `objectstack validate` reports it as `expression-invalid` at `error`, located at the node and `config.`, which also refuses it at the metadata save door; `registerFlow` refuses the flow (a stored flow carrying one is skipped at boot with a warn naming it); and the `create_record` / `update_record` / `assignment` executors refuse the node before writing anything. Each refusal leads with `VALUE_SLOT_TEMPLATE_REFUSAL` and then names the CEL spelling of the string's tokens. + + **Not converted.** No D2 conversion rewrites any spelling: every token spelling authored in flows answers differently under CEL for some input (measured through both engines over the same variables, 13 of 25 probes the same, 12 different), so a rewrite would change what some flow writes. Where a value may be absent, whether the field should take nothing, `null` or a default was decided silently by the template; it is now the author's decision. + + **What is still accepted, unchanged.** A CEL value envelope and every literal (a token-free string, numbers, booleans, `null`, arrays, objects). A `filter` value keeps the template dialect; a text slot (`notify` `title` / `message`, a screen `description`, …) reads `{{ }}` holes and refuses a single-brace token. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | + | `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run | + | `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` | + | `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` | + | `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | + | braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal | + + **The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.** + + **Who is affected, measured.** A TypeScript-AST census of every `create_record` / `update_record` `fields` value and `assignment` value (all three shapes, same-file spreads included): this repository at `959c209d5` carried 21 authored sites (`examples/**` and `packages/verify/src`) — 20 refused by this change (9 bare references, 10 dotted paths, 1 `$error` path, all migrated in this change) and 1 `{$User.Id}` (`examples/app-todo`), which the run-user refusal on this line refuses and migrates; hotcrm at `c529de2` carries 91 (2 of them through a same-file spread) — 71 refused by this change (31 bare references, 36 dotted paths, 4 text with holes) and 20 that the date-macro and run-user refusals on this line refuse (15 date macros, 5 `{$User.Id}`). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `automation/flow-value-slot-template.ts` (`VALUE_SLOT_TEMPLATE_REFUSAL`, `valueSlotTemplateRefusals`, `flowNodeValueTemplateRefusals`), composed into the value-slot contracts in `automation/builtin-node-config.zod.ts`; one new dropped-refinement site (`automation/AssignmentConfig` `out.catchall`, the bare legacy shape's values). + - **The doors.** `AutomationEngine.registerFlow` and the `create_record` / `update_record` / `assignment` executors (`@objectstack/service-automation`); `validateStackExpressions` (`@objectstack/lint`), whose `warning` hint pointing a template expression at the envelope this refusal replaces. + - **The ledger.** The D3 semantic entry `flow-value-slot-template-dialect-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: no authored spelling maps losslessly. +- 2b61f2d: A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. + + **What changes.** + + - **The renderer.** The text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. + - **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. + - **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. + - **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. + - **Unchanged:** a CRUD `filter` value keeps the single-brace dialect, and the value slots are CEL's. + + **Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | + | `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | + | `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | + | `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node's CEL value envelope — `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, by: { dialect: 'cel', source: 'current_user.id' } }` — then `'Due {{ due }}'`, `'By {{ by }}'` | + + **The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** + + **Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. +- 5fb1746: A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate`. The run fails such an expression with `Unknown variable: X` (X being the root) on every evaluation, and the build door used to pass it. The reachable case is the run user: `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` — the spellings formulas, row-level security and the client accept — are refused, naming `current_user`, the one spelling flow CEL binds. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule). + + **Why.** Flow CEL evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, `record` bound to the record the run was handed (where an entrance hands it one), and `previous`, `vars` and `current_user` bound by the engine. Formulas, row-level security and the client also bind the run's user as `user`, `ctx.user` and `os.user`; flow CEL does not, because a top-level `user` would collide with a variable or a lookup field of that name. So an author who wrote the alias by habit shipped a flow that faulted at its first evaluation, and nothing warned. + + **What is refused.** At every flow CEL site the build door reads — a node's `config.condition` (the start node's trigger gate included), an edge `condition`, a `decision` branch `expression`, a screen field `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — a root that is none of: + + - the engine's roots: `previous`, `vars`, `current_user`; + - `record`, where an entrance the stack declares hands the flow a record — a record trigger (a `record-*` `triggerType`), a time-relative sweep, the inbound hook, a `type: 'flow'` action, a `map` node with an `itemObject`, or a `subflow` / `map` parent handed one — or where the flow binds a variable named `record`; + - a name the flow binds: a declared variable, an `outputVariable`, an `iteratorVariable` (a `loop`'s default `item` included), an `indexVariable`, an `errorVariable`, an `assignment` target, a node id, a screen field's `name`; + - a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it (an object-less flow action hands only the row `id`). + + The user spellings are told to write `current_user`, with the guard for a flow that can run without a user; any other root is named with what could have bound it, and the nearest in-scope name. The error is `expression-invalid`, `error`. Where the flattened-scope near-miss advisory ("`X` is not a field of … If `X` is a flow variable this is safe to ignore") named the same root, the refusal replaces it. + + **Not judged — the door stands down where the run can bind a name it cannot see.** + + - **A node:** a `script` or `connector_action` node (each hands its code the live variable map), a `wait`, `subflow` or `map` node (a resume folds the caller's `variables` bag in under their plain names), a screen with no field list, or a node type outside the builtin set. + - **An entrance the stack declares that hands the flow a record whose keys are not in hand:** + - a record trigger (`config.objectName`) or a time-relative sweep (`config.timeRelative.object`) on an object the stack does not declare; + - an `api` trigger, whose inbound hook hands the request body in as the record; + - a `type: 'flow'` action launching the flow on an undeclared object; + - a `map` node feeding the flow whose `itemObject` is undeclared or absent; + - a `subflow` or `map` parent that is itself such a flow. + - **The runtime publish gate:** a flow write there is not judged for unbound roots, by design (rulings `5791822697` item 3 and `6104584601`). Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged. + + A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `condition: 'user.id == "u1"'` | `condition: 'current_user.id == "u1"'` (`current_user != null && current_user.id == "u1"` where the flow can run without a user) | + | `condition: 'ctx.user.id == record.owner'` | `condition: 'current_user.id == record.owner'` | + | `assignments: { who: { dialect: 'cel', source: 'os.user.id' } }` | `assignments: { who: { dialect: 'cel', source: 'current_user.id' } }` | + | `condition: 'deals.size() > 0'` with nothing binding `deals` | declare it (`variables: [{ name: 'deals', type: 'list' }]`) or bind it with a node (`outputVariable: 'deals'`) | + + **The one-line fix: write `current_user` for the run user, and bind or correct every other root the refusal names.** + + **Who is affected, measured.** The last published spec line, 17.x, judged a flow expression's syntax only, so it accepts every refused source. Measured with the judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 45 sites), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `f0afcbda07` (32 flows, 65 sites) — 0 refusals; every root those flows read is an engine root, `record` on a flow an entrance hands a record, a binding of the flow's own or a field of its trigger object. One flow of the 67 is left unjudged by an entrance (`showcase_inbound_task_webhook`, an `api` trigger). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The judge.** `flow-cel-root-scope.ts` in `@objectstack/lint`, called by `validateStackExpressions` at each flow CEL site. One bound set per flow, read once like the shadowing pass's variable set, and one entrance index per stack (`flowCelEntrances`). + - **The ledger.** The D3 semantic entry `flow-cel-unbound-root-refused` (protocol 18). There is no D2 conversion: none of these roots ever evaluated in a flow, so there is no behaviour to carry over. +- 0f77ff5: A flow CEL expression that reads `record` on a flow no entrance hands a record is refused at `objectstack validate`. Since #22642 the run binds `record` only to the record it was handed, so such a read fails the run with `Unknown variable: record`, and the build door still counted `record` as bound on every run and passed it. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule). + + **What is refused.** `record` is no longer one of the roots the build door binds on every run (`previous`, `vars` and `current_user` still are). It is bound where: + + - an entrance the stack declares hands the flow a record: a record trigger (start `config.triggerType` is a `record-*` token), a time-relative sweep (start `config.timeRelative`), the inbound hook (trigger kind `api`), a `type: 'flow'` action that targets the flow (on any object, or on none), a `map` node with a `config.itemObject` that names the flow, or a `subflow` / `map` parent that is itself handed a record; + - or the flow binds a variable named `record` itself (a declared variable or an `assignment` target). + + A start `config.objectName` alone is no entrance: with no `record-*` trigger nothing hands the run a row. A parent's own `record` variable is not handed on either: a child gets its parent's context, not its variables. Everywhere else `record.X` is refused as `expression-invalid`, `error`, with the remedy below. + + **Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for `record`, by design (rulings `5791822697` item 3 and `6104584601`). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `record.assignee` on a flow no entrance hands a record, meaning a flow variable `assignee` | `assignee`, or `vars.assignee` | + | `record.status == "open"` on a flow meant to run when a row is written | the trigger that hands it the row: start `config: { objectName: 'acct', triggerType: 'record-after-update' }` | + + **The one-line fix: read a flow variable by its name or through `vars`, and read `record` only on a flow an entrance hands one.** + + **Who is affected, measured.** With this judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 14 that read `record`), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `1d7148b` (32 flows, 14 that read `record`): 0 newly refused. Every flow that reads `record` there is a record-change, time-relative or inbound-hook flow. Deployed metadata and other repositories were not measured. +- 32fa194: A retired page-component prop is refused at `objectstack validate`, `objectstack build` and `objectstack lint`. Before this change, a key that `ComponentPropsMap` tombstones with `retiredKey()` (`object-grid` `defaultFilters`, `element:record_picker` `displayField`, `page:header` `breadcrumb` and the rest) drew only a `component-props-invalid` warning. The command printed "✓ Validation passed" and built the artifact, while the conversion registry told authors the key is refused. It is now a `component-props-invalid` error carrying the tombstone's prescription, and the stack does not pass until the key is gone. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (the `component-props-invalid` rule of `objectstack validate`, `objectstack build` and `objectstack lint`). + + **What is refused.** A value verdict a tombstone raises: the tombstone's own issue, wherever the key sits in the props bag, an array entry's key included. A tombstoned key inside a union member is refused when that member is the only one judging keys (`object-form` `sections[].fields[]` `publicPicker`). A retired element's props row (`user:profile`, `ai:chat_window`) raises the same issue and is refused the same way. + + **What stays a warning.** Every other `component-props-invalid` finding: a wrong type, a missing required prop, a closed-set refusal (a url action's `target` scheme, a `record:details` section's `count`), a retired enum value and a missing `dataSource.object` binding. Every `component-props-unknown-key` finding also stays a warning. + + **Not judged here.** The runtime publish door (the Studio, REST `/meta`, MCP) does not run this rule, so a page saved there is not refused for a retired prop. `defineStack` and the stack schema keep the open `properties` bag as written. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `object-grid` `properties: { defaultFilters: [...] }` with an empty `filter` | `properties: { filter: [...] }` | + | `element:record_picker` `properties: { displayField: 'title' }` | `properties: { labelField: 'title' }` | + | a retired prop whose prescription says to delete it, e.g. `page:header` `breadcrumb` | nothing: delete the key | + + **The one-line fix: apply the prescription the error prints.** Where an ADR-0087 conversion covers the key, `objectstack migrate meta --from ` lists the mechanical edits for existing sources. + + **Who is affected, measured.** At objectstack `21c86444e`, this rule ran over `examples/app-crm`, `app-todo`, `app-multi-package` and `app-showcase` (through `objectstack validate`), over the five shipped pages outside them (`@objectstack/platform-objects` ×3, `@objectstack/plugin-approvals`, `packages/qa/downstream-contract`) and over the `create-objectstack` template, which authors no page. Result: 0 newly refused, and 0 findings of this rule of any kind. Deployed metadata and other repositories were not measured. + +### Minor Changes + +- f85a83b: fix(lint)!: the object save door refuses a formula field whose expression `os build` refuses (#22019) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. At the object save door it did not. A formula field calling an unregistered function, such as `sqrt(record.amount)`, was refused by `os build` as an unknown function, but `PUT /api/v1/meta/object/:name` answered 200, stored it, and the field read `null` on every row. + + The runtime publish gate now runs the build's own formula check on an object write. The registry entry for the build's expression rule (`validateStackExpressions`) declared the flow, action and hook writes and never the object write, so the gate never dispatched it there. It now declares `object` as well, for one of its passes: a formula field's `expression`. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' expression`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a formula field whose expression the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that field's `expression`. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and the other errors in the build's formula check. Its warnings now ride the save response as advisories, as they already did for a flow write. + + **Remedy.** Fix the expression: the message names the unknown function or field and the position, as `os build` already requires. Use one of the functions `introspectScope` lists, qualify field reads as `record.FIELD`, or compute the value in a stored field and reference it. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps reading, with the formula still `null`, until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - This entry's crossing covers formula fields alone. The other expressions an object carries (validation-rule predicates, the field-rule slots `requiredWhen`, `readonlyWhen` and `visibleWhen`, option `visibleWhen`, and the object's own action predicates) are judged at this door through their own #22032 entries, each its own crossing, measured over the stored corpus first. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every formula field this repository ships has 0 refusals and 0 advisories at the door. That is 29 fields on 28 objects: examples 7 on 6, and the platform `display_title` formulas 22 on 22. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature. The registry entry reaches the passes through an internal function that is not on the package's entry. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. + + +- 73a0a6b: fix(lint)!: the object save door refuses an object action's `visible` or `disabled` predicate that `os build` refuses (#22032) + + Clause-②: no (narrowing: the object save door refuses an action visible or disabled predicate os build already refuses) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For an object's own actions it did not, at the object save door. An action whose `visible` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The action runtime evaluates such a predicate fail-closed, so the action was hidden on every record. + + The runtime publish gate now runs the build's action check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates, the field-rule slots and option `visibleWhen`. On an object write it now also judges each `actions[].visible`, and each `actions[].disabled` that is not a boolean literal, as a predicate over `record`, the way the build does. Every expression an object carries is now judged at this door. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · action 'ACTION' visible`, or `disabled`), message and hint. One location differs, never the verdict: an action that is also declared in the stack's top-level `actions` is located by `os build` at `stack · action 'ACTION' visible`, and by the door, which judges the object alone, at the object. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for an action whose `visible` or `disabled` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that action. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and a syntax error. Its warnings now ride the save response as advisories. + - The platform's own `sys_approval_request` (shipped by `@objectstack/plugin-approvals`) carries 8 action `visible` predicates that read `record.viewer`, a block the approvals service attaches on read and the object does not declare. The shared validator refuses all 8 (``unknown field `viewer` on `sys_approval_request` ``). Until #22211 lands, a save of that packaged object answers as follows. The first two rows were measured on the showcase's `objectstack dev` boot; the third is a source reading. + + | kernel and setting | publish save, before → after | draft save | + |:--|:--|:--| + | host-config kernel, no `OS_METADATA_WRITABLE` | 403 `NOT_OVERRIDABLE` → 422 `INVALID_METADATA` (8 issues) | 403 `NOT_OVERRIDABLE` | + | `OS_METADATA_WRITABLE=object` | 200 → 422 `INVALID_METADATA` (8 issues) | 200 | + | environment-scoped kernel | 403 `NOT_OVERRIDABLE`, unchanged | 403 `NOT_OVERRIDABLE` | + + On a host-config kernel the authoring gate answers before the package door, so there the 422 is returned where the more basic 403 belongs; #22220 carries that order. The object itself is unchanged: it registers, is served, and its actions evaluate as before. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and write a boolean literal (`true` / `false`) for a constant. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. For `sys_approval_request` there is nothing to repair on the consumer side: its predicates are the platform's, and #22211 carries the fix. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - The stack's top-level `actions`, its flows, its sharing rules and its hooks are not judged on an object write: each is judged at its own type's door, as before. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every object this repository ships (118 objects from its `*.object.ts` files) carries 58 action predicates on 16 objects, and the example stacks as `defineStack` composes them (33 objects, standalone actions merged in) carry 59 on 5. The shared validator refuses the 8 on `sys_approval_request` above and nothing else, and the door refuses the same 8 after this change and none before it. There are 0 other refusals and 0 advisories, at both doors. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. + + +- f2a45db: fix(lint)!: the object save door refuses a field-rule slot whose predicate `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field's rule slots it did not, at the object save door. A field whose `requiredWhen` read a bare field, such as `amount > 1`, or whose `visibleWhen` called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. + + The runtime publish gate now runs the build's field-rule-slot check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields and validation-rule predicates. On an object write it now also judges each field's `requiredWhen`, `readonlyWhen` and `visibleWhen` the way the build does, with the build's three gates on them: the `parent` gate, the null-guard check over `requiredWhen`, and the refusal of a `requiredWhen` or `readonlyWhen` that reads through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' SLOT`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a field whose `requiredWhen`, `readonlyWhen` or `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that slot. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, a root a field-level rule never binds (such as `current_user`), a `parent` read on an object that does not declare exactly one `master_detail` relationship, an ordering or arithmetic operator in `requiredWhen` applied to a nullable field with no `!= null` guard, and a `requiredWhen` or `readonlyWhen` that reads through a reference field (`record.account.tier`, or `parent.REF.FIELD`). Its warnings now ride the save response as advisories. + - A detail object's `requiredWhen` or `readonlyWhen` that reads through one of its master's reference fields (`parent.REF.FIELD`) is judged whenever the master is in the write's context, and that includes a save of the master itself. So a master save can answer 422 with an issue located at a stored detail's field. Fix the detail's predicate, then save the master again. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the unbound root, the unguarded operand or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, guard a nullable operand in `requiredWhen` with `record.FIELD != null && …`, and move a check that must read through `record.REF` into a `validations[]` `script` rule, whose `condition` is read one hop through a reference; a read through `parent.REF` has no such surface, so read a column the master declares instead (denormalise the value onto it). Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - `conditionalRequired` is still refused at the save door's schema step, before this gate, as a key retired in protocol 17; `os build` judges it as a field-rule slot as before. + - Option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing, not through this one. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every field-rule slot this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 9 slots on 8 fields of 3 objects (examples: 8 on `showcase_invoice` and `showcase_invoice_line`, three of them `parent`-scoped; the platform: 1 on `sys_permission_set`), over the 118 objects this repository ships. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- aa9447c: fix(lint)!: the object save door refuses a field option's `visibleWhen` that `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field option's `visibleWhen` it did not, at the object save door. An option whose `visibleWhen` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The server's option check cannot evaluate such a predicate and lets the value through, so the gate it declares is never enforced. + + The runtime publish gate now runs the build's option check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates and the field-rule slots. On an object write it now also judges each `fields[].options[].visibleWhen` the way the build does: as a predicate over `record` and `previous`, and with the build's refusal of a read through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for an option whose `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and a read through a reference field (`record.account.tier`, `previous.account.tier`). Its warnings now ride the save response as advisories. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and compare a reference field as a value (`record.account != null`) rather than read through it. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - `current_user` is still accepted in an option's `visibleWhen`, as the build accepts it: the option evaluator binds the acting user (ADR-0068 D1). A role gate such as `'org_admin' in current_user.positions`, or a grant check such as `current_user.can('OBJECT', 'edit')`, still saves. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen` it is still refused, as before. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - The object's own action predicates (`actions[].visible`, `actions[].disabled`) are judged at this door through their own #22032 entry, its own crossing, not through this one. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: this repository ships 5 option predicates, all on `showcase_cascade` (four `record.country` cascades and one `current_user.positions` role gate), among the 118 objects it ships. They have 0 refusals and 0 advisories, at the build and at the door. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 3d91885: fix(lint)!: the object save door refuses a validation rule whose predicate `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a validation rule's predicates it did not, at the object save door. A rule whose `condition` called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amount > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The rule then faulted on every write it judged. + + The runtime publish gate now runs the build's validation-rule check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields alone. On an object write it now also runs its validation-rule pass: each `validations[]` rule's `condition` and a `conditional` rule's `when`, plus the null-guard check over every predicate the rule carries, its nested `then` and `otherwise` rules included. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · validation 'RULE'`, or `… validation rule 'RULE' then → 'CHILD'` for a nested predicate), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a validation rule whose predicate the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, an ordering or arithmetic operator applied to a nullable field with no `!= null` guard (`has()` is no guard here), and the other errors in the build's validation-rule check. Its warnings now ride the save response as advisories. + + **Remedy.** Fix the predicate: the message names the unknown function or field, or the unguarded operand, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and guard a nullable operand with `record.FIELD != null && …`. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - This entry's crossing covers validation-rule predicates. The field-rule slots (`requiredWhen`, `readonlyWhen`, `visibleWhen`), option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every validation rule this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 21 rules carrying 13 predicates on 10 objects: examples 11 predicates on 7 objects, and the platform objects 2 on 3 (one rule on `sys_user` carries no predicate). + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 8fc50b7: fix(lint)!: a `conditional` validation rule's nested `then` / `otherwise` predicate meets the same expression verdict as the rule's own (#22042) + + Clause-②: no (narrowing) + + A `conditional` validation rule applies its `then` rule when its `when` holds and its `otherwise` rule when it does not, and the rule validator evaluates either branch as a rule of its own. `os build` judged a rule's own `condition` and `when` with the shared `validateExpression` validator, but reached the predicates inside `then` / `otherwise` with the null-guard check alone. So a nested `condition` that called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amont > 1`, passed `os build`. The object save door gives the build's verdict, so `PUT /api/v1/meta/object/:name` stored it, and the rule then refused every write it judged, because a validation rule that cannot be evaluated fails closed. The same predicate one level up was refused at both doors. + + The build's expression rule (`validateStackExpressions`) now runs the same check on every predicate nested in a `conditional` rule, at every depth: each nested `condition`, and the `when` of a `conditional` nested inside a branch. A nested `condition` also gets the relationship-traversal checks, because ObjectQL hydrates a one-hop read there, as it does at the top level. A nested `when` does not, because the evaluator never hydrates a `when`, as at the top level. A nested finding is located at the nested rule, the location the null-guard check already gave it: `object 'OBJECT' · validation rule 'OUTER' then → 'INNER'`, with `when-predicate` appended for a nested `when`. A rule's own `condition` and `when` keep their findings and their location (`object 'OBJECT' · validation 'NAME'`) and are judged once. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` now refuse, at `error`, a stack whose `conditional` validation rule carries a nested predicate the shared validator refuses. The validator's warnings on a nested predicate are now reported too, and at the save door they ride the response as advisories. + - An object write in publish mode that carries such a rule answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the nested rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode) and the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`). + - The verdict is the one a rule's own `condition` already got: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and, for a nested `condition`, a reference field read both through the relationship and as a value, or a read deeper than one hop. + + **Remedy.** Fix the nested predicate the way the same predicate is fixed at the top level: the message names the unknown function or field and the position. Qualify field reads as `record.FIELD`, and use one of the functions `introspectScope` lists. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - A rule's own `condition` and `when` are judged exactly as before, at the same location; the null-guard check over every predicate is unchanged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: this repository ships one `conditional` validation rule with nested predicates (`showcase_account.churn_reason_consistency`, two nested `condition`s), among 21 validation rules on the 118 objects it ships. Both have 0 refusals and 0 advisories, at the build and at the door. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- bb4f5cc: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, or any other root the server's option check does not bind (#22157) + + Clause-②: no (narrowing: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, which the runtime's option check cannot bind) + + A select option's `visibleWhen` is a gate the server enforces on write: the rule validator evaluates the predicate for the value a caller picks, and refuses the value when the predicate is false. That option check binds `record`, `previous` and the acting user (`current_user`, and its ADR-0068 aliases `user`, `ctx` and `os`), and nothing else. An option predicate that read `parent`, such as `parent.status == 'closed'`, still passed `os build` and the object save door. `parent` is a root the platform declares, and a field's own `readonlyWhen` and `requiredWhen` bind it on an object with exactly one `master_detail`. The option check does not bind it. So the predicate faulted on every write that picked the option, the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now gives a select option's `visibleWhen` a root verdict over the roots the option check binds. A predicate that reads any other root the platform declares, `parent` above all, is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the option, the field and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a root other than `record`, `previous`, `current_user`, `user`, `ctx` or `os`. Besides `parent`, that covers the roots the platform declares for other evaluation sites, such as `input`, `vars`, `trigger`, `data` and `features`. Each of them faulted at the option check in the same way. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** Rewrite the predicate against what the option check binds: `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. To gate a choice on a master-detail header's state, read a column the detail object declares, and denormalise the header value onto the detail; `parent` is bound only for a field's own `readonlyWhen` and `requiredWhen`. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind `parent` for an option, this refusal is lifted in that same change. + - The acting user is still accepted in an option's `visibleWhen` under all four ADR-0068 spellings, and so is a grant check such as `current_user.can('OBJECT', 'edit')`. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen`, the acting user is still refused and `parent` is still accepted, as before. + - A root the platform does not declare at all, such as `app`, was already refused in an option's `visibleWhen` by the bare-reference check, and it still is, with the same message. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every object this repository ships carries 5 option predicates, all on `showcase_cascade`: four `record.country` cascades and one `current_user.positions` role gate. That is over the 119 objects from its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them (33 objects). They have 0 refusals and 0 advisories, at the build and at the door, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 326a90a: feat(spec)!: a `page` gains an optional `print` declaration and a linted printable block subset; the list-export `'pdf'` prescription points at the print page; the zero-reader document schemas retire whole (#22158) + + Clause-②: yes (narrowing) + + + + **BREAKING** — the document family's exports leave `@objectstack/spec/data` (an export removal on a published entry), graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes, so the level says what the change is. The v18 opening marker already takes the fixed group to `18.0.0-next.N`, so this grade moves no version on its own. `@objectstack/lint` is `minor`: its new rule refuses blocks only inside a page that declares `print`, which no page could carry before this release, and it adds exports. The `print` declaration itself is additive. + + Card ① of the ruling on PDF and print documents (letter B′): **a document is a page with a print declaration; there is no separate template type.** + + ### The print page (additive) + + `PageSchema` gains an optional, closed `print` block. Its presence makes the page a print page — a document (an invoice, a delivery order, a letter, a report) authored in the page's own blocks: + + ```ts + definePage({ + name: 'invoice_print', + label: 'Invoice', + type: 'record', + object: 'invoice', + regions: [ + { name: 'header', components: [{ type: 'element:image', properties: { src: '/logo.png', alt: 'ACME' } }] }, + { name: 'main', components: [ + { type: 'record:details', properties: { fields: ['customer', 'invoice_date'] } }, + { type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'description' }, { name: 'amount' }] } }, + ] }, + { name: 'footer', components: [{ type: 'element:text', properties: { content: 'Payment due within 30 days.' } }] }, + ], + print: { paperSize: 'A4', orientation: 'portrait', margins: { top: 15, bottom: 15 }, repeatHeader: true, repeatFooter: true, pageNumbers: true }, + }); + ``` + + - **Keys** (`PagePrintSchema`): `paperSize` (`A4` | `A5` | `Letter` | `Legal`), `orientation` (`portrait` | `landscape`), `margins` (`{ top, right, bottom, left }` in millimetres), `repeatHeader` / `repeatFooter` (repeat the page's own `header` / `footer` region on every sheet), `pageNumbers`, and the page-break hints `repeatTableHeaders` and `avoidBreakInside`. Each maps to print CSS (`@page`, `thead { display: table-header-group }`, `break-inside`). + - **Not yet rendered.** Nothing applies these keys until the console's print rendering lands; the liveness ledger carries `print` as `planned` with an author warning, so `os validate` tells an author who writes it that the layout is validated but not yet applied. + - **Refused at the parse** (`checkPagePrintComposition`, exported for `.shape` mirrors): `print` on a `slotted` page, on an `html` / `jsx` / `react` page, on a `list` or `utility` page (a print page is a `record`, `home` or `app` page), on a `full` page with no `regions`, and `repeatHeader` / `repeatFooter` on a page with no region of that name. + - **The printable block subset** (`PRINTABLE_PAGE_COMPONENT_TYPES`, with the reason for every other vocabulary type in `PRINT_REFUSED_PAGE_COMPONENT_TYPES`): `page:section`, `page:card`, `page:footer`, `record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`. Inside a print page any other block — one that pages or windows its rows (`object-grid`, `record:related_list`), one that lays itself out to the screen (`page:sidebar`, `object-kanban`, `object-calendar`), or one with nothing printable (controls, inputs, shell chrome, `page:tabs`) — is refused by `@objectstack/lint`'s new gating rule `print-page-block-unprintable` (`validatePrintPageBlocks`), on `os validate`, `os build`, `os lint` and the page save door. + + ### The list-export prescription + + `'pdf'` stays refused in `view.exportOptions` formats. The refusal no longer says "PDF export itself was declined as NOT PLANNED" — no longer true — and instead names the view's `allowPrinting` for printing a list and a page that declares `print` for a document. + + ### FROM → TO (the retirement) + + `DocumentTemplateSchema`, `DocumentSchema`, `ESignatureConfigSchema` and the orphaned `DocumentVersionSchema` (`data/document.zod.ts`) are removed from `@objectstack/spec/data`, with their type aliases (`DocumentTemplate`, `DocumentTemplateParsed`, `Document`, `DocumentParsed`, `ESignatureConfig`, `ESignatureConfigParsed`, `DocumentVersion`, `DocumentVersionParsed`). No code in this repository, objectui or hotcrm read them. + + | before | what to write instead | + | --- | --- | + | `DocumentTemplateSchema` (a docx template with placeholders) | a `page` that declares `print`, its body drawn from the printable block subset | + | `DocumentSchema` / `DocumentVersionSchema` | nothing — no document store ever kept them; a document record is ordinary object data, its files `sys_file` attachments | + | `ESignatureConfigSchema` | nothing — no e-signature integration exists | + + **The one-line fix: delete the import; author a printable document as a page with a `print` block.** Every such import is TS2305 after upgrade. `data/DocumentSchemaValidation` (the NoSQL driver's block) is unaffected. `os migrate meta --from 17` lists the delegated step (D3 `document-schemas-retired`); there is no mechanical edit, since no stack collection ever carried these shapes. + + ### The retirement kit + + - `RETIRED_DEFS_BY_MAJOR[18]`: `data/DocumentTemplate`, `data/Document`, `data/ESignatureConfig`, `data/DocumentVersion`; D3 semantic entry `document-schemas-retired`, with a step-18 rationale fragment. The `ESignatureConfig` deadline-key entries in `RETIRED_KEYS_BY_MAJOR[18]` stay as history. + - No tombstone and no D2 conversion: none of the schemas is a stack collection member or a metadata type, so a conversion would have no seam that runs. + - Generated: `json-schema.manifest/data.json` −4 defs, `authorable-surface/data.json` −30 rows, `authorable-defaults/data.json` −2, the api-surface / declaration-map / export-origins shards, the `data/document` reference page removed. + - Pin: `src/data/document-schemas-retirement.test.ts`, a tree-scoped absence walk over the declared radius. +- d1c226d: fix(lint): the approval-approver, data-model, AI agent-authoring, view-reference and chart-binding findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: yes (widening: three rule id constants exported from the barrel) + + - **Shorter verdicts.** Each finding of these 15 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 197 characters, the `packages/cli` unit suite's at most 188, and `os validate` on the example apps' at most 191; before, the longest of each ran from 201 to 480 characters. The ids: + - approval nodes (`flows[]`): `approval-approvers-may-resolve-empty` (both the group arm and the `manager` arm), `approval-approver-not-membership-tier`; + - the ADR-0120 uniqueness rules (`objects[].indexes` and field-level `unique`): `unique/unscoped-declared-index`, `unique/double-declaration`, `unique/legacy-organization-composite`; + - `os lint`'s data-model sweep: `relationship/master-detail-required`, `relationship/delete-behavior`, `rollup/non-numeric-aggregand`; + - AI agents (`agents[]`, `apps[].defaultAgent`): `agent-authoring-withdrawn`, `default-agent-outside-roster`, `default-agent-legacy-alias`; + - view references: `view-ref-nav-view-missing` (app navigation), `view-key-collision` (view containers); + - chart bindings (reports, list-view charts, dataset-bound page charts): `chart-measure-unknown`, `chart-axis-not-selected`. + + The `manager` arm of `approval-approvers-may-resolve-empty` stays conditional — it says what happens where `sys_user.manager_id` is unset and asserts nothing about the column, which the rule cannot read. `view-ref-nav-view-missing` still lists the object's list views, at most three and then `(and N more)`, and `chart-axis-not-selected` quotes at most three names of the chart's selection. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **Three rule id constants are published** for ids `os lint`'s data-model sweep already emitted without one: `RELATIONSHIP_MASTER_DETAIL_REQUIRED` (`relationship/master-detail-required`), `RELATIONSHIP_DELETE_BEHAVIOR` (`relationship/delete-behavior`) and `ROLLUP_NON_NUMERIC_AGGREGAND` (`rollup/non-numeric-aggregand`), exported from `@objectstack/lint` beside the `UNIQUE_*` constants. The id strings themselves are unchanged. + - **`os explain ` takes these 15 ids**, for example `os explain unique/double-declaration`. It prints the reasoning the verdicts no longer carry: why an all-group or all-`manager` approval slate can stall, what silences the `manager` arm and what does not, and what an `org_membership_level` approver resolves against; the ADR-0120 scope words, the two channels that refuse a bare `unique: true` on a declared index, how a field `unique` and an index `unique` contradict or repeat, and why a hand-written organization composite enforces nothing on NULL rows; why a `master_detail` reference must be required under `controlled_by_parent`, what deleting a master does to its details, and why a `min` / `max` roll-up needs a numeric child; the platform agent roster and its retired aliases, how the runtime enforces the agent withdrawal, and what `defaultAgent` resolves against; how a navigation `viewName` is matched and what a renamed view key does to its references; and which chart positions bind a measure on each surface and which selection each is measured against. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 15 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 15 entries. + - **Where the new text prints.** On the CLI: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face for the approval, agent, view-reference and chart ids and for the three `unique/*` ids, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. `relationship/master-detail-required`, `relationship/delete-behavior` and `rollup/non-numeric-aggregand` print on `os lint` only (and in the metadata-generation rubric's lint half), as before. At the runtime publish gate (Studio, REST `/meta`, MCP): on a `flow` write, the two approval ids ride the 2xx `advisories` and the `[Protocol] authoring advisory` log line; on a `report` write, `chart-measure-unknown` at a report's `values` or chart `yAxis` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`, and `chart-measure-unknown` at a report chart's `series[]` and `chart-axis-not-selected` ride the 2xx `advisories` and the advisory log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the three agent ids (the rule runs there only on a `flow` write, whose snapshot carries no agents or apps), the two view-reference ids and the three `unique/*` ids (CLI-only rules), the three data-model sweep ids (`os lint` only), and the chart ids at a list-view or page chart (a report write carries neither surface). +- 54d32e5: feat(lint, cli): one-line author-time rule verdicts, and `os explain ` for the reasoning + + Clause-②: yes (widening) + + - **Shorter warnings.** `field-no-consumers` and `security-owd-unset` now print one verdict sentence and one fix. `os validate`, `os build` and `os dev` used to print `field-no-consumers` as a single line of about 860 characters, and `os build` added a second paragraph of about 700; the warning now reads: + + ```text + ⚠ object "my_app_ticket" · field "description": declared, but nothing in this stack displays or reads it (inert) + fix: add it to a view column or a form section, or remove the declaration + rule: field-no-consumers at objects[1].fields.description — `os explain field-no-consumers` for what counts as a consumer + ``` + + The finding's `message` no longer restates its `where`, and no longer carries the list of consumer and carrier kinds, the exemptions or the scanned roots; `hint` is the fix alone (for a `carrier-only` verdict it still names each carrier site a removal must clean). The `verdict`, `carriers` and `rootsScanned` fields of a `field-no-consumers` finding are unchanged. A tool that matched the old message text should match on `rule`, `where` and `path` instead. + + `security-owd-unset` also changes on the runtime wire. It runs at the metadata write door's publish gate for `object` writes (Studio, REST `/meta`, MCP), and an active write of a custom object (neither `isSystem: true` nor `sys_`-named) with no `sharingModel` is refused with a 422 whose `security-owd-unset` issue now carries the message `custom object declares no sharingModel (OWD); the runtime falls back to 'private', but the baseline must be an authored decision` and the hint `declare sharingModel: 'private' (owner + shares; recommended), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children)`. The old message named the object, which the issue's `where` and `path` still carry, and told the leave_request incident, which `os explain security-owd-unset` now prints. + - **`os explain `.** `os explain` takes an author-time rule id as well as a schema name — `os explain field-no-consumers`, `os explain security-owd-unset` — and prints the reasoning the warning no longer carries; `--json` prints `{ rule, covers, paragraphs }`. A schema name resolves exactly as before. With no argument it also lists the rule ids that have an explanation (`--json` adds `rules: [{ id, covers }]`). The `rule:` line names the command only for those rule ids. An argument that is neither still exits 1, and its error string changes from `Unknown schema: "X"` to `Unknown schema or rule id: "X"`, followed by a second list, `Rules with an explanation: …`; the `--json` `error` field changes the same way, from `Unknown schema: X` to `Unknown schema or rule id: X`. + - **`os explain constructor` (or `__proto__`) is refused as an unknown id** instead of printing `Schema: Object … undefined` and crashing with `schema.required is not iterable`: both lookups read own keys only. + - **`os validate` prints the `fix:` and `rule:` lines** under each author-time warning, the way `os build` does, and the hint line under every author-time finding (`os build`, `os validate`, `os verify`, `os init`) is now labelled `fix:`. `os lint` adds the same `os explain` pointer to its rule line. + - **The `fix:` line is always a fix.** `expression-invalid` no longer puts the authored source in `hint`, where it printed as `fix: source: …`: the source now ends the finding's `message` as `` — source: `…` `` (the spelling the flow engine's runtime refusals use), so the CLI verdict line still carries it, and so does the issue `message` at the runtime publish gate for `flow`, `action`, `hook` and `object` writes (Studio, REST `/meta`, MCP): in the 422 for an `error`, in the 2xx `advisories` for a `warning`. Its `hint` is empty, so no `fix:` line prints and the runtime issue's `hint` is `''`. `component-props-invalid`, `flow-time-relative-descriptor-invalid`, `react-prop-missing-required` (where the component contract describes the binding) and `liveness-experimental-property` carried context in `hint`; each now leads with the instruction, and `component-props-invalid`'s message states its consequence (nothing refuses it today, so the renderer receives the props as written). Two of the four run at the runtime publish gate. `flow-time-relative-descriptor-invalid` is an `error` on `flow` writes, so its new hint reaches the 422 issue `hint`. `liveness-experimental-property` runs on `email_template`, `mapping` and `datasource` writes as a `warning`, so its hint would ride the 2xx `advisories`, but none of those three ledgers has an `experimental` row today, so it reaches no runtime response yet. `component-props-invalid` is CLI-only, and `react-prop-missing-required` judges no `page` write at the gate, so their new text reaches the CLI only. + - **New exports in `@objectstack/lint`:** `RULE_EXPLANATIONS`, `explainRule(ruleId)` and the type `RuleExplanation`, from the root entry and from the import-free `@objectstack/lint/rule-explanations` entry. +- d1dbe70: fix(lint)!: four authoring rules now fire on the sibling spelling of a defect they already caught + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing of the `os lint`, `os validate` and `os build` verdicts, shipped as `minor` under the launch-window convention for accept-set narrowings. No rule id is added. Each rule below now reports a spelling of a defect it already reported, at the severity it already had. + + - **`hook-api-update-readonly-field`** (error) and **`hook-body-write-unknown-field`** (warning) read a hook body's writes from one extractor. That extractor accepted only the literal `ctx.api.object(...)` receiver. A body that binds the handle first, such as `const api = ctx.api as HookApi | undefined; await api.object('crm_case').update({...})`, was invisible to both rules. The byte-identical `ctx.api.object(...)` statement fired both. The receiver can now also be a local bound to `ctx.api`, with or without type-only and non-null wrappers. That covers `const api = ctx.api`, `const { api } = ctx`, `const { api: db } = ctx` and an optional call `api?.object(...)`. The local must be declared once and never reassigned, and the write must sit in the block that declares it. A reassigned, shadowed, defaulted or out-of-scope local is still not judged, and neither is a local bound to `ctx.api.sudo()`. The action-body write rules share the extractor, so they read the same receivers. + - **`visibility-bare-identifier`** (error) used to accept any name written as a namespace. `has(record.x) && foo.x == 'crm_lead'` published clean, and the console's engine then answered `Unknown variable: foo` and rendered the element unconditionally. A namespace root that no layer binds is now reported, with its own message and fix. Every root a renderer binds still passes: `record`, `previous`, `parent`, `current_user`, `user`, `ctx`, `os`, `features`, `page` and `data`, plus everything in `@objectstack/formula`'s `SCOPE_ROOTS`. On a metadata-editing form, a dotted chain on the right of `==` / `!=` is still left to `predicate-rhs-path-shaped`. + - **`security-master-detail-ungranted`** (warning) used to treat an object as a detail only when it had a `master_detail` field. The runtime also resolves a `controlled_by_parent` object's master through a required lookup. That child, granted in no permission set, gets the same never-derived 403 on object-level CRUD. It is now reported too. A child with two `master_detail` fields is reported against the master the runtime picks. + - **`component-props-invalid`** / **`component-props-unknown-key`** (warning) skipped a component whose `properties` bag was absent. A top-level component never arrives without one, because `PageComponentSchema` defaults it to `{}`. A nested component (in `children`, `items[].children` or `footer`) is never parsed by that schema, so every required prop it omitted went unreported. An absent bag is now judged as `{}`. + + **What an author sees.** A stack that passed before can now draw one of these findings. Each one names a real defect: a write the runtime drops or refuses, a predicate that can never evaluate, a role that is denied the object, or a component missing a prop its contract requires. The fix is in the finding's hint. The readonly-write and visibility findings are errors, so they fail `os build`. The others are warnings, which fail only `os lint --strict`. +- 215e662: A `date` or `datetime` field can declare that it is a deadline, and when that deadline is settled: `dueLike` and `settledWhen` + + Clause-②: yes + + - **`dueLike: boolean`** (optional, no default) declares the date a deadline. A renderer may then show relative overdue wording and an overdue colour once the date has passed. Absent means not a deadline: nothing is inferred from the field's name. + - **`settledWhen`** (optional) is a per-record CEL predicate in the `visibleWhen` / `readonlyWhen` / `requiredWhen` family. While it holds, the deadline is settled and no overdue wording or colour applies to that record, for example `record.status == 'done'`. + - **The doors that judge them.** `FieldSchema` refuses either key on any type other than `date` / `datetime`, and refuses `settledWhen` unless the field also declares `dueLike: true`. The CEL is judged by `@objectstack/lint`'s field-rule pass, the one `visibleWhen` goes through, at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no root but `record` (plus `previous`, and `parent` on a master-detail line item). + - **Display only.** Nothing on the write path reads either key. Every metadata that parsed before parses unchanged; a field without the keys behaves exactly as before. + - **Who reads them.** objectui's date and datetime cells read `dueLike` on the record detail surfaces today. Reading `settledWhen`, and retiring the field-name guess, follows in objectui once this spec version is published. + - **Where to author them.** The field designer offers both on date and datetime fields, `settledWhen` only once the field is a deadline. The example apps declare them on the showcase and todo `task` and the CRM `activity`. +- 3d886ee: A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows + + Clause-②: yes (widening) + + - **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. + - **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. + - **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). + - **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). + - **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). + - **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. +- b1f7a7a: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of `ctx` or `os` the server's option check never binds, such as `os.org.id`, `os.env` or `ctx.locale` (#22274) + + Clause-②: no (narrowing: a select option's `visibleWhen` that reads an unbound member of a bound root is refused at build and at the object save door) + + A select option's `visibleWhen` is a gate the server enforces on write. The option check binds `record`, `previous` and the acting user, as `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`. Under `ctx` and `os` it binds the `user` member and nothing else: it passes no organization and no environment. The build already refused a root the option check does not bind, such as `parent`, but it judged `ctx` and `os` as whole roots. So an option predicate that read `os.org.id != ''`, `os.env == 'prod'` or `ctx.locale == 'en'` passed `os build` and the object save door with no finding. On every write that picked the option the predicate then faulted (`No such key: org`, `env` or `locale`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now judges the members of `ctx` and `os` in an option's `visibleWhen`, in the same verdict that judges its roots. A member other than `user` is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, says that the option check binds only the `user` member under that root, and gives the remedy. Every spelling of the read is judged the same: `os.org`, `os.?org`, `os['org']` and `has(os.org)`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `ctx` or `os` other than `user`, such as `os.org.id`, `os.org.tier`, `os.env` or `ctx.locale`. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** + + - For the caller's organization, compare `current_user.organizationId`, which the option check does bind: it holds the acting user's organization id, or `null` when the caller acts outside an organization. `os.org.id != ''` becomes `current_user.organizationId != null`. No other organization fact, such as its tier, is available to an option predicate; read a column the object declares instead. + - `os.env`, `ctx.locale` and any other member: rewrite the predicate against `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. + - Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `os.org` for an option, this refusal is lifted for that member in the same change. + - The acting user is still accepted under all four ADR-0068 spellings (`current_user`, `user`, `ctx.user`, `os.user`), and so are its fields (`current_user.positions`, `ctx.user.id`) and a grant check such as `current_user.can('OBJECT', 'edit')`. + - The same members are still accepted where they are bound, such as `os.org.id` in a `formula` field's `expression`. The refusal is the option slot's alone. + - A computed key such as `os[name]` names no member, so it is not judged. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one). None reads `ctx` or `os`. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them. They have 0 refusals at the build and at the door, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- 46692c1: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of the acting user the server's option check never binds, such as `current_user.roles` (renamed `positions`), `ctx.user.roles` or `current_user.email` (#22394) + + Clause-②: no (narrowing: a select option's `visibleWhen` that reads an `EvalUser` member the schema does not declare is refused at build and at the object save door) + + A select option's `visibleWhen` is a gate the server enforces on write. The option check binds the acting user under four spellings, `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`, all one `EvalUser` object. That object carries the caller's `id`, `positions`, `isPlatformAdmin` and `organizationId`, and nothing else. The build already refused a root the option check does not bind (such as `parent`) and a member of `ctx` or `os` other than `user`, but it stopped there. So an option predicate that read `'admin' in current_user.roles`, `ctx.user.roles == ['a']` or `current_user.email == 'a@b.c'` passed `os build` and the object save door with no finding. `roles` has not been a member of the acting user since ADR-0090 D3 renamed it `positions`; `name` and `email` are declared by `EvalUserSchema` but the server never sets them for this check. On every write that picked the option the predicate then faulted (`No such key: roles`, `email`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now judges the members of the acting user in an option's `visibleWhen`, under all four spellings, in the same verdict that judges its roots and the members of `ctx` and `os`. A member is accepted only when `EvalUserSchema` (`@objectstack/spec`) declares it AND the option check binds it; the allowlist is read off the schema and off `@objectstack/formula`'s `buildScope`, never written out. Any other member is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, names the members the acting user does carry there, and gives the remedy. Every spelling of the read is judged the same: `current_user.roles`, `current_user.?roles`, `current_user['roles']`, `has(current_user.roles)`, and the same below `ctx.user` and `os.user`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `current_user`, `user`, `ctx.user` or `os.user` other than `id`, `positions`, `isPlatformAdmin` and `organizationId`, such as `current_user.roles`, `current_user.role`, `ctx.user.roles`, `current_user.email` or `user.name`. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** + + - `roles` / `role` → `positions`: `'admin' in current_user.roles` becomes `'admin' in current_user.positions`, in whichever spelling the predicate used (`ctx.user.roles` becomes `ctx.user.positions`). + - `name`, `email` and any other member: rewrite the predicate against a member the option check binds (`current_user.id`, `current_user.positions`, `current_user.isPlatformAdmin`, `current_user.organizationId`), or against a column the object declares (`record.FIELD`, `previous.FIELD`). + - Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `email` for an option, this refusal lifts for that member in the same change, because the allowlist is derived from what the option check binds. + - The same members are still accepted where they are bound, such as `current_user.email` in a row-level security policy or an object action's `visible` predicate. The refusal is the option slot's alone. + - A grant check such as `current_user.can('OBJECT', 'edit')` is a call, not a member read, and is still accepted. + - A computed key such as `current_user[name]` names no member, so it is not judged. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one, `current_user.positions`). None reads a member the option check does not bind. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks. They have 0 refusals at the build, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- f89b5f5: feat(i18n): a screen field's option labels and a flow's terminal toasts have translation keys (`flows..screens..fields..options.`, `flows..successMessage` / `.errorMessage`) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. Two options of one screen field whose values read the same as text — `1` and `"1"`, `true` and `"true"`, or a repeated value — are now refused when the flow is parsed (`FlowSchema`, `objectstack validate`, the metadata save door, `registerFlow`), at the second option's `value`. Neither the option's label translation (keyed by the value as text) nor the console's select can tell such a pair apart. To fix a refused flow, give each option of the field a value that stays distinct as text, or drop the duplicate. Measured reach in this repository: 0 — no flow in its examples carries such a pair. + + - **What is new.** A fully translated screen flow no longer has to keep its select options and its completion or failure toast in the source language. Translate an option's label under `flows..screens..fields..options.`, and the flow's own toasts under `flows..successMessage` and `flows..errorMessage`. + - **How an option is addressed.** By its value read as text: `String(value)`, so the option `{ value: 1, label: 'Tier 1' }` is translated under `options.1` and `{ value: true, … }` under `options.true`. `flowScreenFieldOptionKey(value)` (`@objectstack/spec/automation`) spells the key. Not by position (reordering options would re-point every translation), and not by an object field's options (a screen field declares no such binding). + - **One new refusal**, the narrowing the BREAKING note above describes: option values of one screen field must stay distinct as text, because the label translation is keyed by them. + - **The toasts translate only where the flow authors one.** A bundle cannot add a toast to a flow that declares none; `objectstack validate` refuses such a key (`translation-target-unknown`, error). + - **Where it is read.** `translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn. + - **`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`). + - **`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them. + - **A screen's `description`** is keyed too, in the same release, and translated by the engine rather than the console: see the entry for `flows..screens..description`. +- d25b187: feat(lint): `objectstack validate` judges a translated screen `description` + + Clause-②: yes (widening) + + - A translation of `flows..screens..description` over a screen that declares no `config.description` is reported as `translation-target-unknown` (error). The engine translates body text only where the screen authors one, so nothing would read the key. Declare the screen's `description`, or drop the key. +- 6981246: A flow whose `record-*` trigger names no start `config.objectName` is no longer judged for unbound flow CEL roots by `objectstack validate`. With no object named, the record-change trigger registers its hook with no object filter, so it fires on a write to any object, one the stack does not declare included, and hands that row to the run as `record`, its fields flattened beside it. The build door cannot know that record's keys, so it leaves the flow unjudged, as it already does for a record trigger on an object the stack does not declare. The trigger shape itself is not refused. + + Clause-②: yes (widening: a flow whose `record-*` trigger names no start `config.objectName` opens, so `validate` no longer reports an unbound flow CEL root there) + + **What changes.** On such a flow, `expression-invalid` no longer reports a root the flow does not bind (`user.id`, `foo.bar`, a field name no declared object has). The same flow with a declared `config.objectName` is judged exactly as before. + + **The runtime publish gate, by design.** That gate does not judge unbound flow CEL roots on a flow write, and is not going to: who launches a flow and what record it is handed are read from other items of the stack, which makes this a whole-stack judgment that belongs to `objectstack validate`, and the gate's per-write snapshot is not widened to carry flows and actions. The stand-down there is the intended boundary, not a gap waiting on a wider snapshot. + + **Who is affected.** Every shipped `record-*` trigger names its object, so no example app changes: `objectstack validate` over `app-crm`, `app-todo`, `app-multi-package` and `app-showcase` reports the same findings before and after. +- 5081090: feat(lint): `validateCapabilityReferences` resolves the capabilities a list view or a dashboard requires (#22639) + + Clause-②: no + + - The `/meta` read gate now serves a list view or a dashboard only to a user who holds every capability its `requiredPermissions` names, so a misspelled name hides the item from everyone. `validateCapabilityReferences` resolves those names like every other carrier's: a dashboard's, a view container's `list` and each `listViews` entry, a view item's `config`, a flattened list view's, and an object's own `listViews` entries. An unresolved name warns `capability-reference-unknown` at its own path (for example `views[0].listViews.legal_review.requiredPermissions`). + - It is a warning, as for objects, fields, apps and actions: a single package's lint cannot see a capability another installed package declares. +- a22e67b: A `record:details` section heading can show how many values a multi-value field holds on the current record: `sections[].count: { field }`, with the section's `label` writing the `{{ count }}` hole where the number goes. + + Clause-②: yes (widening) + + ```ts + { + name: 'plan_gap', + label: '{{ count }} features need a higher plan', + fields: ['capabilities_not_on_plan'], + count: { field: 'capabilities_not_on_plan' }, + } + ``` + + - **`@objectstack/spec`.** `RecordDetailsProps.sections[]` accepts `count`, a closed object whose one key, `field`, is a bare machine name (snake_case, no dotted path). A section that declares `count` must write the `{{ count }}` hole in its `label`, in every locale of an inline label map. It is refused when it has no `label`, when the label has no hole, when the label writes any other `{{ … }}` hole (another variable, or the count with a formatter), when it writes the single-brace `{count}` / `${count}`, and beside `group` (a group-referencing section's heading is the group's own label). A translated `objects.._sections..label` writes the same `{{ count }}` hole, and `os i18n extract` seeds it from the authored label. `SECTION_HEADING_COUNT_HOLE` exports the hole's grammar (inner whitespace is free: `{{count}}` is the same hole). + - **`@objectstack/lint`.** The page field-binding pass resolves `count.field` against the bound object, at error severity, on `os validate`, `os lint` and `os build`: a field the object does not declare is `page-field-unknown`, and a declared field that holds one value is the new `page-section-count-not-multi-value`. A multi-value field is `multiselect`, `checkboxes` or `tags`, or a `select`, `lookup`, `user`, `file` or `image` field with `multiple: true`. A section's `fields[]` miss stays a warning. + - **Not rendered yet.** The key is declared ahead of its renderer and its description carries the `[EXPERIMENTAL — not enforced]` marker: until the record detail renderer reads it, the hole renders as literal text. + - **Nothing to migrate.** No stack that parsed before is refused: every new refusal needs the new key. A `{{ count }}` hole in a section that declares no `count` is still accepted. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- ace0a53: feat(lint)!: `relationship/master-detail-required` refuses the three unsafe master-reference shapes at `error` on a `controlled_by_parent` object (#9139) + + Clause-②: no (narrowing) + + A `controlled_by_parent` detail derives all of its record access from the master its `master_detail` reference names (ADR-0055). Three declarable shapes of that reference leave the security gate as the only thing refusing a detail record saved without its master, because record validation never checks a field that is not `required` and skips `readonly` and `system` fields before its required check: + + 1. `required` absent, or `required: false`; + 2. `required: true` with `readonly: true`; + 3. `required: true` with `system: true`. + + A record that lands without its master anyway is readable by nobody, and every later write to it by id is refused. Until now `relationship/master-detail-required` was a `warning` with the predicate "`required` is not `true`", on every object, so shapes 2 and 3 drew no finding at any severity. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary, scoped to `controlled_by_parent`. + + **BREAKING — what moves for consumers.** + + - `os lint` reports each of the three shapes at `error` when the object declares `sharingModel: 'controlled_by_parent'`, located at the defect (`…fields.FIELD.required`, `.readonly` or `.system`). It covers every `master_detail` field of such an object, the same scope the builder's `required: true` force already applies. `os lint` therefore exits non-zero on such a stack, and the metadata-generation rubric (`scoreMetadata`) weighs the finding as an error and marks the stack `valid: false`. + - `@objectstack/spec` gains the step-18 semantic migration entry `cbp-master-detail-required-lint-error`, so `os migrate meta` across protocol 18 prints the prescription below. + + **Remedy — the v18 upgrade-checklist line.** On every object with `sharingModel: 'controlled_by_parent'`, give each `master_detail` reference `required: true` and remove any `readonly: true` or `system: true` from it. `os lint` now refuses the missing-`required`, `required` + `readonly` and `required` + `system` shapes there at `error` (`relationship/master-detail-required`). An object authored through `ObjectSchema.create` already gets `required: true` when the key is omitted, so the edit there is dropping the flag. + + **Unchanged.** + + - On every object that is not `controlled_by_parent` the rule is exactly as before: a `warning` for a `master_detail` without `required: true`, the same message and fix, and no finding for the two flagged shapes. + - The rule is not in the authoring-rule registry. `os build`, `os validate` and the metadata save door do not run it, so a stack carrying one of the shapes still builds and publishes. Only `os lint`'s exit code and the generation rubric move. + - Runtime is untouched. The security gate keeps refusing an insert that omits the master FK on these shapes and keeps resolving the master for metadata already at rest, and stored metadata is neither rewritten nor refused on load. + - No export or signature moves in either package. + - Measured before crossing, at `b04a5295f`: 129 authored objects across the example apps, the platform, plugin and service objects and the CLI's golden eval corpus. 7 of them are `controlled_by_parent`, and 0 draw the new `error`. + + + +### Patch Changes + +- 7aa8d51: feat(plugin-security): the boot no longer writes `sys_capability` rows for the platform's curated capabilities + + Clause-②: no + + The curated and derived-default capability seeder (`bootstrapSystemCapabilities`) is deleted. On a fresh database the boot writes no `sys_capability` row for the nine curated platform capabilities (`manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`), and none for a name a permission set grants in `systemPermissions` without declaring it. The curated capabilities are served by the registry: `GET /api/v1/meta/capability` lists them and `GET /api/v1/meta/capability/:name` answers each one's definition, as before. Declare a capability your package grants with `defineCapability` (`stack.capabilities`); a declared capability is still seeded as a package row. + + No authorization decision changes: a grant is still resolved by the capability name in `systemPermissions`, and no runtime check reads a `sys_capability` row. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + + In `@objectstack/spec` (`PLATFORM_CAPABILITIES`) and `@objectstack/lint` (`validateCapabilityReferences`), the doc comments now name the registry instead of the retired seeder; no behaviour changes there. +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- 9fdb127: A `loop`'s (with a `body`) and a `map`'s `collection` is a value slot now: the list the node iterates is a CEL value envelope, `{ dialect: 'cel', source: 'tasks' }`, that the executor evaluates in the run's scope, or the items written as an inline array, iterated as written. A string there is refused at `objectstack validate`, at `registerFlow` and by the executor: a `{…}` template token, naming its CEL spelling, and a variable's bare name, naming that variable in an envelope. The envelope is newly accepted there; the contract refused it before this change. + + Clause-②: yes (narrowing: a string is refused at loop.collection and map.collection, a {…} token and a variable's bare name alike; widening: both accept a CEL value envelope they refused, their published JSON Schema and inferred types gain the envelope arm, and FlowCollectionSlotSchema is a new export) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (both slots accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. The list a `loop` or `map` iterates is a computed value. Until now the executors ran a string `collection` through the single-brace interpolator. The contract also declared a variable's bare name there (`collection: 'tasks'`), but the structured `loop` and `map` never read one: the interpolator handed the name back unchanged, the value was no list, and every run failed at the node ("collection 'tasks' did not resolve to an array"), although `registerFlow` and `objectstack validate` accepted the flow. That arm retires with the token, and the refusal names the envelope that iterates. + + **What changes where the list may be absent.** The envelope must evaluate to a list, and anything else fails the node, `null` included, as a token that resolved to nothing did. One difference remains on an absent variable: the template failed the node with a returned failure (the step's `NODE_FAILURE`), and CEL fails it with an evaluation fault naming the variable (`EXECUTION_ERROR`). A `fault` edge routes both. To iterate nothing when the list may be absent, guard it to answer `[]`: `has(vars.tasks) ? vars.tasks : []`. + + **What does not change.** An inline array is iterated as written, and its elements are data: a `{…}` inside one was never interpolated, and is not judged now. A `loop` with no `body` is the legacy flat-graph form: it still reads `collection` as a bare variable name and sets `$loopItems` and `$loopIndex`, and no door judges it. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `collection: '{tasks}'` | `collection: { dialect: 'cel', source: 'tasks' }` | an absent `tasks` fails the node with an evaluation fault instead of a returned failure | + | `collection: '{record.items}'` | `collection: { dialect: 'cel', source: 'record.items' }` | as above | + | `collection: '{tasks}'` where an absent list should iterate nothing | `collection: { dialect: 'cel', source: 'has(vars.tasks) ? vars.tasks : []' }` | the node iterates nothing where the template failed it | + | `collection: 'tasks'` (a bare name, on a `loop` with a `body` or a `map`) | `collection: { dialect: 'cel', source: 'tasks' }` | the node iterates; it failed every run before, so decide whether it should | + | `collection: ['a', 'b']` | unchanged | an inline array is the list | + | `collection: 'tasks'` on a `loop` with no `body` | unchanged | the legacy flat-graph loop is untouched | + + **The one-line fix: write a `loop`'s or a `map`'s `collection` as a CEL value envelope naming the list, `{ dialect: 'cel', source: 'tasks' }`, or as an inline array.** + + **Who is affected, measured.** In this repository: the showcase example (two `loop` collections and one `map` collection), the todo example (two `loop` collections), the flows guide, the service-automation README, and test fixtures; all are migrated in this change. hotcrm writes a `{…}` collection on eleven structured loops in nine flow files (and one test fixture) and no bare name; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `loop.collection` and `map.collection`: the published JSON Schema for both gains the envelope arm (a list, or the envelope object), and so do the inferred `LoopConfig['collection']` and `MapConfig['collection']`, which lose `string`. `FlowCollectionSlotSchema` (`@objectstack/spec/automation`) is the contract both take. + + **Still accepted, unchanged.** An inline array at both, and on a body-less `loop` any `collection` its legacy form reads. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `LoopConfigSchema.collection` and `MapConfigSchema.collection` take `FlowCollectionSlotSchema`: a list or a CEL value envelope, so the executor's contract parse refuses a string as a guard, in the words above. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS`' `loop.collection` and `map.collection` rows take the `value` role, and `loop.collection` is read only on a `loop` with a `body` (`onlyWithKey`). The `loop` and `map` descriptors declare the slot with `xExpression: 'value'`. The single-brace `flow-template` role, which recorded these two slots and no other, is gone from `FlowNodeExpressionRole`, so a descriptor marker `'template'` fails the reconciliation ratchet as unknown. + - **The executors.** The `loop` and `map` nodes evaluate an envelope through `AutomationEngine.evaluateValueEnvelope`, as every value slot does, and iterate an inline array as written. A value that is not a list fails the node, naming what it evaluated to and its source. + - **The lint.** `validate-flow-template-paths` reads a loop iterator's object off an envelope `collection` that names a variable, as it read the token before. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 968acc8: An `http` node reads each config key by what it is now. Its `url` and every `headers` value are text slots, like a notify `title`: they render `{{ }}` holes, `'https://api.example.com/v1/orders/{{ record.id }}'`. Its `body` is a value slot: a CEL value envelope, `{ dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }`, that the executor evaluates in the run's scope to the payload, or a literal sent as written. Its `method`, `durable`, `timeoutMs` and `signingSecret` are literals, read as written. A single-brace `{…}` token at any of them is refused, naming its `{{ }}` spelling at a text slot, its CEL spelling in the body, and at a literal the remedy to write the value: at `objectstack validate`, at `registerFlow` and by the executor, except at `signingSecret`, which the build doors leave to the run because the flow credential store may hold the secret it signs with, and which the executor's parse refuses there. + + Clause-②: yes (narrowing: a {…} token is refused at http url, each headers value, any string of a literal body, method, durable, timeoutMs and signingSecret, and as a whole headers map, and a body value that names a dialect must be a valid CEL value envelope; widening: the exported FlowNodeTextSlot type gains the optional eachValue field, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains http, and the published HttpConfig JSON Schema marks body as a value slot) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a url and a header are text. Until now the executor handed its whole config to the single-brace interpolator and parsed the result, so every key read the retired dialect, a whole token in a typed key (`timeoutMs: '{t}'`) resolved to its value's type, and a CEL envelope written as the body was sent to the endpoint as the object it spells. + + **The request is rendered once.** The executor parses the config as authored — after the flow credential store's `signingSecret`, unchanged — then renders the url and the headers and evaluates the body, before either mode. The durable outbox enqueues exactly the request the inline call would send, and nothing renders at delivery. + + **What changes where a value may be absent.** A url that renders to no text refuses the node, as an empty one did. A header that renders to no text is not sent, where the template sent it empty. In the body an absent variable or key fails the run under CEL, naming it, where the template wrote `null` for a whole token and nothing inside text; guard a value that may be unset, `has(record.owner) ? record.owner : null`. CEL holds a map's values to one type, so in a CEL map literal a literal or a text beside a field read is wrapped in `dyn(…)`. + + **The literals.** `method`, `durable`, `timeoutMs` and `signingSecret` passed through the interpolator too, but no shipped flow computed one: the census found no `{…}` token at any of them in this repository's examples, docs or dogfood fixtures, only in the tests that pinned the interpolation itself. So no value slot is built there. They are read as written, and a `{…}` token in one is refused with the literal's remedy; an envelope there is refused too, since nothing evaluates it. A token written as the whole `headers` map, which the template resolved to a map, is refused the same way. A signing secret that arrived through a template (`signingSecret: '{signing_key}'`) is refused: write the secret itself, which a flow saved through the metadata API keeps in the write-only flow credential store, or `''` to send unsigned on purpose. + + **What does not change.** Every literal is sent as written. The method still defaults to `GET` inline and `POST` durable. An authored `signingSecret: ''` still sends unsigned, and a secret the credential store holds still takes the literal's place. A url or header with no token, and a body with no token, behave as before. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `url: 'https://api.example.com/v1/orders/{record.id}'` | `url: 'https://api.example.com/v1/orders/{{ record.id }}'` | a `Date` in the url renders as its ISO text, not JSON-quoted | + | `headers: { 'X-Ref': 'r-{record.id}' }` | `headers: { 'X-Ref': 'r-{{ record.id }}' }` | a header that renders to no text is not sent | + | `body: { id: '{record.id}', status: 'done' }` | `body: { dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }` | an absent `id` fails the run instead of sending `null` | + | `body: { text: 'Done: {record.title}' }` | `body: { dialect: 'cel', source: "{'text': 'Done: ' + record.title}" }` | an absent `title` fails the run instead of sending `'Done: '` | + | `body: '{payload}'` | `body: { dialect: 'cel', source: 'payload' }` | an absent `payload` fails the run instead of sending no body | + | `timeoutMs: '{timeout}'`, `durable: '{flag}'`, `method: '{verb}'` | the value itself: `timeoutMs: 5000`, `durable: true`, `method: 'POST'` | the key is a literal now; nothing computes it | + | `headers: '{headers}'` | the map itself: `headers: { 'X-Ref': '{{ record.id }}' }` | a map is not computed into `headers` | + | `signingSecret: '{signing_key}'` | the secret itself, or `''` to send unsigned | a secret read from a flow variable is refused at the run | + + **The one-line fix: double the braces in `url` and the `headers` values, write `body` as one CEL value envelope that builds the payload, and write `method`, `durable`, `timeoutMs` and `signingSecret` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (three bodies holding four tokens) and the flows guide (one body); both are migrated in this change. No url, header, method, `durable`, `timeoutMs` or `signingSecret` in the examples, docs or dogfood fixtures carries a token. hotcrm was not measured at these keys. + + **Newly refused in the body.** An object naming a `dialect` was data before and was sent as written; it is an expression now, evaluated per run, and a malformed one is refused. An envelope nested inside a literal body is still data, sent as written. + + **Still accepted, unchanged.** Every literal at these keys, and the published shapes of `url` (a string), `headers` (a map of strings), `method`, `durable`, `timeoutMs` and `signingSecret`. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `HttpConfigSchema`: `url` and each `headers` value refuse a single-brace token through the text-slot judge; `body` takes `FlowValueSlotSchema`; `method`, `durable`, `timeoutMs` and `signingSecret` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. The token refusals at `method`, `durable`, `timeoutMs` and a whole `headers` map are marked read-as-written, so the build doors' value arm reports them at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains the `http` body row. `FLOW_NODE_TEXT_SLOTS` gains `http.url` and `http.headers`, whose new `eachValue` flag reads each value of the map at its own path (`headers.X-Ref`). `HttpConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the body's marker to the reconciliation ratchet. The build doors' carve-out for a config parsed after interpolation is gone with the interpolation. + - **The executor.** The `http` node parses its config as authored, renders the url and headers through the text renderer, evaluates the body through the shared value-slot resolver, and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names the http url and headers among the text slots, and at an http literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused`, `flow-text-slot-unbound-dollar-root-refused` and `flow-builtin-node-config-values-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 111ffff: A `notify` node's computed values are value slots now: `recipients`, `sourceId` and the values of `templateData` take a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, that the executor evaluates in the run's scope, or a literal written as it is. `recipients` holds one recipient or a list of them, each entry a string or an envelope, and an entry that evaluates to a list adds each of its recipients. The node's link, `actionUrl`, is a text slot like `title`: it renders `{{ }}` holes, `'/crm_case/{{ record.id }}'`. Its `sourceObject`, `actorId` and `payload` are literals, read as written: no flow computed one, so nothing is evaluated there. A single-brace `{…}` token at any of these positions is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling at a value slot, its `{{ }}` spelling at `actionUrl`, and, at a literal, the remedy to write the value. + + Clause-②: yes (narrowing: a {…} token is refused at notify recipients (the value or a list entry), templateData values, sourceId, actionUrl, sourceObject, actorId and any string of a payload value, a templateData value that names a dialect must be a valid CEL value envelope, and a CEL value envelope written as a payload value is refused; widening: recipients and sourceId accept a CEL value envelope they refused, a recipients list accepts envelope entries, and their published JSON Schema and inferred types gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (four positions accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a link with holes is text. Until now the executor ran each of these positions through the single-brace interpolator, and one of its answers reached nobody: a whole token holding a list, written as an entry of a `recipients` list — `recipients: ['{owners}']` with `owners = ['u1', 'u2']` — was handed to the messaging service as ONE recipient, `'u1,u2'`, and the run reported success. A token holding a record there became the recipient `'[object Object]'`, and so did one in a link or a `sourceId`. + + **What changes where a value may be absent.** A recipient envelope that evaluates to `null` or a blank string adds no recipient, as a token that resolved to nothing did, and a node left with none fails, naming the expressions that evaluated to nothing. An absent variable or key fails the run under CEL, naming it, where the template dropped the recipient; guard a field that may be unset, `has(record.assignee) ? record.assignee : null`. A relation hop on a scalar id (`record.account.owner` without the start node's `expand`) fails the evaluation itself, naming the key it could not read, where the template resolved it to nothing. `sourceId` must evaluate to a string; `null` leaves the key unset, and a half-specified click-through pair is still dropped. A recipient, or a `sourceId`, that evaluates to anything else — a map, a number — fails the node, naming what it evaluated to. + + **The literals.** `sourceObject`, `actorId` and the values of `payload` passed through the single-brace interpolator too, but no flow computed one: the census found no `{…}` token at any of them, in this repository or in hotcrm. So no value slot is built there. They are read as written, and a `{…}` token in one — in any string of a `payload` value, at any depth — is refused with the literal's remedy: a single-brace token is not resolved here; write the value. An envelope written there is refused too, since nothing evaluates it (a deeper one inside a `payload` value is data, as it always was). + + **What does not change.** A literal at every value slot is written as it is: a recipient string (`'team:ops'`, a user id, an email address) is one recipient, as before, and is not split. `template`, `topic`, `channels` and `severity` are read raw, as before. An explicit `actionUrl` still wins over the link synthesized from `sourceObject` / `sourceId`, and a link that renders no text yields to it, as an empty one did. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `recipients: '{record.owner}'` | `recipients: { dialect: 'cel', source: 'record.owner' }` | an absent `owner` fails the run instead of leaving the node without a recipient | + | `recipients: ['{record.assignee}']` where it may be unset | `recipients: [{ dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }]` | none: the `null` is dropped, as the unresolved token was | + | `recipients: ['{owners}']` (a list variable) | `recipients: [{ dialect: 'cel', source: 'owners' }]` | each of its recipients is notified, where the template sent one joined string | + | `recipients: ['{$User.Id}']` | `recipients: [{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }]` | none: a run with no user adds no recipient | + | `sourceId: '{record.id}'` | `sourceId: { dialect: 'cel', source: 'record.id' }` | an absent id fails the run instead of dropping the link | + | `sourceObject: '{record.object_name}'`, `actorId: '{record.owner}'`, `payload: { who: '{record.owner}' }` | the value itself: `sourceObject: 'crm_case'` | the key is a literal now; nothing computes it | + | `templateData: { name: '{record.name}' }` | `templateData: { name: { dialect: 'cel', source: 'record.name' } }` | the template gets `null` (a guard's answer) where the key was absent | + | `actionUrl: '/crm_case/{record.id}'` | `actionUrl: '/crm_case/{{ record.id }}'` | a `Date` in the link renders as its ISO text, not JSON-quoted | + + **The one-line fix: write each `notify` value position as a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, double the braces in `actionUrl`, and write `sourceObject`, `actorId` and `payload` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (16 recipient strings, 7 `sourceId`s and 4 links), the todo example (2 recipients and 2 `sourceId`s), the flows guide, the email-templates and common-patterns guides, two dogfood fixtures and test fixtures; all are migrated in this change, and a recipient field that may be unset is guarded. hotcrm at `1d7148bf2d` writes 19 notify nodes in 15 flow files, each with one `{…}` recipient (2 of them `{$User.Id}`), 35 `{…}` `templateData` values and a link with holes; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `recipients` (the value, or each entry of a list) and `sourceId`: their published JSON Schema gains the envelope arm, and so do the inferred `NotifyConfig` types. At a `templateData` key an object naming a `dialect` was data before; it is an expression now, evaluated per run, and a malformed one is refused. `sourceObject`, `actorId` and `payload` keep their published shapes (a string, a string, a map of any values), with no envelope arm. + + **Newly refused at the literals.** A `{…}` token in `sourceObject`, in `actorId` or in any string of a `payload` value, and a CEL value envelope written as a `payload` value, which was handed on as the object it spells. + + **Still accepted, unchanged.** Every literal at these positions. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `NotifyConfigSchema`: `recipients` takes a string, a list of strings and envelopes, or one envelope; `templateData` takes `FlowValueSlotSchema` per key; `sourceId` takes a string or an envelope; `actionUrl` refuses a single-brace token through the text-slot judge; `sourceObject`, `actorId` and `payload` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. That refusal is marked read-as-written, so the build doors' value arm reports it at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains three `notify` value rows. `recipients` carries `eachElement`: a list is read entry by entry, at its index (`recipients[1]`). `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`. `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the markers to the reconciliation ratchet. + - **The executor.** The `notify` node evaluates each envelope through the shared value-slot resolver, renders `actionUrl` through the text renderer and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names `actionUrl` among the text slots, and at a notify literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- d85615d: A flow VALUE slot now refuses the date macros as well — `{TODAY()}`, `{NOW()}`, with an optional `± N` day offset — so it reads no single-brace `{…}` token at all. Each is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form: `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())`, `isoDatetime(addDays(now(), N))`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots: one dialect per slot, with a remedy for every spelling. The date macros were the last spelling kept, because CEL had no string form for a timestamp: `today()` and `now()` reach the data engine as a `Date` object, not the ISO text the macros wrote. The CEL stdlib now has one — `isoDate(t)` (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC calendar the macros rendered on — so the macros can be refused with a remedy that writes the same text. + + **What is refused.** In every value slot — `create_record` / `update_record` `fields`, the `assignment` node's `assignments` map, and both legacy `assignment` shapes — a string carrying a date macro, alone or beside other text and tokens. A string that mixed a date macro with another token used to be kept whole, the run-user token included (`'Due {TODAY()} by {$User.Id}'`); it is refused now, with a remedy for every token. + + **Where the remedy parts from the template.** For no offset or a whole number of days the CEL form writes the template's text byte for byte, at every instant (measured live through both engines over DST transitions, month and year ends and a leap day). The refusal names the three places it does not: + + - a fractional offset: the template added it to the day of the month and truncated the sum, `addDays` truncates the offset itself — going back, a day apart on most days of a month — and `daysFromNow(1.5)` / `daysAgo(1.5)` are refused at build; + - a variable offset (`{TODAY() + days}`): the template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number; CEL reads the path, and fails the run on an absent variable or a value that is not a number; + - an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days. + + **Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged. + + **The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing | + | `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`, `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | nothing for a whole number of days; a fraction truncates differently, and `daysFromNow` / `daysAgo` refuse one at build | + | `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing | + | `'{NOW() + 2}'`, `'{NOW() - 1}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }`, `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }` | nothing — `addDays` keeps the time of day, where `daysFromNow` would land on midnight | + | `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 | + | `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one | + | `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object | + | the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run | + | a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | nothing for a whole number of days | + + **The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.** + + **Who is affected, measured.** This repository's examples carry no date macro in a value slot; its own test fixtures and docs that did are migrated in this change. The census taken for this retirement's first change measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`); they migrate there. Deployed metadata and other repositories were not measured here. + + **Still accepted, unchanged.** A CEL value envelope and every literal. The single-brace dialect, date macros included, keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms. +- 6a06e3b: A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots, one dialect per slot, with a remedy for every spelling. The run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. + + **What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. + + **The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. + + **Still accepted, unchanged.** A CEL value envelope and every literal. `{$User.*}` keeps resolving where the single-brace dialect still lives, a CRUD `filter` value. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | + | `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | + | `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | an `assignment` of `uid: { dialect: 'cel', source: 'current_user.id' }`, a `get_record` on `sys_user` with `filter: { id: '{uid}' }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | + | `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | + | a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | nothing when the run has a user; where the flow can run without one, compute `current_user != null ? current_user.id : null`, which renders nothing where the template did | + + **The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** + + **Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}`, a `notify` `recipients` entry, is migrated to `current_user` with the `notify` value slots on this line. Other repositories and deployed metadata were not measured here. + + ### The kit + + - **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. + - **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, the hint at a node's literal names the value itself, and every other position keeps the single brace. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope and `current_user`. +- 78f841b: A `script` or `subflow` flow node whose `config` carries a key its executor contract does not declare is refused at parse, with a location, in the contract's own words: a `script` `bogusKey`, a `subflow` `timeoutMs` written inside `config`, and the like no longer pass the build doors and registration and then fail every run. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The `script` and `subflow` executors parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check reads the node type descriptor's `configSchema`, and these two descriptors publish none, while the build doors' executor-contract arm judged required keys and present values but not key membership. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: ``script 'n': config does not satisfy the script contract — config: Unrecognized key(s) on this script node config: `bogusKey` ``. + + **What is refused.** A `script` node, at any depth, whose config carries a key other than `function`, `inputs` and `outputVariable`, or a `subflow` node whose config carries a key other than `flowName`, `input` and `outputVariable`. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door. + + **What stays as it was.** + + - Every other builtin node type: its undeclared keys are judged at registration against its descriptor's `configSchema`, with that check's own prescriptions, and the build doors do not judge them. + - `decision`: it publishes no descriptor `configSchema` either, but its executor parses no contract, so an undeclared key fails no run and stays unjudged. + - A retired `script` key (`actionType`, `template`, `recipients`, `variables`, `script`) keeps its tombstone path. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`functionName` and `input` on a `script`, `flow` on a `subflow`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key, as its missing canonical key already was. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`funtion`, `outputVariabel`) | the declared key: `function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow` | + | a value the function or child flow should receive, as its own config key (`config: { function: 'f', taskId: '{record.id}' }`) | inside the input map: `config: { function: 'f', inputs: { taskId: { dialect: 'cel', source: 'record.id' } } }` (`input` on a `subflow`) | + | a `subflow` `config.timeoutMs` | on the node: `{ id, type: 'subflow', timeoutMs: 30000, config: { … } }` | + | a key nothing reads | delete it | + + **The one-line fix: rename, move or delete the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does. + + **Who is affected, measured.** At `15ec50e528`, every `script` and `subflow` node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (8 nodes: 6 `script`, 2 `subflow`) carries only declared keys, and so does every one in hotcrm at `c9678036d9` (5 `subflow`, no `script`). The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys for both types (its `timeoutMs` field writes the node, not `config`), and seeds a new node with an empty `config`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + + **`@objectstack/lint`.** `validateStackExpressions` keeps the pre-conversion tolerance it declares: on a raw source, a `script` node's `functionName` alias stays the callable check's to read, not an undeclared-key error, while every other undeclared `script` key is refused there as at the build doors. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, judged for the builtins in the spec's schemaless class (`SCHEMALESS_NODE_CONFIG_SCHEMAS`) that have an executor contract; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries. + - **The ledger.** The D3 semantic entry `flow-script-subflow-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- 6c17a50: The runtime publish gate no longer charges a write with a stored sibling's finding that only shows when the written item is present. Re-saving a master object with only its label changed answered `422 INVALID_METADATA` for a stored detail the author never touched: a detail's `lookupColumns` entry naming no field of the master, or a detail's `readonlyWhen` read through `parent`. + + Clause-②: no + + - On an update into a context collection (`object`, `permission`, `book`, `dataset`) the gate also judges the stored universe: the live collections with the written item's stored self in place. A finding located on another entry that the stored universe already holds is not this write's. + - A finding located on the written item itself is judged as before, even when the stored row carries the same finding. Re-saving a row is writing it. + - A write that newly breaks a sibling is still refused, for example removing the master field a detail's `lookupColumns` names. + - A create is judged as before. + - On a permission write the same change drops a stored detail's `security-master-detail-ungranted` advisory from a label-only re-save of the tenant's only permission set. + - A finding whose path names no entry the gate can locate keeps the previous verdict. + - ⛔ Nothing you author changes, and no export or signature changes. +- 9fe7448: fix(spec, lint): the functional-completeness findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** The nine rule ids the ADR-0078 completeness predicate emits (`field/summary-without-operations`, `field/formula-without-expression`, `field/relationship-without-reference`, `field/choice-without-options`, `view/layout-without-binding`, `view/tree-without-parent-field`, `view/row-color-without-colors`, `view/row-color-unresolvable-value`, `webhook/without-triggers`) printed a `message` of 239 to 793 characters. Each `message` is now one verdict sentence of at most 200 characters (for `view/row-color-unresolvable-value`, plus the offending `key` = "value" entries it lists). It still names the runtime site that makes the verdict true, for example `` A `summary` field with no `summaryOperations` reads 0/null everywhere: the engine's summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`) ``. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`) is unchanged, and so are every rule id, severity, `path`, and what each rule accepts or refuses. For `view/layout-without-binding`, the keys the old body told the author to declare are on that `fix:` line. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these nine ids**, for example `os explain view/layout-without-binding`. It prints the reasoning the verdicts no longer carry: each view type's measured renderer path, refusal screen and required keys, the exemptions (`user` relationships, `multiselect`, `gallery`), the hex trap behind the two `rowColor` rules, and why an empty `triggers` is not an off switch. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the nine ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the nine entries. + - **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face. `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate, the rule runs on `object` writes only (Studio, REST `/meta`, MCP), so only the `field/*` ids reach it. A `summary` field with no `summaryOperations` or a `formula` field with no `expression` is an `error`: the 422 issue's `message` changes, and so does the server's refusal log line under the unlinted-writes hatch. A `checkboxes` field with no `options` is a `warning`: the `message` in the 2xx response's `advisories` changes, and so does the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. A `lookup`/`master_detail` with no `reference` and a `select`/`radio` with no `options` never reach this gate: the object schema's own parse refuses them first, with its own message, which this change does not touch. + - **Unchanged doors.** The registry's boot-time line (`[Registry] Object "…" registered with N functionally-incomplete field(s) …`) prints each finding's rule id and `fix`, never its `message`, so it reads as before. The webhook auto-enqueuer's `webhook/without-triggers` boot warning has its own text, also unchanged. The `view/*` and `webhook/*` ids do not run at the runtime publish gate. +- d8c7d38: fix(lint): the flow-credential, AI skill, flow-filter-token, managed-object API method, organization-axis business-unit grant, retired permission residue, seed and semantic-role provenance findings print one verdict line, `os explain ` carries their reasoning, and `field-no-consumers` stops listing every object that shares a field name + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 10 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 194 characters, and the runtime publish gate's suite at most 181; before, the longest of each ran from 222 to 441 characters. The ids: + - flows: `flow-credential-literal` (a literal credential in an `http` node's headers or url query, or in a `connectorConfig.input`) and `flow-filter-token-unknown` (a `{NAME()}` filter token neither the flow template dialect nor the filter placeholders define); + - AI agents and skills: `ai-skill-surface-mismatch` and `ai-skill-tool-unresolved`; + - managed objects: `object/managed-api-method-unaffordable`; + - sharing rules: `org-axis-cross-org-bu-grant` (its sibling `org-axis-permission-inheritance` already printed one sentence of at most 174 characters and is unchanged); + - permission sets: `permission-retired-lifecycle-residue`; + - seed datasets: `seed-insert-mode-duplicates-on-replay` and `seed-value-outside-state-machine`; + - semantic roles: `semantic-role-field-unprovisioned`. + + A verdict no longer repeats what the finding's `where` already names: the skill (`ai-skill-tool-unresolved` now opens on the tool), the managed object, the sharing rule's object and the semantic-role object. `flow-credential-literal` keeps a short route (`move it to a connector's \`auth.credentialRef\``, or `drop it` for a connector input) because `os lint` prints the fix only under `--fix`; the route by auth variant stays the fix line's. `seed-value-outside-state-machine` names the first three declared states, then `(and N more)`. `object/managed-api-method-unaffordable` no longer appends the spec's `describeManagedApiMethodConflicts` sentence; it names the refused verbs, the `managedBy` bucket and the affordances they need. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`field-no-consumers`** names the first other object that declares a field of the same name and counts the rest (`a consumer of the same name on "showcase_account" (and 9 more) does not count`), where it listed every one. On `os validate` of the showcase app its longest finding drops from 341 characters to 199, and that 341-character one, which named ten objects, now prints 143. Its `inert` and `carrier-only` verdicts and its explanation are otherwise unchanged. + - **`os explain ` takes these 10 ids**, for example `os explain flow-credential-literal`. It prints the reasoning the verdicts no longer carry: which flow positions are served to every flow reader and where a credential belongs, by auth variant; how an agent's and a skill's surfaces must match, and why an unresolved skill name is skipped; how a skill's tool name resolves and when an action materialises as a tool; the two vocabularies a flow filter token resolves in, and why a miss stops the node; which affordance each write verb needs and what the registry does with a refused one; why business-unit trees stay inside one organization and which recipients may share a platform-global object; why the retired `allowRestore` / `allowPurge` default is accepted in silence and which channels miss it; why seeds must be safe to replay and which states a seeded value may name; and why an injected column on an external object is blank. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 10 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 10 entries. + - **Where the new text prints.** On the CLI, all 11 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs when it compiles at startup, and on each watch recompile under `--verbose`), `os lint` (as `where: message`) and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. `os verify` prints the error-severity ids' new text in its refusal list (`ai-skill-surface-mismatch`, `flow-filter-token-unknown`, `object/managed-api-method-unaffordable`, `org-axis-cross-org-bu-grant`) and counts the warnings without printing them. At the runtime publish gate (Studio, REST `/meta`, MCP): a `flow` write's `flow-filter-token-unknown` and an `object` write's `object/managed-api-method-unaffordable` change the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `flow` write's `flow-credential-literal` and a `permission` write's `permission-retired-lifecycle-residue` change the 2xx `advisories` entry and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** `org-axis-cross-org-bu-grant`, the two seed ids, `semantic-role-field-unprovisioned` and `field-no-consumers` (CLI-only rules), and the two AI ids, whose rules run at that door only for a `flow` write, whose snapshot carries no agents or skills of their own. +- 2e11d90: fix(lint): 15 author-time rule ids print a `fix:` line of one remedy sentence, and `os explain ` carries what those lines used to enumerate + + Clause-②: no + + - **Shorter fix lines.** Each finding of these 15 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters (the lengths here are the `hint` alone; the CLI prints it after `fix: `). Before, the longest of each ran from 128 to 2,280 characters. The ids: + - sharing rules: `sharing-rule-runtime-variable-condition` (2,280 → 195) and `sharing-rule-unlowerable-condition` (968 → 199); + - approvals: `approval-approvers-may-resolve-empty` (its `manager` arm 2,243 → 198, its group arm 440 → 200); + - RLS predicates: `rls-predicate-unenforceable` (1,056 → 191), `rls-predicate-over-budget` (1,044 → 197) and `rls-predicate-unknown-field` (766 → 154); + - filters: `filter-empty-combinator` (764 → 154); + - permission sets: `permission-retired-lifecycle-residue` (681 → 14: `Delete the key`); + - view visibility: `visibility-predicate-over-budget` (651 → 179); + - hook bodies: `hook-api-update-readonly-field` (647 → 182); + - flows: `flow-time-relative-descriptor-invalid` (342 → 176); + - pages: `component-type-unknown` (229 → 158), `react-chart-aggregate-invalid` (363 → 191) and `react-chart-drilldown-invalid` (128 → 181: longer, because it now carries the remedy instead of a pointer); + - fields: `field-no-consumers` (on `os validate` of the showcase app, 624 → 199). + - **Four fix lines pointed at text the verdict no longer carries.** `component-type-unknown` said "Apply the prescription above", `react-chart-drilldown-invalid` and `react-chart-aggregate-invalid` said "the rejection above carries the fix", and `flow-time-relative-descriptor-invalid` said "satisfies each message above", after earlier releases cut those verdicts to one sentence. Each now names the remedy itself: the retired component type's "Delete the …" sentence cut from `RETIRED_PAGE_COMPONENT_TYPES` (for example `Delete the \`element:form\` component and use the object-bound \`object-form\` block instead`), the schema refusal's rename or the layer a misplaced key belongs to, and the descriptor key the verdict names. + - **Enumerations are cut to the first item and a count.** `field-no-consumers` names the first carrier site and counts the rest (`… together with its 16 carrier site(s): translations[0].en.objects.showcase_account.fields.website (and 15 more)`), where it listed every one. `rls-predicate-unenforceable` gives the first faulty site's rewrite and counts the rest; for a bare `current_user` it names the key slot (`current_user.`) instead of listing every kernel key. `rls-predicate-unknown-field` no longer lists every field of the object: the verdict's "Did you mean" already names the nearest one. `permission-retired-lifecycle-residue` carries the retirement's own "Delete the key" sentence, cut from the tombstone's prescription, not the whole prescription. + - **`os explain ` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain ` prints: the lowerable predicate subset and its traps, the comparison classes with the declared types each holds, the rule each `current_user` holding breaks, which mechanism replaces a `current_user` read on which sharing model (an RLS policy narrows only `public_read` / `public_read_write`; it grants nothing on `private`), the graded routes that populate `sys_user.manager_id`, the four ways to shrink an oversized RLS predicate (and why a top-level `&&` must never be split), and the declared "match nothing" filter spelling. The entries already existed; they gain paragraphs. + - **Where the new fix lines print.** On the CLI: the `fix:` line under each finding of `os validate`, `os build` (and `os compile`, which `os dev` runs at startup and, under `--verbose`, on each watch recompile), the scaffold check `os init` runs, and `os verify`'s refusal list; `os lint` prints it as `→ fix:` under `--fix` and as `fix` in `--json`; `os validate --json` (`errors`) and `os build --json` (author-time `issues`) carry it on an error-severity finding; their `warnings` carry an advisory as one `where: message` string, without it. At the runtime publish gate (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), the `hint` FROM → TO, measured at the door: + - a `flow` write: `flow-time-relative-descriptor-invalid` (422 `issues[].hint`, 342 → 176), `filter-empty-combinator` (422, 647 / 764 → 134 / 154) and `approval-approvers-may-resolve-empty` (2xx `advisories[].hint` and the `[Protocol] authoring advisory` log line, 2,243 / 440 → 198 / 200); + - a `permission` write: `rls-predicate-unenforceable` (422, 912 / 312 → 186 / 121), `rls-predicate-unknown-field` (422, 708 → 154), `rls-predicate-over-budget` (422, once the platform CEL bounds refuse) and `permission-retired-lifecycle-residue` (2xx advisory, 573 / 681 → 14); + - a `view` write: `visibility-predicate-over-budget` (422, 651 → 179). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the approval and residue toasts shorten; no Studio, REST or MCP reader in this repository or in objectui matches on `hint` text. A tool that did should match on `rule` and `path` instead. + - **Never at the runtime gate:** the two sharing-rule ids, `field-no-consumers`, `component-type-unknown`, the two `react-chart-*` ids and `hook-api-update-readonly-field` run on the CLI only. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 7dbb89d: fix(lint): 16 more author-time rule ids print a `fix:` line of one remedy sentence, and `os explain ` carries what those lines used to enumerate + + Clause-②: no + + - **Shorter fix lines.** Each finding of these 16 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (the lengths here are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: + - validation rules: `validation-rule-json-schema-unknown-format` (646 → 183); + - datasets and cubes: `measure-aggregate-field-type-refused` (645 → 178) and `dimension-json-stored-field-refused` (262 → 177); + - form predicates: `predicate-rhs-path-shaped` (643 → 184), `predicate-path-unrooted` (214 → 122) and `predicate-path-unresolved` (201 → 153); + - RLS predicates: `rls-predicate-unknown-user-variable` (618 → 193) and `rls-predicate-unparseable` (523 → 171); + - hook bodies: `hook-api-update-readonly-when-field` (607 → 173); + - action names: `action-name-undefined` (560 → 195); + - AI skills: `ai-skill-tool-unresolved` (548 → 189); + - organization axis: `org-axis-permission-inheritance` (542 → 145) and `org-axis-cross-org-bu-grant` (315 → 141); + - object names: `object-reference-unknown` (522 → 134) and `object-reference-unregistered-platform` (500 → 187); + - filters: `filter-empty-node` (515 → 113). + - **Lists are no longer echoed.** `object-reference-unknown` no longer lists every object the stack and its artifact define, and `action-name-undefined` no longer lists every action of the stack or of the related object: the verdict's "Did you mean" already names the nearest one. `validation-rule-json-schema-unknown-format` names the nearest registered format and no longer lists all of them, and `ai-skill-tool-unresolved` no longer lists the tool family prefixes. `rls-predicate-unknown-user-variable` names the kernel keys and the nearest one, and so holds no author text. + - **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why a missing object costs what it costs at each site (the record picker, the view container's index, the dashboard filter, the dataset's reports, the nav entry), the doors that refuse a refused aggregate pair later (a dataset's compile, a cube's query), how an app stages an RLS membership set, why no system context waives a `readonlyWhen` lock, and the declared "match nothing" filter spelling. Where the namespace-prefixed object exists, a view container's fix line is that spelling alone (`write "my_app_order_line", not "order_line"`). + - **`os explain ` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain ` prints. Two ids had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `object-reference-unknown` ("how an object name resolves, and what a miss costs") and `org-axis-permission-inheritance` ("why access never walks the organization tree"). The other 14 entries gain paragraphs. The registered format names and the tool family prefixes are written out in their entries and held to the live sets by the rules' tests. + - **Where the new fix lines print.** On the CLI: the `fix:` line under each finding of `os validate`, `os build` (and `os compile`, which `os dev` runs at startup and, under `--verbose`, on each watch recompile), the scaffold check `os init` runs, and `os verify`'s refusal list; `os lint` prints it as `→ fix:` under `--fix` and as `fix` in `--json`; `os validate --json` (`errors`) and `os build --json` (author-time `issues`) carry it on an error-severity finding; their `warnings` carry an advisory as one `where: message` string, without it. At the runtime publish gate (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), the `hint` FROM → TO, measured at the door: + - an `object` write: `validation-rule-json-schema-unknown-format` (422 `issues[].hint`, 638 / 606 → 175 / 143, with and without a nearest name); + - a `dataset` write: `measure-aggregate-field-type-refused` (422, 606 / 581 → 147 / 122), `dimension-json-stored-field-refused` (422, multi-value 259 → 166, structured JSON 177 unchanged), `object-reference-unknown` (422 on the dataset's `object`, 331 → 134) and `object-reference-unregistered-platform` (2xx `advisories[].hint` and the `[Protocol] authoring advisory` log line, 449 → 187); + - a `view` write of a schema-bound metadata form: `predicate-rhs-path-shaped` (422 on a dotted right-hand side, 384 → 156; 2xx advisory on a bare word, 631 → 180), `predicate-path-unrooted` (422, 214 → 122) and `predicate-path-unresolved` (422, 100 → 36 with a nearest key); + - a `permission` write: `rls-predicate-unparseable` (422, 523 → 171) and `rls-predicate-unknown-user-variable` (422, 566 / 634 → 158 / 190, without and with a nearest key); + - a `flow` or `report` write: `filter-empty-node` (422, 515 / 278 / 170 → 113 / 107 / 69 for the whole filter, a `$or` branch and a `$and` branch). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the platform-object and bare-word toasts shorten; no Studio, REST or MCP reader in this repository or in objectui matches on `hint` text. A tool that did should match on `rule` and `path` instead. + - **Never at the runtime gate:** `hook-api-update-readonly-when-field`, `action-name-undefined`, `ai-skill-tool-unresolved` and the two `org-axis-*` ids run on the CLI only, as do the object-reference ids on every site but a dataset's `object`. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 6a7eca6: fix(lint): 18 more author-time rule ids print a `fix:` line of one remedy sentence, and `os explain RULE_ID` carries what those lines used to explain + + Clause-②: no + + - **Shorter fix lines.** Each finding of these 18 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (the lengths here are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: + - flow credentials: `flow-credential-literal` (646 → 177); + - flow writes: `flow-update-readonly-when-field` (549 → 174) and `flow-update-readonly-field` (400 → 143); + - list-view bulk wiring: `action-dispatch-contract-mismatch` (514 → 157); + - security posture: `security-controlled-by-parent-ambiguous-relation` (496 → 174), `security-master-detail-ungranted` (314 → 153), `security-controlled-by-parent-no-relation` (294 → 194), `security-fls-unknown-field` (293 → 132) and `security-wildcard-vama` (218 → 103); + - sharing rules: `sharing-rule-object-not-shareable` (482 → 144) and `sharing-rule-object-controlled-by-parent` (395 → 169); + - interface pages: `page/visualization-without-binding` (480 → 183); + - action bodies: `action-api-update-readonly-when-field` (470 → 188); + - flow triggers: `flow-api-trigger-secret-missing` (461 → 171), `flow-time-relative-descriptor-unroutable` (441 → 166), `flow-trigger-unroutable` (295 → 163), `flow-trigger-unknown-event` (268 → 101) and `flow-trigger-unknown-object` (210 → 156). + - **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why `runAs: 'system'` is the channel for a static readonly field and why a `beforeInsert` stamp survives the strip; why a hook-derived value lands on a `readonlyWhen`-locked record, the drop-the-field alternative and that the warning never blocks a build; how to pick between the two bulk contracts; the controlled-by-parent tier precedence and what each sharing baseline means; how to tell which half of a sharing rule on a public object is wrong; why `sourceView` is an interface page's one binding channel; the descriptor's optional keys and the schedule's default; the api trigger's signature format; the connector's `provider`, the Basic auth variant, where `credentialRef` resolves and that a `{variable}` template draws nothing. + - **A list becomes a nearest name.** `security-fls-unknown-field` no longer lists up to twelve of the object's fields; it names the nearest declared or registry-injected column when one is close (`'crm_account.description' is the nearest`), and none otherwise. `page/visualization-without-binding` names the field TYPE the derivation looks for and no longer repeats the name pattern it also tries. + - **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain RULE_ID` prints. Two ids had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `security-wildcard-vama` ("why the '*' entry cannot carry View All / Modify All") and `flow-trigger-unknown-object` ("why an unknown trigger object only warns"). The other 16 entries gain paragraphs. The auth variants and the descriptor keys the lines and entries spell out are held to the spec's `ConnectorInstanceAuthSchema` and `TimeRelativeTriggerSchema` by the rules' tests. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 13 of the 18 fire; the `hint` FROM → TO, measured at the door: + - a `flow` write: `flow-credential-literal` (2xx `advisories[].hint`, header / query / connector input 646 / 528 / 488 → 173 / 177 / 141), `flow-update-readonly-field` (422 `issues[].hint`, update / create 239 / 396 → 116 / 139), `flow-update-readonly-when-field` (2xx advisory, 527 → 161, under `runAs: 'user'` and `'system'` alike), `flow-trigger-unknown-object` (2xx advisory, record / time-relative 210 / 179 → 156 / 125), `flow-trigger-unknown-event` (422, token / array 191 / 268 → 101 / 96), `flow-time-relative-descriptor-unroutable` (422, 441 → 166), `flow-trigger-unroutable` (422, 295 → 163) and `flow-api-trigger-secret-missing` (422, 461 → 171); + - an `object` write: `security-controlled-by-parent-no-relation` (422, 294 → 194), `security-controlled-by-parent-ambiguous-relation` (422, 496 → 167) and `security-master-detail-ungranted` (2xx advisory, 304 → 147); + - a `permission` write: `security-fls-unknown-field` (422, 324 → 132), `security-wildcard-vama` (422, 218 → 103) and `security-master-detail-ungranted` (2xx advisory, 304 → 147). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the credential, readonlyWhen, unknown-object and ungranted-detail toasts shorten, and Studio's draft-changes panel shows the `hint` in place of the message for the two controlled-by-parent errors its in-browser security check finds on object drafts. No Studio, REST or MCP reader in this repository or in objectui matches on `hint` text; a tool that did should match on `rule` and `path` instead. + - **Never at the runtime gate:** `action-dispatch-contract-mismatch`, `page/visualization-without-binding`, `action-api-update-readonly-when-field` and the two `sharing-rule-object-*` ids run on the CLI only. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 63d5db2: fix(lint): 17 more author-time rule ids print a `fix:` line of one remedy sentence, and `os explain RULE_ID` carries what those lines used to explain + + Clause-②: no + + - **Shorter fix lines.** Each finding of these 17 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (the lengths here are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: + - list-view search: `searchable-field-unknown` (448 → 129), `searchable-field-unprovisioned` (362 → 175) and `searchable-field-unsearchable` (328 → 139); + - list-view sort: `sort-field-unprovisioned` (378 → 191), `sort-field-unsortable` (332 → 154) and `sort-field-unknown` (301 → 142); + - visibility predicates: `visibility-predicate-unknown-function` (375 → 165), `visibility-bare-identifier` (221 → 102) and `visibility-predicate-syntax` (214 → 113); + - flow filters: `flow-filter-token-unknown` (427 → 182); + - actions: `action-no-placement` (424 → 171); + - import mappings: `mapping-target-field-unknown` (405 → 189); + - dashboards: `unconsumed-widget-option` (396 → 135); + - app navigation: `nav-object-ungranted` (370 → 118); + - hook bodies: `hook-body-write-unprovisioned-anchor` (365 → 178), `hook-body-write-unknown-field` (275 → 110) and `hook-body-source-unparseable` (254 → 105). + - **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why a stale or inadmissible `searchableFields` entry is a `400 INVALID_FIELD` on list search, that a view narrows the object's searchable set and never widens it, and that a lookup column stores only an id; that both runtime doors refuse a formula sort with `400 INVALID_SORT`; how to vouch for an unprovisioned anchor (`columnMap`) or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`); the two vocabularies a flow filter token resolves in; the other-language operator spellings CEL does not parse, why re-spelling cannot fix an unregistered function, and which namespaces each surface binds; why gating a nav entry does not clear a missing grant; which hook-body write shapes are checked; what a partially parsed body leaves unchecked. + - **A list the verdict already covers is not echoed.** `searchable-field-unknown`, `sort-field-unknown` and `mapping-target-field-unknown` no longer list every field of the object: the verdict's "Did you mean" names the near miss. `mapping-target-field-unknown` still lists the object's compound fields and their parts, as every import-mapping refusal does. + - **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain RULE_ID` prints. Two ids had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `action-no-placement` ("where an action can be placed") and `unconsumed-widget-option` ("which widget options a renderer reads"). Five entries gain text. The action locations the new entry spells out are held to the spec's `ACTION_LOCATIONS` by the rule's test. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 10 of the 17 fire; the `hint` FROM → TO, measured at the door: + - a `view` write: `sort-field-unknown` (422 `issues[].hint`, a name / a dotted path 114 / 261 → 47 / 135), `sort-field-unsortable` (422, 318 → 140), `sort-field-unprovisioned` (2xx `advisories[].hint`, 363 → 176), `searchable-field-unknown` (422, a name / a dotted path 281 / 430 → 48 / 129), `searchable-field-unsearchable` (422, a formula / outside the declared set / a lookup / a json column 222 / 142 / 323 / 157 → 110 / 68 / 135 / 64), `searchable-field-unprovisioned` (2xx advisory, 361 → 174), `visibility-predicate-syntax` (422, a recognised spelling / none 106 / 214 → 106 / 107), `visibility-predicate-unknown-function` (422, 371 → 165) and `visibility-bare-identifier` (422, a bare name / an unbound namespace 160 / 215 → 41 / 96); + - an `object` write: `searchable-field-unknown` (422, the object's own set / a built-in list view 266 / 261 → 55 / 50), `searchable-field-unsearchable` (422, 222 → 110) and `searchable-field-unprovisioned` (2xx advisory, 361 → 174); + - a `flow` write: `flow-filter-token-unknown` (422, `{TOMORROW()}` / `{ROUND(x)}` / `{Math.round(x)}` / `{TODAY() - 45 - 10}` 336 / 358 / 427 / 100 → 91 / 113 / 182 / 100). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the two `*-unprovisioned` toasts shorten; Studio renders no 422 issue's `hint`. No Studio, REST or MCP reader in this repository or in objectui matches on `hint` text; a tool that did should match on `rule` and `path` instead. + - **Never at the runtime gate:** `action-no-placement`, `mapping-target-field-unknown`, `unconsumed-widget-option`, `nav-object-ungranted` and the three `hook-body-*` ids run on the CLI only. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- a9bcbc6: fix(lint): 17 more author-time rule ids print a `fix:` line of one remedy sentence, and `os explain RULE_ID` carries what those lines used to explain + + Clause-②: no + + - **Shorter fix lines.** Each finding of these 17 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (the lengths here are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: + - react pages: `react-chart-field-unprovisioned` (362 → 175), `react-prop-missing-required` (277 → 131), `react-page-source-unparseable` (254 → 105) and `react-block-needs-record-context` (228 → 195); + - action bodies: `action-body-write-unprovisioned-anchor` (358 → 171), `action-body-write-unknown-field` (323 → 91), `action-record-write-discarded` (309 → 104) and `action-body-source-unparseable` (254 → 105); + - agents: `agent-authoring-withdrawn` (363 → 158) and `default-agent-outside-roster` (237 → 118); + - flow nodes: `flow-node-write-unprovisioned-anchor` (358 → 171) and `flow-node-write-unknown-field` (289 → 110); + - object validation rules: `validation-rule-json-schema-uncompilable` (341 → 124) and `validation-rule-regex-uncompilable` (300 → 135); + - translations: `translation-section-name-missing` (359 → 172); + - managed objects: `object/managed-api-method-unaffordable` (357 → 169); + - app navigation: `nav-object-unservable` (348 → 156). + - **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: how to vouch for an unprovisioned anchor (`columnMap`) or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`), now on the react-chart, action-body and flow-node anchor ids too; what a partially parsed source leaves unchecked; which action-body write shapes are checked and that the warning never blocks a build; why the flow-node unknown-field finding gates where the body rules advise; that skills attach to the platform agents by `surface` affinity, so dropping a custom agent loses nothing; the escaped regex spellings and the named `format`s; the shapes the common JSON Schema keywords take; that a suggested section name is never a key derived from the label; and that a record block's react-tier replacement binds the record itself. + - **Shared one-line forms.** The anchor and parse ids of the react-page, action-body and flow-node rules print the same one-line forms the hook-body ids print (`If the remote table carries '…', declare it in …'s own fields (mapped through columnMap); otherwise drop the reference or opt the object out of the injection`, and `Fix the syntax error and re-run: until the source parses, the checks cover only what the parser recovered`). + - **Quoted from the contract, cut to one sentence.** A missing required react-tier binding still prints the contract's own description of it, now its first sentence, which names the binding and its spelling (`Pass data={…}: The object this block binds to, written as the metadata-tier data source: data={{ provider: 'object', object: '…' }}`); the rest of the description is in the published react-blocks reference. A record block's line is the spec's named replacement behind the lead-in `Instead:`. + - **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain RULE_ID` prints; eleven entries gain text. The named formats the regex entry spells out are held to the spec's `FormatValidationSchema` by the rule's test. `react-prop-missing-required` has no entry, as before, so its `rule:` line carries no pointer. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 5 of the 17 fire; the `hint` FROM → TO, measured at the door: + - an `object` write: `object/managed-api-method-unaffordable` (422 `issues[].hint`, two refused verbs / every write verb 335 / 372 → 132 / 169), `validation-rule-regex-uncompilable` (422, the pattern `([` / a 28-character pattern 288 / 314 → 123 / 149) and `validation-rule-json-schema-uncompilable` (422, 341 → 124); + - a `flow` write: `flow-node-write-unknown-field` (422, with / without a near miss 271 / 250 → 92 / 71) and `flow-node-write-unprovisioned-anchor` (2xx `advisories[].hint`, 361 → 174). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the flow-node anchor toast shortens; Studio renders no 422 issue's `hint`. No Studio, REST or MCP reader in this repository or in objectui matches on `hint` text; a tool that did should match on `rule` and `path` instead. + - **Never at the runtime gate:** the four react-page ids, the four action-body ids, the two agent ids, `translation-section-name-missing` and `nav-object-unservable` run on the CLI only. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 33ae4bf: fix(lint): 26 more author-time findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 26 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 200 characters; before, the longest of each ran from 205 to 697 characters. The ids: + - dashboard widgets: `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`, `dashboard-filter-field-not-included`, `dashboard-filter-field-unprovisioned`, `widget-filter-field-unknown`, `widget-filter-field-not-included`, `widget-sortby-unselected`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`; + - datasets: `dataset-include-unknown`, `dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`; + - security posture: `security-controlled-by-parent-ambiguous-relation`, `security-fls-unknown-field`, `security-controlled-by-parent-no-relation`, `security-master-detail-ungranted`, `security-owd-alias`, `security-delegation-missing-reason`; + - visibility predicates: `visibility-root-mislayered`, `visibility-predicate-over-budget`, `visibility-predicate-syntax`, `visibility-predicate-unknown-function`, `visibility-bare-identifier`. + + The values the author wrote still close each verdict — the field path, the candidate roster, the selection list, the predicate excerpt (now at most 72 characters; `visibility-predicate-over-budget` no longer echoes the predicate at all, its `path` locates it) — so a verdict over a long authored value grows with it. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 26 ids**, for example `os explain chart-field-unknown`. It prints the reasoning the verdicts no longer carry: how a dashboard filter reaches every widget, why the renderer ignores `chartConfig` binding keys, what an unresolved dataset path does to the analytics query, how the master relation of a `controlled_by_parent` object is chosen, how the runtime resolves a field-permission key, why a visibility predicate that cannot evaluate renders its element anyway. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 26 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 26 entries. + - **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. `os doctor`'s dashboard widget check prints the 11 widget ids as `where: message`, with no `rule:` line. At the runtime publish gate (Studio, REST `/meta`, MCP), by write type: a `dashboard` write carries the widget ids, a `dataset` write the dataset ids, a `view` write the visibility ids, an `object` write the two `controlled_by_parent` ids and `security-master-detail-ungranted`, a `permission` write `security-fls-unknown-field` and `security-master-detail-ungranted`, a `seed` write `security-delegation-missing-reason`. An `error` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `warning` (`dashboard-filter-field-unprovisioned`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`, `security-master-detail-ungranted`, `visibility-root-mislayered`) changes the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** `security-owd-alias`. The object schema's closed `sharingModel` / `externalSharingModel` enums refuse those values at parse, with their own message, before the gate runs; the rule speaks only on the unparsed doors (`os lint` on a raw config, a direct call). +- ebbb8f9: fix(lint): 14 more author-time findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 14 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire, and the CLI suite's handler-hook variants, is at most 200 characters; before, the longest of each ran from 212 to 792 characters. The ids: + - hook bodies: `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`; + - action bodies: `action-body-write-unknown-field`, `action-body-write-unprovisioned-anchor`, `action-record-write-discarded`, `action-body-source-unparseable`; + - flow nodes: `flow-node-write-unknown-field`, `flow-node-write-unprovisioned-anchor`; + - readonly writes: `flow-update-readonly-field`, `flow-update-readonly-when-field`, `hook-api-update-readonly-field`, `hook-api-update-readonly-when-field`, `action-api-update-readonly-when-field`. + + The three write surfaces now share one wording per question: an undeclared field ends on "so the write is refused (INVALID_FIELD / 400)", an unprovisioned anchor reads "'FIELD' is an injected column with no storage on external object 'OBJECT', so … can never land", an unparseable hook or action body reads "L2 body did not parse (…), so writes in its unread part go unchecked", and a readonly write says it is "silently stripped" (a `readonlyWhen` field "where its predicate is TRUE"). The values the author wrote still close each verdict — the field, the object, the `ctx.api` call, the run identity — so a verdict over a long name grows with it, and a hook lowered from an inline `handler` keeps its " (judged on the metadata body lowered from the inline handler)" suffix. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 14 ids**, for example `os explain hook-body-write-unknown-field`. It prints the reasoning the verdicts no longer carry: how each write channel reaches the engine's declared-field door and what the refusal takes down with it, why a write to an unprovisioned anchor on an external object passes the validator and never lands, what a partially parsed body leaves unchecked, why an action's `ctx.record` is never written back, and which readonly strip a system context waives and which it does not. The paragraphs the three surfaces share are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 14 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 14 entries. + - **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), a `flow` write carries the four flow ids: `flow-node-write-unknown-field` and `flow-update-readonly-field` are errors, so the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change; `flow-node-write-unprovisioned-anchor` and `flow-update-readonly-when-field` are warnings, so the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line change. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the ten hook and action ids. A `hook` or `action` write does not dispatch these rules (they parse authored JavaScript, which the publish path never loads), and a `flow` write's snapshot carries no hook or action body for them to read; they speak only on the CLI doors above. +- 07ee6f6: fix(lint): the RLS-predicate and sharing-rule findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites, the CLI's RLS admission suite and the runtime publish gate's suite fire is at most 195 characters; before, the longest of each ran from 492 to 2,056 characters. The ids: + - RLS policies (`permissions[].rowLevelSecurity[].using` / `.check`): `rls-predicate-unparseable`, `rls-predicate-unenforceable`, `rls-predicate-unknown-user-variable`, `rls-predicate-unknown-field`, `rls-predicate-over-budget`; + - sharing rules (`sharingRules[]`): `sharing-rule-unlowerable-condition`, `sharing-rule-object-not-shareable`, `sharing-rule-object-controlled-by-parent`, `sharing-rule-runtime-variable-condition`. + + A finding no longer echoes the predicate or the condition: its `path` names the clause, and the predicate is author text of any length (the old message quoted up to 200 characters of it, or all of it). Each verdict names what is wrong and closes on what it costs: an RLS policy the runtime drops ends "so the policy is dropped and grants no access" (a `using`) or "… and admits no write" (a `check`); a field compared with a field that holds a list or an object, or across comparison classes, quotes the first such comparison, names the column or the two classes, counts the rest, and ends on the `INVALID_FILTER / 400` refusal; a sharing condition that does not lower ends "so the rule is never seeded and grants nothing"; an anchor object a grant cannot widen names its `sharingModel` and the `SHARING_NOT_ENABLED` refusal. A quoted refusal — the compiler's, the platform's shared filter check's, or the engine's (`RLS using (CODE / STATUS): …`) — is quoted up to its own suggested rewrite, or to its first sentence. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 9 ids**, for example `os explain rls-predicate-unenforceable`. It prints the reasoning the verdicts no longer carry: what `RLSCompiler` does with a policy it cannot compile (the deny sentinel and its ZERO rows, the OR with other policies, the INSERT check a `using` doubles as, the `check` set), the one WARN line each drop leaves, which `current_user` keys the kernel resolves and with which type, why a column that holds a list or an object — or two columns of different comparison classes — cannot be compared, what the engine's own filter admission refuses and what that costs analytics, and, for sharing rules, why a condition is compiled once at boot, what an unlowerable or refused condition leaves behind, and why a grant on a public or `controlled_by_parent` object is refused. The paragraphs the RLS and sharing rules share are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries. + - **Where the new text prints.** On the CLI, all 9 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), a `permission` write carries the five `rls-predicate-*` ids, all errors: the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the four `sharing-rule-*` ids. The rule is registered for the CLI doors only (no write type dispatches it at the publish gate), so they speak only on the CLI doors above. +- 61bea24: fix(lint): the flow-trigger, react-page, dataset-measure and validation-rule findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 15 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites and the runtime publish gate's suites fire is at most 194 characters; before, the longest of each ran from 211 to 1,049 characters. The ids: + - flow triggers (`flows[].nodes[]` start node): `flow-time-relative-descriptor-invalid`, `flow-time-relative-descriptor-unroutable`, `flow-trigger-unroutable`, `flow-api-trigger-secret-missing`, `flow-trigger-unknown-event`; + - react pages (`pages[].source`): `react-chart-drilldown-invalid`, `react-chart-aggregate-invalid`, `react-chart-field-unprovisioned`, `react-block-needs-record-context`, `react-page-source-unparseable`; + - dataset and cube members: `measure-aggregate-field-type-refused`, `dimension-json-stored-field-refused`; + - object validation rules: `validation-rule-json-schema-uncompilable`, `validation-rule-regex-uncompilable`, `validation-rule-json-schema-unknown-format`. + + A verdict no longer repeats what the finding's `where` and `path` already name: the validation rule and its object, the JSON Pointer of a `format`, the measure or dimension name, the source of an uncompilable `regex` (the `fix:` line still quotes it). A refusal the verdict quotes is quoted to its verdict. `TimeRelativeTriggerSchema`'s refusal of a descriptor is quoted for ONE issue (an unrecognized key first) and the rest are counted, `(and N more)`; ajv's metaschema refusal is quoted to its first violation, with the rest counted; a `@objectstack/spec` unknown-key refusal is quoted to the key and the schema's rename (`Did you mean …?`), without the wrong-layer prescription and the history sentence that follow it; and when no form of an `` `groupBy` union matches, the verdict keeps the forms whose complaint is about the value's content. A measure or dimension over a column its join chain reaches names a `joined object` (it read "(reached through this dataset's join chain)"). The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 15 ids**, for example `os explain flow-trigger-unroutable`. It prints the reasoning the verdicts no longer carry: the engine's trigger routing chain, the record-trigger token grammar and the time-relative routing predicate, what nothing reports at run time, and where the publish gate refuses the flow; the inbound api secret (ADR-0041) and why the engine refuses to register a flow without one; the write path's fail-open skip of a rule whose `regex` or `schema` does not compile, the runtime's ajv environment, and what ajv does with an unregistered `format` name; each aggregate's accepted field types (the `AGGREGATE_FIELD_TYPE_COMPATIBILITY` rows, written out), what a JSON-stored column is, and why analytics refuses to group by one; how a forwarded `` or `aggregate` refusal is quoted and which keys the schema answers with a prescription instead of a rename (`schedule` and `runAs` on a time-relative descriptor, `drilldown`, `mode`, `report`, `view` and `sort` on a drill block, five aggregate and two `groupBy` keys); what an unprovisioned anchor is; why a `record:*` block renders empty on a react page; and why an unparseable react source is a warning. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 15 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 15 entries. + - **Where the new text prints.** On the CLI, all 15 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): a `flow` write carries the five flow ids (all errors, refusing an active flow's publish), a `dataset` write the two dataset ids, and an `object` write the three validation-rule ids — the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the five `react-*` ids. Their rule runs at that door only for a flow write, whose snapshot carries no pages, so they speak only on the CLI doors above. +- 762db99: fix(lint): the list-view sort and search, form-predicate path, bulk-dispatch, component-type and preset-comparand findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 12 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 193 characters, and the runtime publish gate's suites at most 137; before, the longest of each ran from 287 to 927 characters. The ids: + - list-view `sort` (`objects[].listViews`, `views[]` lists, list overlays and ViewItem records): `sort-field-unknown`, `sort-field-unsortable`, `sort-field-unprovisioned`; + - `searchableFields` (the object's own set, list views, and a react page's ``): `searchable-field-unknown`, `searchable-field-unsearchable`, `searchable-field-unprovisioned`; + - metadata-form `visibleWhen` predicates (`views[]` forms bound to a schema): `predicate-path-unresolved`, `predicate-path-unrooted`, `predicate-rhs-path-shaped`; + - list-view bulk wiring: `action-dispatch-contract-mismatch`; + - page component types (`pages[]`): `component-type-unknown`; + - filter comparands: `filter-preset-comparand`. + + A verdict no longer repeats what the finding's `where` already names: the view that wires an action (`action-dispatch-contract-mismatch`). The unprovisioned-anchor ids print the same one-clause cause the other converted anchor rules print (`'owner_id' is an injected column with no storage on external object 'x'`), and the two virtual-entry ids (`sort-field-unsortable`, `searchable-field-unsearchable`) state the storage fact in one shared wording. `searchable-field-unsearchable` quotes at most three names of an object's declared set, then `(and N more)`. A retired component type (`user:profile`, `element:filter`, `element:form`, `ai:chat_window`) is quoted to the head of its prescription in `RETIRED_PAGE_COMPONENT_TYPES` (`` `element:filter` was removed in @objectstack/spec 17 (ADR-0049) ``), and the verdict says the parse refuses the node by name; the whole prescription is the parse door's refusal of the same name, which `os validate` and `os build` print. `filter-preset-comparand` opens with the first sentence of the refusal the schema door shares (`"last_30_days" is a dashboard date-range PRESET name, not a filter value`), then names the operator and the preset's `{date-macro}` window. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 12 ids**, for example `os explain sort-field-unknown`. It prints the reasoning the verdicts no longer carry: why an unknown sort field breaks a view's first fetch and every load after it, and which list-view surfaces the sort and search rules walk and skip; what a `formula` field's lack of storage does to an ORDER BY and to a search; what an unprovisioned anchor is and what sorting or searching one measured; how a stale `searchableFields` entry narrows a search or falls through to the auto-default set, and how a list view's narrowing reaches the runtime as the `$searchFields` override; how a metadata-form predicate path is resolved against the edited schema, why a dead predicate fails open, and why the right side of `==` / `!=` is a literal; how the two bulk wirings call an action and why nothing refuses a mismatch at run time; which component types the vocabulary closes and where a retired type's prescription lives; and where a date-range preset name is understood and what each layer does with a bare one. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 12 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 12 entries. + - **Where the new text prints.** On the CLI, all 12 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change for: the three `searchable-field-*` ids on a `view`, `object` or `flow` write; the three `sort-field-*` ids on a `view` or `flow` write; the three `predicate-*` ids on a `view` write; and `filter-preset-comparand` on a `dashboard`, `view`, `object`, `page`, `flow` or `report` write. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** `component-type-unknown`, which runs on the CLI doors only, and `action-dispatch-contract-mismatch`, whose rule runs at that door only for a `flow` write, whose snapshot carries no actions to judge, so both speak only on the CLI doors above. +- 996aa86: fix(lint): the dashboard-action, empty-filter, list-view-field and translation findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence (followed by `Did you mean "…"?` where the rule offers the nearest declared name). Every finding the rules' own test suites fire is at most 199 characters, where the longest of each id ran from 226 to 553 before. The ids: + - dashboard header actions (`dashboards[].header.actions[]`): `dashboard-action-target-undefined` (the `script` and `modal` arms), `dashboard-action-route-unresolved` (one finding per unregistered `COLLECTION/NAME` segment of a `url` target); + - authored filters: `filter-empty-combinator` (`$and: []`, `$or: []`, `$not: {}`), `filter-empty-node` (`{}` as the whole filter, as a `$or` branch, as a `$and` branch); + - list-view field references: `list-view-field-unknown`, `list-view-field-dotted` (the projection door and the filter door's three refused head classes); + - translation bundles (`translations[]`): `translation-target-unknown` (every leg: objects and their fields, views, sections, tabs, validations and actions; global actions; apps and navigation; dashboards, widgets and header actions; flows, toasts, screens, screen descriptions, screen fields and refusals; action params, outcomes and result dialogs), `translation-option-key-unknown` (field, action-param and screen-field options), `translation-section-name-missing`. + + The empty-filter verdicts still take their row-set words ("matches EVERY row" / "matches NO row") from the shared filter reduction. `list-view-field-unknown` keeps the shared field-path sentence and, for a dotted reference, which written name it judged. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 9 ids**, for example `os explain translation-target-unknown`. It prints the reasoning the verdicts no longer carry: what a `script` or `modal` header-action target must name and how a `url` route is resolved and skipped; the empty-filter identity table, what each shape does on a read scope or beside other branches, and which filters are judged; what a dangling list-view field costs at each position, why only the head of a dotted name is judged, and which positions reach which query door; how the translation resolver reads a key, why an orphan key is an error while a mis-keyed option is a warning, what every bundle leg may name, and why a section with no `name` can never be translated. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries. + - **Where the new text prints.** On the CLI: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and the scaffold check `os init` runs print the new `message` on the text face for all 9 ids, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): on a `flow` or `report` write, `filter-empty-combinator` and `filter-empty-node` change the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; on the write types the reference-integrity suite dispatches the list-view rule on (`view`, `object` and `flow`), `list-view-field-dotted` and the error-tier positions of `list-view-field-unknown` change the 422 issue's `message` and the refusal log line, and its warning-tier positions ride the 2xx `advisories` and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the two dashboard-action ids (a CLI-only rule: a single published item cannot see the actions and pages it resolves against) and the three translation ids (the rules run there only on a `flow` write, whose snapshot carries no translation bundles). +- 23419ba: fix(lint): the navigation, object-reference, capability, action-name, import-mapping, view-container and interface-page findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 10 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 199 characters, and the runtime publish gate's suite at most 95; before, the longest of each ran from 227 to 629 characters. The ids: + - app navigation (`apps[].navigation`, `areas[]`): `nav-object-ungranted`, `nav-object-unservable`, `nav-target-unresolved`; + - object field-name lists (`highlightFields`, `publicSharing.redactFields`, `indexes[].fields` and the field-level `relatedListColumns`, `lookupColumns`, `lookupFilters`, `dependsOn`): `object-field-ref-unknown`; + - object-name references: `object-reference-unregistered-platform` (its sibling `object-reference-unknown` already printed one sentence of at most 175 characters and is unchanged); + - `requiredPermissions`: `capability-reference-unknown`; + - name-bound actions (list views, page components, navigation): `action-name-undefined`; + - import mappings (`mappings[].fieldMapping[].target`): `mapping-target-field-unknown`; + - the stack's `views:` collection: `view-container-shape`; + - interface list pages (`appearance.allowedVisualizations`): `page/visualization-without-binding`. + + A verdict no longer repeats what the finding's `where` already names: the import mapping and its object, and the interface page. `object-field-ref-unknown` prints the shared field-path sentence the list-view rules print (`highlightFields[1] "field_10" is not a field on object "proj_task".`); what a miss costs at each position is its explanation's. `action-name-undefined` names at most three places a related-list action is defined, then `(and N more)`. `nav-object-unservable` names the object's `enable` key and the status the list answers (404 or 405). The flat-list-view arm of `view-container-shape` no longer says `ViewSchema` strips the keys to an empty container — the schema is strict and refuses that shape, and so does the registration loop; it now says so. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 10 ids**, for example `os explain nav-object-unservable`. It prints the reasoning the verdicts no longer carry: which grants count toward a navigation entry's read access, why an object's `enable` block makes an entry dead for every user and what the served `/meta` does with it, and why a grant and a list are two independent findings; which `defineStack` check `nav-target-unresolved` stands in for; which object each field-name list is judged against and what a miss costs at each position; the object-name resolution ladder and the platform object registry; where a capability may come from; which surfaces bind an action by name and how a related list resolves and places one; what an import mapping target may name, compound-field parts included; the container-only contract of `views:` and the doors that hold it; and the renderer's binding derivation for each interface-page visualization. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 10 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 10 entries. + - **Where the new text prints.** On the CLI, all 10 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): an `object` write's `object-field-ref-unknown` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`, and a `dataset` write's `object-reference-unregistered-platform` (its base `object`) changes the 2xx `advisories` entry and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** `capability-reference-unknown` and `view-container-shape` (CLI-only rules), and the three navigation ids, `action-name-undefined`, `mapping-target-field-unknown` and `page/visualization-without-binding`, whose rules run at that door only for a `flow` write, whose snapshot carries no apps, pages, views or mappings of its own. +- 9af0005: The shared build validator (`@objectstack/lint`'s expression rule over `@objectstack/formula`) judges `record..` against an object's declared read attachments (`ObjectSchema.attachedOnRead`) + + - **`@objectstack/lint`.** The field index the expression rule builds now adds each block an object declares under `attachedOnRead` to the names `record.` resolves to, and hands the shared validator the block's declared leaves. A predicate such as `record.viewer.can_act` on an object that declares the `viewer` block is accepted where it was refused as an unknown field. + - **`@objectstack/formula`.** `ExprSchemaHint` gains an optional `attachedOnRead` (block name → declared leaf keys). When a `record.` or `previous.` reference names a declared block, its next segment must be a leaf the block declares. Anything else is refused with the existing `unknown-field` code: `field` is the dotted path as written (`viewer.can_actt`), `suggestion` the nearest declared leaf (`viewer.can_act`), and the new optional `block` and `leaves` params carry the leaves the block declares, which the message names. No new refusal code. + - **`@objectstack/metadata-core`.** The ADR-0106 field-level-security masker classifies the new top-level key as passed through unchanged (`OBJECT_REFERENCE_POSITIONS.attachedOnRead`): a block name and its leaf keys never name a field, so no caller's served object definition loses or gains anything. + - **Unchanged.** An object that declares no block keeps every verdict it had, and a second segment off any other field is not judged. Index access, a method call on a block and a third segment stay unjudged. +- 440bed6: A select option whose `visibleWhen` cannot be evaluated refuses the write instead of admitting the value with one warn line (#22402) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing on `@objectstack/objectql`, shipped as `minor` under the launch-window convention for breaking changes (recorded in `scripts/check-changeset-no-major.mjs`). The governing decision is ADR-0137 D2, which the maintainer's ruling on #22402 (letter A) extended to the option gate on the write path. + + A select, multiselect, radio or checkboxes option may gate itself with a `visibleWhen` predicate, and the server re-checks it for every value a write picks. ADR-0137 D2 says a field-rule predicate that cannot be evaluated refuses the submit. The option gate is the server's enforcement of who may pick the option (ADR-0124 D1), so D2 now reaches it on the write path too. + + - **Before.** When an option's predicate faulted on a write (an undeclared key, a root or member the gate does not bind, a read through a reference, a computed key or receiver, `current_user.can()` with no permission data), the server logged "the option's gate was NOT enforced on this write" and admitted the value. A role-gated option whose rule was broken was open to every caller. + - **Now.** The write is refused with `VALIDATION_FAILED` (HTTP 400) and one field entry in the envelope a faulting `requiredWhen` or `readonlyWhen` already uses: `code: 'rule_violation'`, `constraint.reason: 'unevaluable'`, `constraint.rule: 'visibleWhen'`, `constraint.fault` naming the fault, and the picked option as `value`. The message names the option, the field and the fault, for example `Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected.` Nothing is written. This holds on insert (one row or a batch), by-id update and bulk update, and so on an import, which writes through them; `validate()` previews the same refusal. + - **Unchanged.** A predicate that evaluates is judged exactly as before: false refuses with `invalid_option`, true admits. A system write with no acting user whose predicate reads the acting user (`'admin' in current_user.positions` on a seed) is still admitted and logged with reason `no-acting-user`, because the gate has nobody to ask about. A system write whose predicate faults for any other reason is refused, as a faulting `requiredWhen` is. The form still offers an option whose predicate faults (ADR-0137 D3); the refusal comes at save. + - **`current_user.can()` with no permission data.** On an engine with no effective-permission resolver registered (no security plugin composed), a write by an acting user that picks a `can`-gated option is now refused, naming the missing permission data, instead of admitted. + - **A preview limit this inherits.** An `update`-mode `validate()` preview (an import dry run of a matched row) reads no stored row, so a cascade predicate whose parent column the patch omits faults there and the preview now refuses the row, as it already refuses a `requiredWhen` that reads an omitted column. The write itself reads the stored row and judges the predicate cleanly. + + **The remedy: fix the predicate, which `os build` names for every statically judgeable shape.** The shapes no build can judge (a computed key, a computed receiver, a row stored before the build verdicts, or a save under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`) now meet this refusal at the first write that picks the option, naming the fault. + + Measured before the change, on `origin/main` 3054516ef1: the objects this repository ships carry 5 option predicates, all on the showcase's `showcase_cascade` (four `record.country == …` cascades and one `'org_admin' in current_user.positions` role gate), and none of them faults on a write. Insert and update evaluate them over a total record, so a payload without `country` reads `null == 'cn'`, a clean false. No seed or dogfood write picks a gated option. + + Earlier entries in this release that say an option predicate which faults is still logged and admitted describe the gate before this change. + + `@objectstack/lint` and `@objectstack/spec`: the published text that said the server admits a faulting option predicate now says it refuses the write. This covers the `objectstack validate` finding for an option predicate that reads through a reference, a root or member the gate does not bind, and the `SelectOption.visibleWhen` description. No verdict moves. +- 3073b72: A `{{ }}` hole in a flow text slot (a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`) whose root is a `$` name the flow engine does not bind is refused. It is refused at the same doors, and by the same judge (`textSlotTemplateRefusal`), as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. `'By {{ $User.Id }}'` used to pass all three and send `'By '`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The hole grammar admits `$` in a name so that the engine's own variables have a spelling (`{{ $error.message }}`). That also makes `{{ $User.Id }}` a well-formed hole, but over a root no flow variable answers to. The text went out with the fragment missing, the run reported success, and nothing warned. Its single-brace spelling, `{$User.Id}`, was already refused with a remedy. The `$` names are reserved for the engine: a resume signal may not write one. + + **What is refused.** + + - A hole whose root is a `$` name other than the variables the engine binds: `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`. + - `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` raise a `custom` issue at the slot's key. + - `registerFlow` refuses the flow, and a stored flow carrying such a hole is skipped at boot with a warn naming it. + - `objectstack validate` reports `expression-invalid` at `error`. + - The remedy for `{{ $User. }}` is the sentence `{$User.}` gets: for `$User.Id`, compute the run user's id into a variable with an `assignment` node's CEL value envelope (`current_user.id`), then write the variable as a hole; any other `$User` path never resolved, and the refusal says how to read the user record by that id. Any other root is named in the refusal, beside the variables the engine does bind. + - A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy. + + **Unchanged.** `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}` and every hole over an engine-bound `$` variable. The template engine binds no new variable. + + **`@objectstack/lint`.** In a text slot, `flow-bare-dollar-reference` prescribes the hole for a bare `$X.y` written outside the holes only when the judge admits that hole. A bare `$User.Id` gets the judge's refusal and remedy instead of a `{{ $User.Id }}` the judge refuses. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `message: 'By {{ $User.Id }}'` | an `assignment` node first, `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `message: 'By {{ by }}'` | + | `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` | + + **The one-line fix: compute a run-user value into a variable first, and name a variable the flow binds itself without the `$`.** + + **Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), has no text-slot judge. Its `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain strings, so it accepts `'By {{ $User.Id }}'` in every one of these slots. Its single-brace interpolator substituted the inner `{ $User.Id }` token and left a literal brace on each side. This repository was measured with `git grep` over `examples`, `packages`, `skills`, `apps` and `content`: no flow text slot outside tests carries a `{{ $… }}` hole other than `{{ $error.… }}`. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `textSlotTemplateRefusal` in `automation/flow-text-slot-template.ts` reads one package-internal list of the `$` variables the engine binds. `@objectstack/service-automation`'s `text-slot-template.test.ts` scans that package's sources for every `$` variable they bind by name, and fails when the list misses one. + - **The ledger.** The D3 semantic entry `flow-text-slot-unbound-dollar-root-refused` (protocol 18). There is no D2 conversion: what the hole was meant to read is not in the flow. +- c76edeb: A declared read attachment resolves only where `record` is a row a service serves, so `os validate` refuses a flow condition that reads one + + Clause-②: no + + `ObjectSchema.attachedOnRead` declares the blocks a service attaches to the rows it serves, computed per caller and never stored. An earlier entry in this release adds each declared block to the names `record.` resolves to, at every expression site bound to the object. That covered the sites whose `record` is the stored row, which never carries a block. There `os build`, `os validate` and the object save door accepted a read such as `record.viewer.can_act`, and the expression then faulted with `No such key: viewer` on every row. A record-change flow on `sys_approval_request` whose start condition read `record.viewer.can_act == true` validated, then failed on every record it fired for. + + A declared block now resolves, and its leaves are judged, only at the sites whose `record` is a served row: an action's `visible` and `disabled` predicates. + + - **Refused now.** On an object that declares a block, `record.BLOCK` at any other site gets the refusal it gets on an object that declares none: ``unknown field `viewer` on `sys_approval_request` ``, at `error`. Those sites are a flow's node and edge conditions, a validation rule, a field's `requiredWhen`, `readonlyWhen` and `visibleWhen`, an option's `visibleWhen`, a field formula, a sharing-rule condition and a hook condition. The block is no longer offered as a "did you mean?" candidate there either. The object save door runs the same rule over an object's own slots, so an object write in publish mode that reads a block outside an action predicate is refused with an `expression-invalid` issue. + - **Unchanged.** At an action predicate a declared leaf is accepted, and a misspelt leaf is refused with a message that names the leaves the block declares. `sys_approval_request`'s eight action predicates pass. No refusal code is added, and no export or signature moves. + - **`@objectstack/spec`.** The description of `ObjectSchema.attachedOnRead`, which the JSON schema and the reference pages carry, now says where the validator reads the key: in an action's `visible` and `disabled` predicates `record.` resolves and its leaves are judged, and every other expression site binds the stored row and refuses `record.` as an unknown field. The schema and its parse verdicts are unchanged. + - **Reach.** The only shipped object that declares a block is `sys_approval_request`, and its action predicates are the only shipped expressions that read it. The earlier entry has not been released, so no released version accepted a block read at these sites. +- 9f15939: `os lint`'s `approval-approvers-may-resolve-empty` hint no longer tells an author that the admin bulk import does not write `sys_user.manager_id`; it names the import's `manager_id` column as a working route, beside Set Manager and the admin endpoint + + Clause-②: no + + On a node whose approvers are all `{ type: 'manager' }`, the hint listed the routes that populate `sys_user.manager_id` and said of one of them: "its admin bulk import does not write it either". That has been false since the import admitted the column. `POST /api/v1/auth/admin/import-users` reads a `manager_id` column whose cell holds the manager's email address, or phone number where phone sign-in is enabled. The manager can be someone in the same file or an existing user. The import links each row once every row in the file exists, through the same derivation as `POST /api/v1/auth/admin/set-user-manager`, and reports each outcome on the row's `manager` result. A link it cannot make never fails the user it imported. The old clause sent an author with a whole file of reports away from that route. + + The hint now names the bulk import's `manager_id` column among the routes that work on this platform, ahead of the clause that hands SCIM and directory sync to the deployment, and names Set Manager (Setup → Users) beside the endpoint it posts to. Nothing else about the rule moves: it fires on the same shape, at `info`, and a seeded manager chain remains the one silencer. +- 69d4218: feat(plugin-approvals): an approval step whose approvers resolve to no one opens on the organization's administrators under the default `onEmptyApprovers: 'admin_rescue'`, as ordinary approvers in their inbox + + Clause-②: yes + + **FROM → TO: who receives an `admin_rescue` request.** A node whose approvers resolve to no concrete person (an unstaffed position, an empty team or department, an unset manager, an expression that yields no one) under the default `onEmptyApprovers: 'admin_rescue'`: + + - **FROM:** the request opened on a slate that named no one, such as the literal `position:sales_manager`. It was in no one's "My Pending", the bell counted 0, no one was told, and a `lockRecord: true` record stayed locked until a platform or tenant admin found it under **All** and decided it through the privileged override. + - **TO:** the request opens on the request organization's administrators: the people who hold the organization-admin capability (`organization_admin` or `organization_admin_no_bypass`, ADR-0095 D3) in that organization. That is the same authority that lets a tenant admin override a request, resolved to the people who hold it, through the same grant resolution every request runs. An organization's owners and admins receive the capability automatically. + + They are ordinary approvers. The request is listed and counted for each of them under their own `approverId`, each is told once by `approval.requested`, out-of-office delegation applies to them, and a decision by one of them is an ordinary approval or rejection, recorded with `via_override: false`. The node's `behavior` reads them as one group. A platform administrator is not added for being one; one who also holds the organization's capability, such as its owner, is on the slate for that. + + The request says why. Its timeline carries a note, for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder.", and its node-config snapshot keeps the abandoned slate and the reason under `__resolvedFrom` (`admin_rescue:abandoned`, `admin_rescue:reason`). The abandoned slate is replaced, not kept beside the administrators, so a holder staffed into the position after the request opened does not receive it; an administrator can reassign it to them. + + **Unchanged.** A slate that names a person; `fallback`, `auto_approve` and `fail`; a `fallback` whose own list resolves to no one still degrades to `admin_rescue`, which now opens on the administrators. The privileged override stays for what the administrators' slate cannot reach: a request whose named approvers have all left after it opened, and an organization with no administrator (or a request with no organization), where the request still opens on the slate that names no one and the server logs a warning. + + **To name the person who should decide instead of the administrators,** declare `onEmptyApprovers: 'fallback'` with a `fallbackApprovers` list. + + **`@objectstack/spec`:** the `onEmptyApprovers` description says that `admin_rescue` opens on the organization's administrators and that `fallback` names who decides instead. + + **`@objectstack/lint`:** `approval-approvers-may-resolve-empty` no longer says the request "waits forever": it says the request opens on the organization's administrators, its remedy names `onEmptyApprovers: 'fallback'` as the way to name who decides, and its `os explain` text says the same. +- eed637c: docs(automation): every text that teaches a sender of an inbound `api` hook teaches the timestamped `x-objectstack-signature` form only — `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock + + Clause-②: no + + The inbound hook door has accepted the timestamped form since `signHttpBodyAt` landed in `@objectstack/core`, and it is the only form that stops a captured post from verifying forever; the published teaching still showed the body-only `sha256=` form. The guidance converges first; the door's refusal of the body-only form is a later change of its own. + + - **`@objectstack/skills`.** The `objectstack-automation` skill's Inbound webhook section teaches `t=,v1=`: `v1` is the HMAC-SHA256 of `.` under the start node's `secret`, and the door refuses a `t` more than 300 seconds from its clock. The body-only form is no longer shown. + - **`@objectstack/lint`.** The hint of `flow-api-trigger-secret-missing` and its `os explain` entry describe the same form. The rule's id, severity, verdict sentence and judgement are unchanged. + - The showcase example's comment and the platform checklist's intake recipe sign the same way. + + **Nothing to migrate for an author.** A sender that already signs with `signHttpBodyAt` is unchanged. **The outbound contract stays as published:** the messaging outbox and the flow `http` node keep signing body-only (`signHttpBody`) for third-party receivers, and the inbound door's accept set is not changed by this release. +- eb20c33: fix(cli): the nine rule ids the CLI produces print one verdict sentence each, `os explain ` carries their reasoning, and the undeclared-handlers boot line is one line + + Clause-②: no + + - **One verdict sentence of at most 200 characters.** Measured on every finding the CLI's suites fire (message length; before → after): + - `os lint`'s hook-body rules: `hook-body/not-lowerable` 565–661 → 165–178, `hook-body/bundled-fallback` 324–947 → 109–171, `hook-body/unparseable` 469 → 157, `hook-body/extraction-failed` 331 → 140. Over every refusal the extractor can throw (14 forbidden constructs and 7 parameter refusals), `hook-body/bundled-fallback` runs 296–1,011 → 113–199: the verdict quotes the extractor's own verdict clause, cut to fit and marked `…` when cut. + - package docs (`os build`, `os validate`, `os lint`): `docs/uncollected-directory` 433–558 → 124–183, `docs/nav-target` 263–299 → 147–184, `docs/frontmatter-tags` 233–285 → 135–195, `docs/duplicate-name` 330 → 167 (its within-package arm is unchanged at 101). A doc finding no longer repeats its path, which every printer already leads with; lists of files, packages and directories name the first three and count the rest. + - `picklist-reference-unverified` (the `os validate` / `os build` notice): 240–245 → 173–175, naming at most two outside dependencies and counting the rest. + - **A `fix:` line for the hook-body rules.** Their remedy moves out of the message into the finding's `fix`, at most 200 characters: `os lint` prints it as `→ fix:` under `--fix`, and `os lint --json` now carries `fix` on these four ids' issues. The remedy is still chosen per case: inline a module-scope name, or keep a check on a host-only global such as `Intl` in a string handler ref or a validation rule. A doc finding carries no separate fix, so its sentence ends with the remedy; the picklist notice's `hint` is unchanged. + - **`os explain ` carries the rest.** Each of the nine ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, the one table `os explain` reads, and `os lint`'s `rule:` line now points at it. They hold what the messages stopped saying: why a bundled handler is a change of deployment shape and how to bundle on purpose, what the sandbox cannot run and where `os build` prints the full refusal, why an instrument limit is not a verdict, where package docs are read from and why the collector never guesses, why a doc name may appear once, what a doc navigation item can open, how doc tags are read, and why a reference into a dependency outside the stack is not judged. + - **The `[action-governance]` boot line for registered handlers with no declaration** (`os dev`, `os serve`, and any host that boots ObjectQL) is one line of 192 characters, where it was 628: it names the three sources it read, states the 404 as expected rather than measured, and says to declare each with `defineAction`. Its structured `count` / `handlers` tail is unchanged, and the dead-button line beside it is unchanged. + - **Unchanged:** every rule id, severity and `path`, the boot lines' level and payload, and what each check accepts or refuses. A tool that matched on this message text should match on `rule` and `path` instead. +- de70e97: Retirement prescriptions name `os migrate meta --write`: "… to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." + + Clause-②: no + + Wording only: no schema, key, type, export or error code changes, and every input that parsed or was refused before gets the same verdict at the same path. Only the closing sentence of the message changes. + + - A prescription covered by an ADR-0087 conversion used to close with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand." It now closes with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." The default run still only lists. `os migrate meta --write` rewrites in place each edit it can trace to one literal in one project file and lists every other edit with the reason it was not written. The sentence never says the tool rewrites your sources automatically, because `--write` does not write every edit. Text up to and including "for existing sources;" is unchanged, so a caller matching that prefix still matches. + - The three prescriptions for conversions that cover only part of a value (dashboard `compareTo.offset`, the script flow node's `config.actionType` and the package manifest `permissions` case) carry the same `--write` clause before their second clause. + - The `record:chatter` / `record:discussion` `position` value prescriptions (`'sidebar'`, `'inline'`, `'drawer'`) used to tell the author to run a bare `os migrate meta`, which the command refuses because `--from` is missing. They now name `os migrate meta --from 17` and close with the same sentence. + - `@objectstack/lint`: the script node's retired dispatch-key diagnostic and the dataset widget's `chartConfig.xAxis.field` hint close with the new sentence. + - `@objectstack/driver-turso`: the `timeout`, `localPath` and `wasm` config tombstones close with the new sentence. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [345d3f3] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + - @objectstack/sdui-parser@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index d770436a88c..1ed66cee1b5 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Static, build-time validation for an ObjectStack metadata graph — dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.", "type": "module", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 5d90b9934ab..e8967b44be9 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,251 @@ # @objectstack/plugin-mcp-server +## 18.0.0-next.0 + +### Minor Changes + +- c4e7fa5: fix(mcp)!: on the stdio transport, a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22777) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + + **FROM.** On the MCP stdio transport (`OS_MCP_STDIO_API_KEY`), the `query_records` and `aggregate_records` tools judged only the object they address against its `enable` block, so such a condition in `where` was evaluated whatever the related object's `enable` block declared — including a related object those same tools refuse when it is addressed. + + **TO.** The stdio data bridge asks each condition's related object the same exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is asked. A related object the gate does not serve refuses the call with the bridge's existing exposure refusal, the one an addressed object gets: + + - `enable.apiEnabled: false` → `OBJECT_API_DISABLED` (404). + - an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `OBJECT_API_METHOD_NOT_ALLOWED` (405), with the effective operation set. + + The tool's error text names the related object and the condition's position (`where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. The generic data door has given the same answer for the same condition since #22737. + + **Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + + **Bindings.** + + - **Refused, not withheld.** Dropping a condition would widen the result. + - **One decision, one walk.** The gate is the one the bridge already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door uses. + - **The engine is untouched.** A flow's or a hook's own query calls the engine directly and never passes this bridge. + - **No new error code.** + +### Patch Changes + +- 4e9fe9f: feat(spec)!: `PROTOCOL_VERSION` is `18.0.0`; the runtime handshake accepts `engines.protocol: '^18'` and refuses `'^17'` + + Clause-②: yes (narrowing: the handshake refuses a manifest pinned to protocol 17 (`'^17'`, `'^17.x.y'`, `'17.x'`), while the exported `PROTOCOL_VERSION` moves to `'18.0.0'` and a `'^18'` manifest is admitted) + + + + **BREAKING**, graded `major`: Changesets is in pre mode (`next`) for the v18 line, so this ships in an `18.0.0-next.N` prerelease. The protocol major moves in this reviewed change, not in the version pass (ruling record 6049734955, Q1 → B). Until that line's first version pass, `PROTOCOL_VERSION` is one major ahead of the package version; `check-changeset-no-major.mjs` admits that only in pre mode with a pending `major` for `@objectstack/spec` (ruling record 6056808625). + + - **`PROTOCOL_VERSION` reads `'18.0.0'`** and `PROTOCOL_MAJOR` reads `18`. The ADR-0087 D1 handshake on every load and install door (the app load seam, `POST /api/v1/packages`, `POST /api/v1/marketplace/install-local`) now admits a package whose `engines.protocol` admits major 18, and refuses one pinned to 17 (`'^17'`, `'^17.x.y'`, `'17.x'`) with `OS_PROTOCOL_INCOMPATIBLE` (422), naming `objectstack migrate meta --from 17`. + - **What you change.** Run `objectstack migrate meta --from 17` to list the edits for existing sources; `--write` applies the ones it can prove, including the declared `engines.protocol` range, rewritten in place from `'^17'` to `'^18'`, and you apply the rest by hand. + - **`spec-changes.json` and the protocol upgrade guide** now carry the protocol 17 → 18 record, generated from the registries: 64 mechanical conversions and 329 semantic entries. The aggregate record spans 16 → 18. + - **`os init`** stamps `'^18'` in the manifests it writes, and `os lint`'s `protocol/missing-engines-range` fix names `'^18'`. The `create-objectstack` template keeps `'^17'` until the version pass stamps it together with its `@objectstack/*` dependency ranges. + - **`@objectstack/mcp`**: the README's `defineStack` examples declare `'^18'`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 838fc7bb294..03c6496b01c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "ObjectStack as an MCP server — exposes your app's objects (and AI tools) over the Model Context Protocol (stdio + Streamable HTTP)", "type": "module", diff --git a/packages/metadata-core/CHANGELOG.md b/packages/metadata-core/CHANGELOG.md index dc9b432115f..fbf7018f3ce 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,447 @@ # @objectstack/metadata-core +## 18.0.0-next.0 + +### Minor Changes + +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- f4069c5: fix(metadata): the object read's `sortability` follows the caller's field permission for every caller class + + Clause-②: yes (widening) + + - **What was wrong.** ADR-0106 D4 serves a caller holding `manage_metadata`, `studio.access` or `setup.access` (a platform admin, and an organization admin through `setup.access`) the object schema UNMASKED, because Studio and Setup authoring need the whole definition. The `sortability` projection served beside it on `GET /meta/object/:name` (and the runtime dispatcher's `/metadata/object/:name`) was derived from that unmasked document, so it marked a field `sortable: true` that the same caller's field permission marks unreadable, while the data route refuses a sort or filter on that field with `403 PERMISSION_DENIED`. A client building its sort picker from the projection offered a click the server refuses. + - **What changes.** `sortability` is now derived from the caller's runtime view: the served document for every caller the mask projects (unchanged), and for a D4-exempt caller the fields their own field permission reads. The definition (`item.fields`) is unchanged for every class: whole for an exempt caller, masked for everyone else. The data route and `/auth/me/permissions` are unchanged. + - **Caching.** On the cached `GET /meta/object/:name` branch an exempt caller's ETag folds a fingerprint of the fields withheld from their projection, and the conditional request is judged after it is folded. An exempt caller whose permission withholds nothing keeps the byte-identical ETag. An exempt caller and a projected caller denied the same field never share a validator. + - **Failure posture.** The exempt caller's field permission is asked lazily, only by the item read that serves `sortability`, and never turns their definition read into a fault: if the security service throws or cannot answer, `sortability` is derived from the served document as before, with one `warn`. + - **New in `@objectstack/metadata-core`.** `resolveObjectSchemaRuntimeView(document, posture)` returns `{ document, withheld, fingerprint }`, the view the runtime projections beside an object schema are derived from. The types `ObjectSchemaRuntimeView` and `ObjectSchemaRuntimeFieldPosture` are exported with it, and the `passthrough` member of `ObjectSchemaMaskPosture` gains an optional `runtime` (set on an `exempt` posture for a capability-exempt caller in a deployment with a `security` service). + - **New in `@objectstack/rest`.** `translateMetaEnvelope` takes an optional fifth argument, `runtimeDocument` (default: the served document), and `MetaItemAnswerSources.translateEnvelope` an optional third. Nothing is removed, renamed or narrowed. +- 8532783: feat(service-datasource,metadata-core)!: the datasource-admin plugin reads the environment's stored `datasource` rows only; a legacy organization-scoped row is neither loaded at boot nor served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** Graded `minor` on the v18 prerelease line, as the read narrowing it completes. + + **What changes.** `DatasourceAdminServicePlugin` reads stored `datasource` rows in five places — the boot restore (`restoreRuntimeDatasources`), the by-name read behind cluster convergence and the `/meta` door's datasource projection, and the existence probes behind its own create, update and remove. Each now reads the environment's rows only, judged by the same row-value test the metadata reads apply. A row stored organization-scoped by an earlier release: + + - is no longer registered at boot, so the datasource-admin door and the metadata read door no longer serve it, and no live pool is opened from it; + - is never updated or deleted by an admin create, update or remove under its name — those write and remove an environment row of their own; + - stays at rest, and the boot report `[metadata_org_scoped_unserved]` keeps naming it. Nothing is deleted or rewritten; the v18 migration ceremony (`os migrate`, ADR-0131 D10) carries it. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | a legacy organization-scoped runtime datasource a deployment relied on | re-create it through the datasource-admin door or save it through the `/meta` door to make it environment-wide now, or wait for the v18 migration ceremony | + + `@objectstack/metadata-core` exports `isLegacyOrganizationRow`, the one row-value test for such a row; `@objectstack/objectql`'s authored hook and action binders now read it from there, with no change in what they bind. +- 58f5c90: fix(metadata-core): an artifact declaring `engines.protocol: '^18'` that was built during the v18 prerelease boots on a later runtime that retired one of its shapes — the artifact door replays every retired conversion of protocol 18's step for a floor of `18.0.0` + + Clause-②: yes + + **What an operator saw.** A compiled artifact (`os build` → `dist/objectstack.json`) that declares `engines.protocol: '^18'` and was built before a protocol-18 retirement landed carries the retired shape, for example a defaulted key that every earlier parse emitted. Booted on a runtime that enforces the retirement, the metadata plugin's startup refused it at the strict parse and the boot rolled back. The artifact door compared the floor `18.0.0` with each retirement's `retiredAfter` stamp, and every retirement that lands during the v18 prerelease is stamped with the last 17.x release, below that floor, so nothing was replayed. + + **What an operator sees now.** The same artifact boots. One warning per conversion names it and its site count: `[MetadataPlugin] artifact 'LABEL' declares the first release of protocol 18 (authored engines.protocol floor 18.0.0), which a build from any 18.0.0 prerelease declares too, so it may predate retirements in the conversion step into protocol 18 that this runtime enforces (runtime spec VERSION) — converted N site(s) forward via ADR-0087 conversion 'ID' …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. An artifact built after the retirement boots exactly as before: the replay finds nothing to convert, and nothing is logged. + + - **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` opens its window on a third condition. A declared floor of `M.0.0` at or above the runtime label replays every retired entry whose `toMajor` is `M`, except the default flips the door already refuses. An artifact cannot say which of those entries it predates: it carries no field naming the spec or CLI that built it. Each entry rewrites only its own old shape, so the replay changes nothing in an artifact that lacks that shape. `ArtifactForwardConversionVerdict` gains `'converted-major-step'`, so a consumer that switches exhaustively over the verdict adds that arm. `replayedRetirements` still lists only the entries the per-entry rule opened. The rule holds for every 18.0.0 prerelease label and at 18.0.0 itself. From 18.0.1 on, the label half replays the whole chain for such an artifact, as before. + - **`@objectstack/metadata`.** The artifact door places the new verdict on the open side of its window table and gives it its own clause, so the summary never claims that a `^18` artifact "predates" a runtime labelled below it. The bare-root form-predicate notice uses the same window. + + **Unchanged.** The protocol handshake still refuses a `^17` artifact on a protocol-18 runtime and prescribes `objectstack migrate meta --from 17`. A floor above `M.0.0` (such as `^18.0.1`) and a 17.x floor keep their previous verdicts. + +### Patch Changes + +- 56c8844: `@objectstack/spec/ui` now exports the rule that decides which forms a `view` body opens to anonymous intake, so a console reads "published" from the same rule the server's anonymous form doors serve + + Clause-②: yes (widening) + + - **New on `@objectstack/spec/ui`:** `publicFormSlug`, `anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`, `anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type. They lived only in `@objectstack/metadata-core`, which a browser console should not depend on. They are pure functions with no imports, beside the `SharingConfigSchema` they read. + - **What they decide is unchanged.** A form is open when its `sharing` has `enabled === true`, `allowAnonymous === true` and a non-empty `publicLink`. The scan covers the same three shapes in the same order: the nested `form`, every `formViews` entry, then the `config` of a `viewKind: 'form'` item. + - **`@objectstack/metadata-core` re-exports the same functions** from `@objectstack/spec/ui`. They are the spec's own bindings, not wrappers or copies, so there is still one copy of the rule. Its exports, names and types are unchanged, and `@objectstack/rest` and `@objectstack/metadata-protocol` keep importing from it. Its built output now loads `@objectstack/spec/ui` to get them. + - **Not covered by the new export:** whether another metadata layer withdraws a form (`anonymousFormIntakeWithdrawnIn`), and whether the deployment's tenancy posture lets the form take an anonymous submission (`anonymousFormIntakeUnavailability`). These two read server state and stay in `@objectstack/metadata-core`. `anonymousFormObjectName`, which names the object a form submits into, stays there beside them; it is a pure read of the form and the view, not of server state. A form the new functions call open can still be withheld by a withdrawal in another layer or by the posture. +- 3d886ee: A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows + + Clause-②: yes (widening) + + - **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. + - **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. + - **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). + - **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). + - **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). + - **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. +- 9af0005: The shared build validator (`@objectstack/lint`'s expression rule over `@objectstack/formula`) judges `record..` against an object's declared read attachments (`ObjectSchema.attachedOnRead`) + + - **`@objectstack/lint`.** The field index the expression rule builds now adds each block an object declares under `attachedOnRead` to the names `record.` resolves to, and hands the shared validator the block's declared leaves. A predicate such as `record.viewer.can_act` on an object that declares the `viewer` block is accepted where it was refused as an unknown field. + - **`@objectstack/formula`.** `ExprSchemaHint` gains an optional `attachedOnRead` (block name → declared leaf keys). When a `record.` or `previous.` reference names a declared block, its next segment must be a leaf the block declares. Anything else is refused with the existing `unknown-field` code: `field` is the dotted path as written (`viewer.can_actt`), `suggestion` the nearest declared leaf (`viewer.can_act`), and the new optional `block` and `leaves` params carry the leaves the block declares, which the message names. No new refusal code. + - **`@objectstack/metadata-core`.** The ADR-0106 field-level-security masker classifies the new top-level key as passed through unchanged (`OBJECT_REFERENCE_POSITIONS.attachedOnRead`): a block name and its leaf keys never name a field, so no caller's served object definition loses or gains anything. + - **Unchanged.** An object that declares no block keeps every verdict it had, and a second segment off any other field is not judged. Index access, a method call on a block and a third segment stay unjudged. +- e5687cb: An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` + + Clause-②: yes (widening) + + - **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. + - **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. + - **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. + - **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. + - **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. + - **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES. +- 5677add: fix(metadata-protocol): two locales of one email template saved through the metadata door both survive a restart — an overlay row is keyed by its bundle member (#22877) + + Clause-②: no + + **What an administrator sees.** Saving the same email template in two languages through the metadata door (`PUT /api/v1/meta/email_template/:name`, the Studio editor's save) keeps both. Before, the store kept one overlay row per template name, so the second language's save replaced the first language's row: both read back until the next restart, and then the first language mailed the package wording again. Each language now keeps its own wording across restarts, and each send in that language uses it. + + **What changed in the store.** + + - `sys_metadata` gains one nullable column, `bundle_member`. It holds the member of a metadata type whose identity is a pair — today `email_template`, identified by `(name, locale)`. NULL is the canonical member (`en-US`, the locale a send falls back to). Every other row stays NULL, so rows of every other type, and an `en-US` template row, are byte-identical to before. + - The value is taken from the saved body's `locale`; it is never authored. A body that declares no `locale` is the `en-US` member. + - The overlay uniqueness index becomes `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))`, for active rows and for draft rows, and the declared index in `metadata-core` carries the column too. A second row for the same template, language, package and state is refused by the index. + + **What a read or a door addresses.** A save addresses the language its body names. A read, delete, publish, history or revert by name that names no language addresses the `en-US` member: `GET /meta/email_template/:name` serves the `en-US` wording (the stored one when it was customized, else the package's), where before it served whichever row the name had. The layered list and the send path serve every language as before. A non-`en-US` language's own customization is reached through the list, and is replaced by a save that names its `locale`; a by-name reset (`DELETE`) removes the `en-US` customization only. The lineage counter stays one per template name, so a version number names one saved snapshot, and a revert by name restores only a version of the `en-US` member. Resetting, publishing or reading a non-`en-US` language by name needs the by-name member selector, which arrives with stage 2 of #22877; until then those doors address the `en-US` member. + + **What happens on the first boot after upgrading.** Nothing to configure. + + 1. The schema sync adds the `bundle_member` column. + 2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written. + 3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`. + + **On a database without partial indexes (MySQL).** The rebuild above is refused there, as before, and reported in the server log at the first metadata write. A store that booted an earlier version keeps the four-column declared index under `idx_sys_metadata_overlay_active`, because the schema sync skips an index whose name already exists. That index has never constrained an environment-wide overlay row: those rows store no organization, and a UNIQUE key does not collide NULLs. So two languages of one template coexist there too, and no save is refused. `os migrate plan` lists the declaration change on that index as a destructive `recreate_index`. To bring the index to the five-column declaration, run `os migrate apply --allow-destructive`. A fresh store gets the five-column declaration. + + `os migrate duplicates` boots read-only and adds no column. Run against a store this version has not booted yet, it lists the two overlay indexes with every row read as the empty member — the value the column will hold — so a duplicate that will block the rebuild is still reported, and the entry's `duplicateSql` is the member-less statement that ran there. +- c5b43b6: fix(metadata-core, runtime): a multi-package artifact built before a retirement boots at `os dev -a` and `os start`. The ADR-0087 forward conversion walks every `packages[].manifest` body, and the runtime's artifact load converts the bundle once, before any reader sees it + + Clause-②: no + + **What an operator saw.** A multi-package release artifact (ADR-0130 D4: `os build` of a `composeStacks(…, { manifest: 'preserve' })` project) carries its collections in `packages[i].manifest`. Take such an artifact built before a retirement, for example with a dashboard still spelled `refreshInterval`. `os dev -a dist/objectstack.json` and `os start` exited at boot with "`packages[1]` is not a package entry (ADR-0130 D4): manifest.dashboards.0.refreshInterval: `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds`…". The metadata plugin's artifact door refused the same artifact at its strict parse. The ADR-0087 conversion converted that dashboard when it sat in the artifact's own `dashboards`, but it never read a package body, and the runtime read the bundle raw before any conversion ran. + + **What an operator sees now.** The same artifact boots at `os dev -a` and at `os start`. Each body's items are served converted (`refreshIntervalSeconds: 300`, valid), each owned by its own package. The boot log names each conversion once, with the body it found it in: `[MetadataPlugin] artifact '…' … converted 2 site(s) forward via ADR-0087 conversion 'dashboard-refresh-interval-to-refresh-interval-seconds' (first at packages[0].manifest.dashboards[0].refreshIntervalSeconds) …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. A body built after the retirement converts nothing and logs nothing. An artifact authored past every retirement is still refused with the tombstone inside its body. + + - **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` runs the same conversion over each package body as over the artifact's own collections. It uses the same registry and the same window, decided once from the artifact's own `engines.protocol`; a body's own `engines.protocol` is not read. A notice from a body is located from the artifact root (`packages[1].manifest.…`). + - **`@objectstack/runtime`.** `loadArtifactBundle` converts the loaded artifact once, before it merges the runtime module and before the stack, `AppPlugin` or the `manifest` service read it. The database resolver reads the artifact file itself, and it runs the same conversion on what it reads. So a multi-package artifact that declares its default datasource in a package body opens that database, not the unified default. + + **Unchanged.** An artifact without `packages[]` converts exactly as before, with the same notices, paths and messages, and it is served the same way. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index 3fd62a36809..c9f73858497 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Metadata Repository contracts: types, canonicalization, errors, interface (ADR-0008).", "type": "module", diff --git a/packages/metadata-fs/CHANGELOG.md b/packages/metadata-fs/CHANGELOG.md index f7f3a34ce25..bcf30d823cf 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,34 @@ # @objectstack/metadata-fs +## 18.0.0-next.0 + +### Patch Changes + +- a3d47c2: fix(metadata-fs, runtime): metadata writes record the acting user's id or nobody, never the label `'fs'`, and a signed-in save through the runtime dispatcher records the user + + Clause-②: no + + - **What was wrong (filesystem repository).** `FileSystemRepository` named the label `'fs'` as the writer whenever no user was given. That covered every edit it saw on disk, every file it found that no recorded write produced, and an actor-less `put()`, which read back as `'fs'`. `MetadataPlugin` attaches the repository without naming a user, so this happened on every host that boots it. The contract for these fields (`PutOptions.actor`, `MetadataItem.authoredBy`, `MetadataEvent.actor`) is the acting user's id, or `null` for "nobody". + - **What it does now.** With no user named, the repository records `null`. A write that names a user records that user, and a write that records `null` reads back as `null`. The `fsActor` option keeps its type. When it is set, it is the user id reported for changes the repository did not write itself, and it no longer replaces a writer that a write recorded. Every event still names its origin on `source: 'fs'`, as before. + - **What was wrong (runtime dispatcher).** `PUT /api/v1/meta/:type/:name` served by the runtime dispatcher (the `@objectstack/hono` catch-all) saved without the caller's identity, so a signed-in author's save recorded `null` in `sys_metadata.updated_by` / `created_by` and `sys_metadata_history.recorded_by`. `RestServer`'s route recorded the user. + - **What an operator sees.** Filesystem-backed metadata history and events from this release on no longer name `'fs'` as the writer. A save through the runtime dispatcher records the signed-in user's id, as the REST route does, and a system caller with no user records `null`. Entries written before this release keep what they recorded. +- Updated dependencies [06cc45d] +- Updated dependencies [7895671] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [56c8844] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [9af0005] +- Updated dependencies [8532783] +- Updated dependencies [e5687cb] +- Updated dependencies [5677add] +- Updated dependencies [58f5c90] +- Updated dependencies [c5b43b6] +- Updated dependencies [8c10776] +- Updated dependencies [ef1fcb2] + - @objectstack/metadata-core@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index 499230b2d98..7463a113b62 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "FileSystemRepository: Node-only Repository implementation backed by JSON files and a JSONL change log (ADR-0008).", "type": "module", diff --git a/packages/metadata-protocol/CHANGELOG.md b/packages/metadata-protocol/CHANGELOG.md index 55f80584238..fbfe76e57cc 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,1350 @@ # @objectstack/metadata-protocol +## 18.0.0-next.0 + +### Major Changes + +- e23eccb: feat(spec,metadata-protocol)!: the metadata type registry's `allowOrgOverride` is renamed to `allowEnvironmentOverlay`, on the registry and on `GET /meta/types` + + Clause-②: yes (narrowing) + + + + **BREAKING**: a renamed authorable key and a renamed response field, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0131 D6 retired the per-organization overlay axis: every metadata write and read is environment-wide. The registry flag kept the name that said "per-org" while it decided one environment-scope question at every door — whether an environment may overlay an item of the type that a managed package ships: the metadata protocol's save door, the managed-content seal on write and on the served read, the repository's put gate, and Studio's write gate through `GET /meta/types`. It is renamed to say that. One key, not two, and not the old name with a new meaning; no window in which both spellings are accepted or served. + + ## FROM → TO + + | where | you wrote / read | write / read instead | + |:--|:--|:--| + | a `DEFAULT_METADATA_TYPE_REGISTRY` entry (`MetadataTypeRegistryEntry`, `@objectstack/spec/kernel`) | `allowOrgOverride: true` | `allowEnvironmentOverlay: true` | + | a reader of a registry entry | `entry.allowOrgOverride` | `entry.allowEnvironmentOverlay` | + | each `entries[]` item of `GET /api/v1/meta/types` (`GetMetaTypesResponse`) | `allowOrgOverride` | `allowEnvironmentOverlay` | + + **The one-line fix: rename the key, and every read of it; the value is unchanged.** + + Every value is unchanged: `true` on `view`, `dashboard`, `report`, `translation` and `email_template`, `false` on every other built-in type. On the wire the value is still the registry's grant OR the `OS_METADATA_WRITABLE` hatch naming the type (`overrideSource: 'env'`), and the hatch still opens only items no managed package ships, never a managed one. `overrideSource` keeps its `registry` / `env` values; its describe now names the new field. + + ⚠️ **Read the old key nowhere.** Writing `allowOrgOverride` fails to compile (its input type is `never`) and fails to parse with the rename prescription (issue code `invalid_type` at path `allowOrgOverride`). A READER does not fail: `if (!entry.allowOrgOverride)`, a filter on it or a partial-match assertion still compiles against the tombstone and reads `undefined`, which treats every type as closed to environment overlays. A client that declares its own type for the `/meta/types` response, instead of importing `GetMetaTypesResponse`, sees no compile error at all and reads every type as locked. Search for the old name, do not wait for `tsc`. + + **`supportsOverlay` keeps its name; its describes now say what it gates.** It decides one thing: a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type's environment-overlay permission: the removal removes no managed definition, and changes what is served for `permission` alone. It opens no save and it decides no read. The registry's describe, the `GET /meta/types` describe and `MetadataTypeInfoResponse`'s describe used to promise that "the loader can merge overlays", in the retired per-organization wording. + + **Who is affected, measured.** No manifest, stack, plugin configuration or stored row can carry a registry entry (`contributes.kinds` is closed, `config.additionalTypes` is a tombstone, the registry is a TypeScript literal), so no authored metadata changes. The readers are code: every reader in this repository moved in this change, and a reader of the `/meta/types` field outside it moves with it — the Studio console reads the field and moves in its own repository's paired change, which the console pin carries. + + ### The kit + + - **The tombstone.** `MetadataTypeRegistryEntry.allowOrgOverride` is a `retiredKey()` naming `allowEnvironmentOverlay` (`kernel/metadata-plugin.zod.ts`); the 28 built-in rows spell the new key. + - **The ledger.** The retired-keys entry `kernel/MetadataTypeRegistryEntry:allowOrgOverride` and the D3 semantic entry `metadata-type-registry-allow-org-override-renamed` (protocol 18), which records the registry key and the wire field together. No D2 conversion: there is no authored or stored source a conversion could rewrite. + - **The wire.** `getMetaTypes` writes the hatch elevation onto `allowEnvironmentOverlay` on both of its arms — a registry type and a runtime-registered type with no registry row — and no entry carries `allowOrgOverride`. + - **The refusal sentences** that named the flag name the new key: `… takes no environment overlay (allowEnvironmentOverlay=false) …`, `… has neither allowEnvironmentOverlay nor allowRuntimeCreate …`, `… allowRuntimeCreate=false and allowEnvironmentOverlay=false …`. Codes and statuses are unchanged. + +### Minor Changes + +- 34dba5a: Every seed row of an object that carries an organization is stamped with the install's organization, platform-namespace seeds included, or the seed loader refuses the row + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D9). + + - **The exemption is withdrawn.** When a seed load names no organization (`config.organizationId`), the loader stamps the install's sole organization on every row of an object that carries an `organization_id` column. Seeds of `sys_`, `cloud_` and `ai_` objects used to be exempt as "intentionally global" and landed NULL. There are no platform-global seeds left, so they now carry the organization too: a seeded `sys_business_unit` is the organization's own business unit. + - **No owner, no row.** When the load names no organization and the install holds none, or holds several, a row of an object that carries an `organization_id` column is refused, counted in the result's errors and named in the message. Nothing of it is written. Before this change those rows were written NULL. A row that sets its own `organization_id` still loads. + - **No column, no stamp.** A row of an object with no organization column (`tenancy: { enabled: false }`, or a platform object whose injected column was dropped) is written without one, whether the organization was named by the load or derived. Before this change such a row was stamped anyway, and the engine refused it as an unknown field, so the row was lost. + - **Unchanged.** A load that names its organization (the per-organization replay under a walled posture) stamps it on every row that carries the column, as before. A composition that registers no organization object at all keeps loading its seeds unstamped. Rows written before this change keep their bytes; attributing that residue is ADR-0131 C7's. + + **The remedy.** Under the `single` posture the Default Organization exists before seeds load, so a boot seed always has its owner. A load that is refused names the organization it needs: pass it as `config.organizationId` (the organization the seed populates on a walled deployment), or set `organization_id` on the record. +- c8a9783: feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. + + - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. + - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. + - **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. + - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. + + Added (additive, nothing an author writes changes): + + - `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. + - `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. + - `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `bootstrapDeclaredPermissions` + - `normalizeManagedByVocab` + - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` + - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` +- e02833c: feat(metadata-protocol)!: managed content is sealed — `OS_METADATA_WRITABLE` no longer opens a write onto, or a removal of, an item a managed package ships (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6: no door edits a managed definition. The operator hatch `OS_METADATA_WRITABLE` (and its legacy spelling `OBJECTSTACK_METADATA_WRITABLE`) used to open one: an item a managed package ships, of a type whose registry entry allows no environment overlay, could be overlaid and its overlay row removed once the type was named in the variable. Measured on the CRM example with `OS_METADATA_WRITABLE=flow,object,permission,position`, a shipped flow, an object field and a position each took an overlay row, `PUT /api/v1/automation/:name` re-registered the shipped flow, and `DELETE /api/v1/meta/object/crm_lead?dropStorage=true` took the managed object off the data plane. + + **What changes.** With the hatch open or shut, the metadata doors now answer the same thing for an item a managed package ships: `403 NOT_OVERRIDABLE` on a write, and on a removal of a type whose overlay does not merge at read. The protocol's two package doors (`PUT` / `DELETE /api/v1/meta/:type/:name`, and the `/automation` definition doors that ask them), the repository's write path (draft promotion, restore, revert) and the read envelope (`editable` / `deletable`) all read one predicate: the item is artifact-backed and its type's registry entry opens no overlay channel. + + - The refusal's first sentence names the managed package ("… is provided by a managed package and is sealed …"). A type with an ADR-0126 regime row (`flow`, `action`, `permission`) still names its sanctioned route, the clone or the switch. Every other type now reads the seal, says the hatch does not open a managed item, and keeps the source remedy; it no longer prescribes setting `OS_METADATA_WRITABLE`, which would no longer help. + - A write naming a read-only package (`?package=`) still answers `403 ITEM_LOCKED`, now without a hatch-dependent remedy. `SysMetadataRepository.readOnlyBaseOverrideError` takes `(type, packageId)`; its third `hatchOpen` parameter is gone, because nothing it chose survives the seal. + + **What does not change.** + + - The environment overlay of a `view`, `dashboard`, `report`, `translation` or `email_template` a package ships, which the registry allows. + - Everything about items no managed package ships: the hatch still opens their runtime creation for a type that allows none, and their organization-scoped write. + - Disabling a managed flow or action (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/:object/:action`), operator-gated under a wall as before, and cloning a flow under a new name, which records no linkage. + - Removing a stored overlay row of a type whose loader merges it at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`): that removal restores the package's definition and stays allowed. + - Every overlay row a deployment already holds keeps loading and serving as before. + + **For an operator who set the hatch to customize a managed item.** Customize it through its type's route: an environment overlay for the five presentational types, the switch or a clone under a new name for a flow, the clone for a permission set, an extension package for an object. An overlay row the hatch wrote earlier onto a flow, action, hook, object or another type whose overlay does not merge at read keeps serving, and can no longer be edited or removed through the metadata API, with the hatch set or not. +- b389e43: feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) + + Clause-②: yes (narrowing) + + + + **BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. + + **What changes.** + + - Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. + - The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. + - The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. + - `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. + - A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | + | `organizationId` on any other protocol write verb's request | drop it, same refusal | + | `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | + | `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | + | `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | + | `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | + | `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | + | a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | + + **What a deployment observes.** + + - Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. + - An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. + + **What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- c7d6037: A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) + + Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) + + A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. + + - **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. + - **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: + - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; + - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; + - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. + An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. + - **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. + - **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. + + Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. +- 72b26ed: `importUnmanagedPackage` copies one package into the environment ledger, editable from then on — the import behind the `unmanaged` install mode (ADR-0131 D6) + + Clause-②: yes (widening) + + A package installs one of two ways. Managed: the package is the authority, it upgrades, and nothing it ships may be edited. Unmanaged: every metadata item is copied once into this environment, is fully editable, and never follows an upgrade. This release adds the import; the install door calls it for an unmanaged install, and the manifest key `installModes` declares the modes a package permits. + + - **The verb** (`@objectstack/metadata-protocol`). `ObjectStackProtocolImplementation.importUnmanagedPackage({ manifest, targetPackageId?, targetName? })` writes every metadata item of ONE package body into a NEW writable base through the metadata save door: each row is owned by the base, carries no organization, is served as environment-authored (`_provenance: 'org'`), and the next save edits it. The base's id differs from the source (`targetPackageId`, defaulting to `-copy`), it keeps the source namespace so every object name stays valid, and nothing is registered under the source id. The answer names the source, the base, its namespace and every item copied, in write order. + - **All or nothing.** Every refusal that can be judged is raised before the base exists, so a refused import writes nothing: a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value); a body carrying what cannot be copied; a malformed name or an unusable target id; a protocol-incompatible package; a target id that names an installed package, or a namespace another package owns (a managed install of the same package included); an item that does not convert to this protocol; a name an environment row or a managed package already holds. A write that fails afterwards deletes the base — its rows and its tables — and the failure is thrown with the item's own code and status, naming the item and what the rollback did. + - **What is copied, and what is refused.** The items of every manifest collection and the assembled `viewItems` are copied. A body carrying a runtime-uncreatable type (`jobs`, `agents`, `apis`, `capabilities`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), extensions of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`) or an artifact envelope (`packages`) is refused whole; `OS_METADATA_WRITABLE` does not open a type for it. The package's `translations` are copied too, each locale of each bundle as one `translation` item, and an unmanaged install loads no seed `data`; deployment configuration (`i18n`, `requires`, `datasourceMapping`, …) is not part of a package install in either mode. + - **Two new error codes** (`@objectstack/spec`, registered to `@objectstack/metadata-protocol`). `UNMANAGED_INSTALL_WALLED_POSTURE` (409) and `UNMANAGED_INSTALL_UNCOPYABLE` (422). Both mean the package can be installed managed instead. A collision, an existing target or an owned namespace answers the standard `RESOURCE_CONFLICT` (409). + - **Two new schemas** (`@objectstack/spec/api`). `ImportUnmanagedPackageRequestSchema` is parsed by the verb, so a request key it does not declare is refused with `400`. `ImportUnmanagedPackageResponseSchema` declares the answer. + + Nothing existing changes: no schema, door or code is removed or narrowed. +- 2d21b9c: An unmanaged install no longer copies a package's protection lock, in either spelling: every copied item is editable, and the install's answer names each lock it dropped with the package's reason (ADR-0131 D6, ADR-0010) + + Clause-②: yes (widening: the new response field `strippedLocks` on `ImportUnmanagedPackageResponseSchema`, relayed on the install-local answer for `installMode: 'unmanaged'`) + + An unmanaged install copies a package into this environment as the environment's own metadata. A package can lock an item in two spellings: the `_lock` envelope (`_lock`, with `_lockReason`, `_lockSource` and `_lockDocsUrl`), or the author-facing `protection: { lock, reason, docsUrl }` block that a managed install translates into it. Until now an item locked with `_lock` was copied with that lock, so the next save of the copy answered `403 ITEM_LOCKED`; an item locked with `protection` was copied with an inert block that bound nothing, so the vendor's lock vanished without a word. The package permitted the unmanaged install through its `installModes`, so it chose to let environments take editable copies; a lock whose source is the package does not survive the copy. + + - **The copy** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` writes every item without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and, on a type whose schema declares one, without its `protection` block. Its answer gains `strippedLocks`: one entry per lock that bound (`no-overlay`, `no-delete` or `full`), in the order the items were written — `{ type, name, lock, reason? }`, with `reason` the package's reason verbatim and absent when it gave none. Each entry is the lock a managed install of the same item would have bound: an item carrying both spellings is reported once, with the `protection` block's lock and reason, because that is the one a managed install binds. A package that locks nothing answers `strippedLocks: []`. + - **Refusals are unchanged.** A lock the spec's declarations refuse (an unknown `_lock` value, a reason that is not a string, a malformed `protection` block) is not stripped: the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back. So is a `protection` block on a type whose schema does not declare one (`action`, `hook`, `page`). A managed install is unchanged: the package stays the authority, and its locks bind. + - **The declaration** (`@objectstack/spec`). `ImportUnmanagedPackageResponseSchema` declares `strippedLocks`; `lock` takes the three binding values of `MetadataLockSchema`. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'`). The answer carries the import's `strippedLocks` as the import states it. + - **`os package install --install-mode unmanaged`** (`@objectstack/cli`). Each dropped lock prints as one line under `Locks removed`, spelled `.: — `, so the installer reads why the vendor locked an item before editing it. An answer from a runtime that states no list prints no lock line. + + Nothing is removed or narrowed: the answer gains a field, and every refusal of the unmanaged install is unchanged. +- 8caa131: A package's stored copy of a view container it ships overlays that package's shipped views, so a withdrawal saved in the copy holds at the anonymous form endpoints; the runtime save door refuses three copies it accepted before + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing at the runtime save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's earlier view container refusals shipped with. + + - **What was wrong.** The source loaders register a view container a package ships as `OBJECT.KEY` views for that package, whichever package owns the object. When that package stored a copy of the same container (a `PUT /api/v1/meta/view/NAME` of the container) and the object belonged to another code package, the copy expanded under its own name instead, as `OBJECT.CONTAINER.KEY` (a bare `list` as `OBJECT.CONTAINER`). So the copy overlaid none of the views its package ships: a form withdrawn from anonymous intake in the copy stayed open in the package's shipped form of that name, and the anonymous form endpoints kept serving it. + - **What it does now.** A package's stored copy of a container that package ships, bound to the same object, expands as the loaders expand the shipped container: each member is served under the loaders' name, `OBJECT.KEY`, in the copying package's own slot on the view list and on the by-name read naming that package. So a withdrawal saved in the copy holds at the anonymous form endpoints. The copy still declares no default view for an object another package owns. Any other container on another package's object keeps expanding under its own name, unchanged. + - **The by-name read on an unscoped kernel.** On a kernel with no environment id, a stored container's expanded views are also registered in the schema registry, under the bare name. A view whose name another package also ships is no longer registered there: the registry answered that bare entry ahead of the other package's own view, so `getMetaItem` naming the other package served this container's view. Every kernel's by-name read already serves such a view from its stored row, for its own package and for a read that names no package. Two packages that ship one container, with one of them storing a copy, were affected before this change too. + + **What is refused now.** `saveMetaItem`, which `PUT /api/v1/meta/view/NAME` and the dispatcher's metadata save both call, judges a copy at the names it now expands to, so three copies it accepted before are refused with `VALIDATION_ERROR` / 400, before anything is stored. Each is a package's copy of a container it ships on another package's object: + + - The copy adds a bare `list` whose name, `OBJECT.default`, only the other package ships. Before: accepted, served as `OBJECT.CONTAINER`. After: refused, naming the package that ships `OBJECT.default`. + - The copy adds a keyed member (a `formViews` or `listViews` entry, a named `list`, or a `form`) whose name, `OBJECT.KEY`, only the other package ships. Before: accepted, served as `OBJECT.CONTAINER.KEY`. After: refused, naming the package that ships `OBJECT.KEY`. + - Another stored container, saved under a different name, already expands a name the copy now expands. Before: accepted. After: refused, naming that stored container. + + **The fix.** For the first two, give the added member a key of its own that no package ships and no stored container expands, or save a view item (`name`, `object`, `viewKind`, `config`) under that name to override the other package's view. For the third, add the view as a member of the stored container that already expands the name, or save a view item under that name. + + **What still saves.** A copy that keeps the members its package's shipped container has, their contents edited, under the container's own name. A copy that adds a member under a key no package ships and no stored container expands. A view item under any of these names. Every container that is not a copy of its own package's shipped container, as before. + + **Rows stored before this change.** They keep their bytes. A stored copy of a container its package ships, on another package's object, is now served under the loaders' names (`OBJECT.KEY`) instead of `OBJECT.CONTAINER.KEY`, so it overlays the package's shipped views from the next read on, with no re-save. A reference to one of its old names (a navigation `viewName`, a form action `target`) no longer resolves; point it at `OBJECT.KEY`. A new save of a stored copy in one of the refused shapes, a re-save included, is refused until its body stops colliding. Delete stays open. +- 2f70c22: A key a builtin flow node's executor contract does not declare is refused at parse, with a location, on every builtin but `try_catch`: a `notify` `bogusKey`, a screen field's `visibleIf`, a `create_record` `fieldValues`, an `http` `outputVariable` and the like no longer pass `objectstack validate` and `objectstack compile` and then get the whole flow refused at registration. + + Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** Each of these executors parses the node's `config` against a strict contract before it acts. The build doors' executor-contract arm judged required keys and present values on these types, but held key membership back, because `registerFlow`'s undeclared-key walk judged it against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied the key into `dist/objectstack.json`), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. + + **What is refused.** A `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node, at any depth, whose config carries a key its executor contract does not declare: at the config itself, on a `screen` field, or on one of a field's `options`. That includes a body-less legacy `loop`, which is judged on key membership alone. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). It comes from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share. Its message carries the contract's own sentence, with the contract's prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss), and closes with the remedy: rename the key to one the contract declares there, or remove it. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the save door. `script` and `subflow` keys were already refused this way. The new `builtinNodeConfigKeysJudged(nodeType)` export names exactly the builtins the spec judges. + + **`@objectstack/service-automation`.** `registerFlow`'s descriptor walk (`validateNodeConfigKeys`) stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. Before the move, the declared key sets were measured equal: on each of these types the descriptor's declared keys, at every position the walk descends to, equal the keys the contract accepts there. So registration refuses exactly what it refused before. The refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. + + **`@objectstack/metadata-protocol`.** The save door judges a flow in its canonical spelling on every path. When no flow canonicalizer resolved (a host with no automation service), or the canonicalizer threw (a draft with a temporary cycle), the schema gate and the runtime authoring gate now judge the body with the ADR-0087 D2 conversions applied (`applyConversionsToFlow`), for the verdict only. The stored body stays the raw request body, exactly as before. A D2 spelling the load path still rewrites (`filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`; `functionName` and `input` on a `script`; `flow` on a `subflow`) is therefore accepted at that door, as at every other one, and an undeclared key that no conversion rewrites is refused there, located. This widens the fallback path back for the `script` and `subflow` aliases the previous key arm had begun refusing there. `duplicatePackage` re-saves through the same gate. + + **What stays as it was.** + + - `try_catch`: its undeclared keys stay registration's, judged by the walk. Its contract's `retry` is the shared `RetryPolicySchema`, which strips an unknown key, while its descriptor closes `retry` to `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. Moving the judge would have widened registration. + - Every plugin node type (the spec does not declare its contract): judged by the walk against its descriptor's `configSchema`, with the walk's own prescriptions. + - A key at or under a region slot (a `loop` body or a `parallel` branch, and their nodes and edges): judged at registration by the region check, as before. + - A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged. + - `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names | + | `fieldValues` on `create_record` / `update_record` | `fields` | + | `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` | + | `visibleIf` on a screen field | `visibleWhen` | + | `itemVariable` on a `loop` | `iteratorVariable` | + | `outputVariable` on an `http` node | delete it: the http executor binds no output variable | + | a key nothing reads | delete it | + + **The one-line fix: rename or delete the key the refusal names.** Registration already refused such a flow, so the fix changes nothing a running flow does. + + **Who is affected, measured.** At `fbcbcf124`, every builtin flow node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (95 nodes) carries only keys its contract declares. The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys whenever the engine publishes the node's descriptor. Its fallback form (while the descriptor list is loading, or when it cannot be read) offers `outputVariable` on an `http` node and the `url` alias on a `notify` node. Registration already refused the first and the conversion rewrites the second; the designer fix is tracked in objectui. hotcrm, deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, over every builtin in `getBuiltinNodeConfigContracts()` but `try_catch`, named by the new export `builtinNodeConfigKeysJudged`; no new code joins `FLOW_SLOT_REFUSAL_CODES`. + - **The ledger.** The D3 semantic entry `flow-builtin-node-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- 1fb274e: `ObjectStackProtocolImplementation.declinesStoredRow(type, name)` is now public, so a door that serves a stored row out of the layered read can ask the same decision the reads make + + Clause-②: yes (widening) + + - The method answers `true` for exactly the names whose stored `sys_metadata` row the active reads (`getMetaItem`, the list, and the `effective` layer of `getMetaItemLayered`) do not adopt: a flow name a managed package ships (the answer `isShippedFlowName` gives), and a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`). Every other type and name answers `false`. + - The `GET /meta/:type/:name/published` doors in `@objectstack/rest` and `@objectstack/runtime` now ask this method in place of `isShippedFlowName`. A door asks it, and does not restate either half or the host's code-datasource set. + - `isShippedFlowName` stays public and unchanged. + - The only change to the public surface is this one added method. No signature, schema or accept set changes, and no behaviour of this package changes. +- f85a83b: fix(lint)!: the object save door refuses a formula field whose expression `os build` refuses (#22019) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. At the object save door it did not. A formula field calling an unregistered function, such as `sqrt(record.amount)`, was refused by `os build` as an unknown function, but `PUT /api/v1/meta/object/:name` answered 200, stored it, and the field read `null` on every row. + + The runtime publish gate now runs the build's own formula check on an object write. The registry entry for the build's expression rule (`validateStackExpressions`) declared the flow, action and hook writes and never the object write, so the gate never dispatched it there. It now declares `object` as well, for one of its passes: a formula field's `expression`. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' expression`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a formula field whose expression the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that field's `expression`. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and the other errors in the build's formula check. Its warnings now ride the save response as advisories, as they already did for a flow write. + + **Remedy.** Fix the expression: the message names the unknown function or field and the position, as `os build` already requires. Use one of the functions `introspectScope` lists, qualify field reads as `record.FIELD`, or compute the value in a stored field and reference it. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps reading, with the formula still `null`, until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - This entry's crossing covers formula fields alone. The other expressions an object carries (validation-rule predicates, the field-rule slots `requiredWhen`, `readonlyWhen` and `visibleWhen`, option `visibleWhen`, and the object's own action predicates) are judged at this door through their own #22032 entries, each its own crossing, measured over the stored corpus first. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every formula field this repository ships has 0 refusals and 0 advisories at the door. That is 29 fields on 28 objects: examples 7 on 6, and the platform `display_title` formulas 22 on 22. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature. The registry entry reaches the passes through an internal function that is not on the package's entry. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. + + +- 73a0a6b: fix(lint)!: the object save door refuses an object action's `visible` or `disabled` predicate that `os build` refuses (#22032) + + Clause-②: no (narrowing: the object save door refuses an action visible or disabled predicate os build already refuses) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For an object's own actions it did not, at the object save door. An action whose `visible` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The action runtime evaluates such a predicate fail-closed, so the action was hidden on every record. + + The runtime publish gate now runs the build's action check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates, the field-rule slots and option `visibleWhen`. On an object write it now also judges each `actions[].visible`, and each `actions[].disabled` that is not a boolean literal, as a predicate over `record`, the way the build does. Every expression an object carries is now judged at this door. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · action 'ACTION' visible`, or `disabled`), message and hint. One location differs, never the verdict: an action that is also declared in the stack's top-level `actions` is located by `os build` at `stack · action 'ACTION' visible`, and by the door, which judges the object alone, at the object. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for an action whose `visible` or `disabled` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that action. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and a syntax error. Its warnings now ride the save response as advisories. + - The platform's own `sys_approval_request` (shipped by `@objectstack/plugin-approvals`) carries 8 action `visible` predicates that read `record.viewer`, a block the approvals service attaches on read and the object does not declare. The shared validator refuses all 8 (``unknown field `viewer` on `sys_approval_request` ``). Until #22211 lands, a save of that packaged object answers as follows. The first two rows were measured on the showcase's `objectstack dev` boot; the third is a source reading. + + | kernel and setting | publish save, before → after | draft save | + |:--|:--|:--| + | host-config kernel, no `OS_METADATA_WRITABLE` | 403 `NOT_OVERRIDABLE` → 422 `INVALID_METADATA` (8 issues) | 403 `NOT_OVERRIDABLE` | + | `OS_METADATA_WRITABLE=object` | 200 → 422 `INVALID_METADATA` (8 issues) | 200 | + | environment-scoped kernel | 403 `NOT_OVERRIDABLE`, unchanged | 403 `NOT_OVERRIDABLE` | + + On a host-config kernel the authoring gate answers before the package door, so there the 422 is returned where the more basic 403 belongs; #22220 carries that order. The object itself is unchanged: it registers, is served, and its actions evaluate as before. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and write a boolean literal (`true` / `false`) for a constant. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. For `sys_approval_request` there is nothing to repair on the consumer side: its predicates are the platform's, and #22211 carries the fix. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - The stack's top-level `actions`, its flows, its sharing rules and its hooks are not judged on an object write: each is judged at its own type's door, as before. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every object this repository ships (118 objects from its `*.object.ts` files) carries 58 action predicates on 16 objects, and the example stacks as `defineStack` composes them (33 objects, standalone actions merged in) carry 59 on 5. The shared validator refuses the 8 on `sys_approval_request` above and nothing else, and the door refuses the same 8 after this change and none before it. There are 0 other refusals and 0 advisories, at both doors. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. + + +- f2a45db: fix(lint)!: the object save door refuses a field-rule slot whose predicate `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field's rule slots it did not, at the object save door. A field whose `requiredWhen` read a bare field, such as `amount > 1`, or whose `visibleWhen` called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. + + The runtime publish gate now runs the build's field-rule-slot check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields and validation-rule predicates. On an object write it now also judges each field's `requiredWhen`, `readonlyWhen` and `visibleWhen` the way the build does, with the build's three gates on them: the `parent` gate, the null-guard check over `requiredWhen`, and the refusal of a `requiredWhen` or `readonlyWhen` that reads through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' SLOT`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a field whose `requiredWhen`, `readonlyWhen` or `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that slot. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, a root a field-level rule never binds (such as `current_user`), a `parent` read on an object that does not declare exactly one `master_detail` relationship, an ordering or arithmetic operator in `requiredWhen` applied to a nullable field with no `!= null` guard, and a `requiredWhen` or `readonlyWhen` that reads through a reference field (`record.account.tier`, or `parent.REF.FIELD`). Its warnings now ride the save response as advisories. + - A detail object's `requiredWhen` or `readonlyWhen` that reads through one of its master's reference fields (`parent.REF.FIELD`) is judged whenever the master is in the write's context, and that includes a save of the master itself. So a master save can answer 422 with an issue located at a stored detail's field. Fix the detail's predicate, then save the master again. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the unbound root, the unguarded operand or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, guard a nullable operand in `requiredWhen` with `record.FIELD != null && …`, and move a check that must read through `record.REF` into a `validations[]` `script` rule, whose `condition` is read one hop through a reference; a read through `parent.REF` has no such surface, so read a column the master declares instead (denormalise the value onto it). Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - `conditionalRequired` is still refused at the save door's schema step, before this gate, as a key retired in protocol 17; `os build` judges it as a field-rule slot as before. + - Option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing, not through this one. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every field-rule slot this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 9 slots on 8 fields of 3 objects (examples: 8 on `showcase_invoice` and `showcase_invoice_line`, three of them `parent`-scoped; the platform: 1 on `sys_permission_set`), over the 118 objects this repository ships. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- aa9447c: fix(lint)!: the object save door refuses a field option's `visibleWhen` that `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field option's `visibleWhen` it did not, at the object save door. An option whose `visibleWhen` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The server's option check cannot evaluate such a predicate and lets the value through, so the gate it declares is never enforced. + + The runtime publish gate now runs the build's option check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates and the field-rule slots. On an object write it now also judges each `fields[].options[].visibleWhen` the way the build does: as a predicate over `record` and `previous`, and with the build's refusal of a read through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for an option whose `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and a read through a reference field (`record.account.tier`, `previous.account.tier`). Its warnings now ride the save response as advisories. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and compare a reference field as a value (`record.account != null`) rather than read through it. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - `current_user` is still accepted in an option's `visibleWhen`, as the build accepts it: the option evaluator binds the acting user (ADR-0068 D1). A role gate such as `'org_admin' in current_user.positions`, or a grant check such as `current_user.can('OBJECT', 'edit')`, still saves. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen` it is still refused, as before. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - The object's own action predicates (`actions[].visible`, `actions[].disabled`) are judged at this door through their own #22032 entry, its own crossing, not through this one. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: this repository ships 5 option predicates, all on `showcase_cascade` (four `record.country` cascades and one `current_user.positions` role gate), among the 118 objects it ships. They have 0 refusals and 0 advisories, at the build and at the door. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 3d91885: fix(lint)!: the object save door refuses a validation rule whose predicate `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a validation rule's predicates it did not, at the object save door. A rule whose `condition` called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amount > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The rule then faulted on every write it judged. + + The runtime publish gate now runs the build's validation-rule check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields alone. On an object write it now also runs its validation-rule pass: each `validations[]` rule's `condition` and a `conditional` rule's `when`, plus the null-guard check over every predicate the rule carries, its nested `then` and `otherwise` rules included. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · validation 'RULE'`, or `… validation rule 'RULE' then → 'CHILD'` for a nested predicate), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a validation rule whose predicate the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, an ordering or arithmetic operator applied to a nullable field with no `!= null` guard (`has()` is no guard here), and the other errors in the build's validation-rule check. Its warnings now ride the save response as advisories. + + **Remedy.** Fix the predicate: the message names the unknown function or field, or the unguarded operand, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and guard a nullable operand with `record.FIELD != null && …`. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - This entry's crossing covers validation-rule predicates. The field-rule slots (`requiredWhen`, `readonlyWhen`, `visibleWhen`), option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every validation rule this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 21 rules carrying 13 predicates on 10 objects: examples 11 predicates on 7 objects, and the platform objects 2 on 3 (one rule on `sys_user` carries no predicate). + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 8fc50b7: fix(lint)!: a `conditional` validation rule's nested `then` / `otherwise` predicate meets the same expression verdict as the rule's own (#22042) + + Clause-②: no (narrowing) + + A `conditional` validation rule applies its `then` rule when its `when` holds and its `otherwise` rule when it does not, and the rule validator evaluates either branch as a rule of its own. `os build` judged a rule's own `condition` and `when` with the shared `validateExpression` validator, but reached the predicates inside `then` / `otherwise` with the null-guard check alone. So a nested `condition` that called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amont > 1`, passed `os build`. The object save door gives the build's verdict, so `PUT /api/v1/meta/object/:name` stored it, and the rule then refused every write it judged, because a validation rule that cannot be evaluated fails closed. The same predicate one level up was refused at both doors. + + The build's expression rule (`validateStackExpressions`) now runs the same check on every predicate nested in a `conditional` rule, at every depth: each nested `condition`, and the `when` of a `conditional` nested inside a branch. A nested `condition` also gets the relationship-traversal checks, because ObjectQL hydrates a one-hop read there, as it does at the top level. A nested `when` does not, because the evaluator never hydrates a `when`, as at the top level. A nested finding is located at the nested rule, the location the null-guard check already gave it: `object 'OBJECT' · validation rule 'OUTER' then → 'INNER'`, with `when-predicate` appended for a nested `when`. A rule's own `condition` and `when` keep their findings and their location (`object 'OBJECT' · validation 'NAME'`) and are judged once. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` now refuse, at `error`, a stack whose `conditional` validation rule carries a nested predicate the shared validator refuses. The validator's warnings on a nested predicate are now reported too, and at the save door they ride the response as advisories. + - An object write in publish mode that carries such a rule answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the nested rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode) and the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`). + - The verdict is the one a rule's own `condition` already got: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and, for a nested `condition`, a reference field read both through the relationship and as a value, or a read deeper than one hop. + + **Remedy.** Fix the nested predicate the way the same predicate is fixed at the top level: the message names the unknown function or field and the position. Qualify field reads as `record.FIELD`, and use one of the functions `introspectScope` lists. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - A rule's own `condition` and `when` are judged exactly as before, at the same location; the null-guard check over every predicate is unchanged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: this repository ships one `conditional` validation rule with nested predicates (`showcase_account.churn_reason_consistency`, two nested `condition`s), among 21 validation rules on the 118 objects it ships. Both have 0 refusals and 0 advisories, at the build and at the door. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- a543e24: `ObjectStackProtocolImplementation.revertStoredPackage({ packageId, organizationId?, actor? })` reverts a package's stored members to their published version + + Clause-②: yes (widening) + + - A Studio-authored package's members are `sys_metadata` rows bound by `package_id`. The method reads them through the same predicate `listDrafts` uses, which now has one statement shared by both reads. + - No stored row bound to the package: it answers `{ stored: false, discarded: [] }` and touches nothing. The package's members, if it has any, belong to the metadata service. An unprovisioned `sys_metadata` answers the same way. + - Stored rows, none of them published: it refuses with `RESOURCE_CONFLICT` / 409, "Package '…' has never been published, so there is no published version to revert to." Nothing is touched. + - Otherwise it removes every draft of the package, each in the scope it lives in, through the same per-draft step `discardPackageDrafts` takes. Each item then serves its published (active) row again. An item created after the last publish has no published row and is removed. A refused draft fails the call with that refusal's own code and status. + - The only change to the public surface is this one added method. `discardPackageDrafts` and `listDrafts` answer as before. +- 8f2e808: feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data + + Clause-②: yes (narrowing) + + **BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. + + ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. + + - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. + - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. + - **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. + - **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. + + +- 3b49318: fix(meta): a second unpinned draft save of a package-owned item is no longer refused 409, the item read serves that draft's `version`, and a package-less draft save keeps a package-owned datasource's stored credential + + Clause-②: yes (widening) + + - **The second draft save is accepted.** Save an item into a package (`PUT /api/v1/meta/:type/:name?package=`), then save a draft of it without `?package=` twice. The repository stores that draft in the item's package (the draft inheritance that keeps a pending change in the package it edits). The save door took its expected parent from the package-unbound draft row instead. It found none, so the second save, which sent no `If-Match`, was refused `409 METADATA_CONFLICT` ("Expected parent null but current is hmac-sha256:…"). ADR-0008 makes an unpinned save last-writer-wins, and it is accepted again. + - **One head row per write address.** `SysMetadataRepository.headAt(ref, { state, packageId })` answers `{ head, packageId }`: the row a `put` at that address upserts (`null` for a create), and the package binding that put writes under. Both come from the same resolution `put`'s optimistic lock runs. That resolution includes the draft inheritance and the adoption of a draft stored without a package. The save door's expected parent and the item read's `version` both come from it, so the token `GET /meta/:type/:name?state=draft` serves is the token a draft save at the same address accepts. Before this, that read served `version: null` while the inherited draft existed. + - **`?package=all` on the item read.** `GET /meta/:type/:name?package=all` now reads `all` as the save and publish doors read it: as naming no package. Before, the read forwarded the literal `all`. Its `version` was resolved at a package no save writes, so it served `null` beside a row the save then judged. Its `?state=draft` read answered `404 NO_DRAFT` for a draft stored in the item's package. The read now serves the item as a read with no `?package=` serves it, and `?package=all` still takes the uncached arm, so it keeps serving `version`. + - **The credential carry-forward reads the same row.** A save of a type whose read withholds a credential (`datasource`; `flow` with the automation plugin) carries the stored credential back into a body that returns as it was served. It compared that body against the row at the key the caller named. A package-less draft save of a package-owned item therefore found neither the inherited draft nor the package's active row, and compared against the code layer. The stored credential was persisted away on the first such save, measured on the real route with a datasource whose `config.url` holds one. Both reads now go through `headAt`: the draft the save overwrites, or with no draft yet the active row in the package the draft is stamped into. An item with no package binding is unchanged. + - **Descriptions only, in `@objectstack/spec`.** `SaveMetaItemRequestSchema.packageId` now states the draft inheritance, where it said "absent = env-local overlay". The `POST /meta/:type/:name/publish` route description spells the conflict code `METADATA_CONFLICT`, as the wire sends it. + - A stale `If-Match` is still refused `409`, and `If-None-Match: *` is still refused while a row exists. An item with no package binding is unchanged. No spec key, request field or response field is added or removed. + - The only change to the public surface is one added method on the exported `SysMetadataRepository`: `headAt`. No existing signature, schema or accept set changes. +- bb4f5cc: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, or any other root the server's option check does not bind (#22157) + + Clause-②: no (narrowing: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, which the runtime's option check cannot bind) + + A select option's `visibleWhen` is a gate the server enforces on write: the rule validator evaluates the predicate for the value a caller picks, and refuses the value when the predicate is false. That option check binds `record`, `previous` and the acting user (`current_user`, and its ADR-0068 aliases `user`, `ctx` and `os`), and nothing else. An option predicate that read `parent`, such as `parent.status == 'closed'`, still passed `os build` and the object save door. `parent` is a root the platform declares, and a field's own `readonlyWhen` and `requiredWhen` bind it on an object with exactly one `master_detail`. The option check does not bind it. So the predicate faulted on every write that picked the option, the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now gives a select option's `visibleWhen` a root verdict over the roots the option check binds. A predicate that reads any other root the platform declares, `parent` above all, is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the option, the field and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a root other than `record`, `previous`, `current_user`, `user`, `ctx` or `os`. Besides `parent`, that covers the roots the platform declares for other evaluation sites, such as `input`, `vars`, `trigger`, `data` and `features`. Each of them faulted at the option check in the same way. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** Rewrite the predicate against what the option check binds: `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. To gate a choice on a master-detail header's state, read a column the detail object declares, and denormalise the header value onto the detail; `parent` is bound only for a field's own `readonlyWhen` and `requiredWhen`. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind `parent` for an option, this refusal is lifted in that same change. + - The acting user is still accepted in an option's `visibleWhen` under all four ADR-0068 spellings, and so is a grant check such as `current_user.can('OBJECT', 'edit')`. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen`, the acting user is still refused and `parent` is still accepted, as before. + - A root the platform does not declare at all, such as `app`, was already refused in an option's `visibleWhen` by the bare-reference check, and it still is, with the same message. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every object this repository ships carries 5 option predicates, all on `showcase_cascade`: four `record.country` cascades and one `current_user.positions` role gate. That is over the 119 objects from its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them (33 objects). They have 0 refusals and 0 advisories, at the build and at the door, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- c512c25: feat(spec,metadata-protocol): each `GET /meta/_drafts` row carries the draft body's own `label`, or `null` + + Clause-②: yes (widening) + + - **What a client can now read.** Every row of the pending-drafts list (`GET /api/v1/meta/_drafts`, the runtime's `GET /metadata/_drafts`, and the SDK's `client.meta.listDrafts()`) carries `label`: the draft body's own top-level `label`. This list is the only place a client finds an item that exists only as a draft, so before this such an item could be shown by its machine name alone (a draft permission set read `technician`, not "Technician"). + - **Its shape.** `I18nLabel | null`, required on the wire. It is carried as authored: a plain string, or an inline locale map (`{ en: …, 'zh-CN': … }`) on the types whose `label` is an `I18nLabel`. The route takes no locale, so the reader resolves a map the way it resolves every other `I18nLabel` (`resolveI18nLabel` in `@objectstack/spec/ui`). + - **When it is `null`.** The body declares no `label`; it declares one `I18nLabelSchema` refuses (a row stored before its type's schema was enforced on save, or a row of a type with no registered schema); or its stored bytes do not parse, in which case the draft stays listed and every read of its body still fails. It is never the item name standing in for a missing label: a reader that wants a fallback chooses it, knowing the label is absent. + - **Where it comes from.** `SysMetadataRepository.listDrafts` reads it off the `sys_metadata` row it already fetches, so there is no second query, and `ObjectStackProtocolImplementation.listDrafts` passes it through. Of the stored body, only this one member leaves; field definitions and every other body key stay off the header. + - **Unchanged.** The other six members, the filters (`?packageId=`, `?type=`), the org scope, and the authoring gate on both routes. +- b1f7a7a: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of `ctx` or `os` the server's option check never binds, such as `os.org.id`, `os.env` or `ctx.locale` (#22274) + + Clause-②: no (narrowing: a select option's `visibleWhen` that reads an unbound member of a bound root is refused at build and at the object save door) + + A select option's `visibleWhen` is a gate the server enforces on write. The option check binds `record`, `previous` and the acting user, as `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`. Under `ctx` and `os` it binds the `user` member and nothing else: it passes no organization and no environment. The build already refused a root the option check does not bind, such as `parent`, but it judged `ctx` and `os` as whole roots. So an option predicate that read `os.org.id != ''`, `os.env == 'prod'` or `ctx.locale == 'en'` passed `os build` and the object save door with no finding. On every write that picked the option the predicate then faulted (`No such key: org`, `env` or `locale`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now judges the members of `ctx` and `os` in an option's `visibleWhen`, in the same verdict that judges its roots. A member other than `user` is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, says that the option check binds only the `user` member under that root, and gives the remedy. Every spelling of the read is judged the same: `os.org`, `os.?org`, `os['org']` and `has(os.org)`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `ctx` or `os` other than `user`, such as `os.org.id`, `os.org.tier`, `os.env` or `ctx.locale`. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** + + - For the caller's organization, compare `current_user.organizationId`, which the option check does bind: it holds the acting user's organization id, or `null` when the caller acts outside an organization. `os.org.id != ''` becomes `current_user.organizationId != null`. No other organization fact, such as its tier, is available to an option predicate; read a column the object declares instead. + - `os.env`, `ctx.locale` and any other member: rewrite the predicate against `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. + - Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `os.org` for an option, this refusal is lifted for that member in the same change. + - The acting user is still accepted under all four ADR-0068 spellings (`current_user`, `user`, `ctx.user`, `os.user`), and so are its fields (`current_user.positions`, `ctx.user.id`) and a grant check such as `current_user.can('OBJECT', 'edit')`. + - The same members are still accepted where they are bound, such as `os.org.id` in a `formula` field's `expression`. The refusal is the option slot's alone. + - A computed key such as `os[name]` names no member, so it is not judged. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one). None reads `ctx` or `os`. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them. They have 0 refusals at the build and at the door, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- 46692c1: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of the acting user the server's option check never binds, such as `current_user.roles` (renamed `positions`), `ctx.user.roles` or `current_user.email` (#22394) + + Clause-②: no (narrowing: a select option's `visibleWhen` that reads an `EvalUser` member the schema does not declare is refused at build and at the object save door) + + A select option's `visibleWhen` is a gate the server enforces on write. The option check binds the acting user under four spellings, `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`, all one `EvalUser` object. That object carries the caller's `id`, `positions`, `isPlatformAdmin` and `organizationId`, and nothing else. The build already refused a root the option check does not bind (such as `parent`) and a member of `ctx` or `os` other than `user`, but it stopped there. So an option predicate that read `'admin' in current_user.roles`, `ctx.user.roles == ['a']` or `current_user.email == 'a@b.c'` passed `os build` and the object save door with no finding. `roles` has not been a member of the acting user since ADR-0090 D3 renamed it `positions`; `name` and `email` are declared by `EvalUserSchema` but the server never sets them for this check. On every write that picked the option the predicate then faulted (`No such key: roles`, `email`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now judges the members of the acting user in an option's `visibleWhen`, under all four spellings, in the same verdict that judges its roots and the members of `ctx` and `os`. A member is accepted only when `EvalUserSchema` (`@objectstack/spec`) declares it AND the option check binds it; the allowlist is read off the schema and off `@objectstack/formula`'s `buildScope`, never written out. Any other member is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, names the members the acting user does carry there, and gives the remedy. Every spelling of the read is judged the same: `current_user.roles`, `current_user.?roles`, `current_user['roles']`, `has(current_user.roles)`, and the same below `ctx.user` and `os.user`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `current_user`, `user`, `ctx.user` or `os.user` other than `id`, `positions`, `isPlatformAdmin` and `organizationId`, such as `current_user.roles`, `current_user.role`, `ctx.user.roles`, `current_user.email` or `user.name`. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** + + - `roles` / `role` → `positions`: `'admin' in current_user.roles` becomes `'admin' in current_user.positions`, in whichever spelling the predicate used (`ctx.user.roles` becomes `ctx.user.positions`). + - `name`, `email` and any other member: rewrite the predicate against a member the option check binds (`current_user.id`, `current_user.positions`, `current_user.isPlatformAdmin`, `current_user.organizationId`), or against a column the object declares (`record.FIELD`, `previous.FIELD`). + - Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `email` for an option, this refusal lifts for that member in the same change, because the allowlist is derived from what the option check binds. + - The same members are still accepted where they are bound, such as `current_user.email` in a row-level security policy or an object action's `visible` predicate. The refusal is the option slot's alone. + - A grant check such as `current_user.can('OBJECT', 'edit')` is a call, not a member read, and is still accepted. + - A computed key such as `current_user[name]` names no member, so it is not judged. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one, `current_user.positions`). None reads a member the option check does not bind. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks. They have 0 refusals at the build, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- 3ca71b6: fix(metadata-protocol)!: one reader of `OS_METADATA_WRITABLE` — the legacy `OBJECTSTACK_METADATA_WRITABLE`, removed in 11.0, no longer opens the hatch at the type listing either + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + This finishes 11.0's removal at the reader that kept it. The 11.0 release removed ObjectStack's own legacy environment-variable names, `OBJECTSTACK_METADATA_WRITABLE` among them. That change edited one of the two readers of `OS_METADATA_WRITABLE` (the metadata repository's write gate) and missed the other (the protocol's, which feeds `GET /api/v1/meta/types` and the protocol's write gates). So with only the legacy spelling set, the type listing reported a named type as writable (`allowOrgOverride: true`, `overrideSource: 'env'`), while creating a new item of that type answered `403 NOT_CREATABLE`. One deployment gave two answers to one question. + + **What changes.** The protocol now reads the setting through the repository's reader, so there is one reader and it reads `OS_METADATA_WRITABLE` only. With only `OBJECTSTACK_METADATA_WRITABLE` set, the hatch is shut everywhere: the listing reports no env override, and every write the hatch would open is refused as it is with nothing set. No deprecation warning is printed for the legacy name any more, because nothing reads it. + + **What does not change.** `OS_METADATA_WRITABLE` behaves exactly as before. + + **Operator action.** A deployment that still sets `OBJECTSTACK_METADATA_WRITABLE` sets `OS_METADATA_WRITABLE` in its place, with the same comma-separated type list. This is the rename 11.0 already published; nothing else changes. +- ad14957: fix(metadata-protocol): a create under a name a managed package or a built-in holds is told to choose another name, not to edit the source artifact (#22591) + + Clause-②: yes + + The public input surface widens: `packagedBaseRefusal` accepts `operation: 'create'`, and `tenantAuthoredWriteRefusal` takes an optional operation; no verdict, code or status moves. + + A write onto an item a managed package ships, on a type with no environment overlay, is refused with `403 NOT_OVERRIDABLE`. The refusal's remedy used to assume the write was an edit of that item: "Edit the source artifact and redeploy", or, for a flow, an action or a permission set, its clone or on/off switch. That remedy is wrong for a create. An administrator who creates a position of their own under a name a package or a built-in already holds (`manager`, `everyone`, `guest`, …), or renames one into such a name, cannot edit a source artifact they did not write, and did not ask to customize the package's item. + + The remedy now follows what the write did: + + - **A create, or a rename into the name**, is told: the name is taken; to author your own item, choose a name no package or built-in holds, and `GET /api/v1/meta/:type` lists the names in use. This is one sentence for every metadata type. + - **An edit** keeps the sentence it had, byte for byte. + + Which writes are refused does not change, and neither do the code and the status. + + Where the remedy is a create's: + + - **Setup** (`@objectstack/plugin-security`): a create of a `sys_position` row, or a rename into a name a package or a built-in holds, under the `single` posture. The data door relays the metadata door's refusal for a create. + - **The metadata door** (`PUT /api/v1/meta/:type/:name`): a save sent with `If-None-Match: *`, the first-write precondition (`parentVersion: null` in `saveMetaItem`). Any other save is an edit, as before. + - **`ObjectStackProtocolImplementation.packagedBaseRefusal`** takes `operation: 'create'` beside `'save'` and `'delete'`, for a door that knows its write takes a name. The verdict is the `'save'` verdict. `tenantAuthoredWriteRefusal` takes an optional `operation: 'create'` the same way. +- c63028e: fix(metadata-protocol)!: the cross-object search asks the spec's exposure decision for `search` on each object, so an `apiMethods` whitelist that withholds `list` keeps the object out of the sweep (#22640) + + Clause-②: no + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The REST data routes, the runtime dispatcher, the MCP data tools and the analytics door judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`). In the derivation table, `search` derives from `list`. The cross-object search (`GET /search`, `searchAll`) spelled its own skip instead, and that skip read only `apiEnabled: false` and `searchable: false`. + + **FROM.** The search swept an object whose `apiMethods` whitelist does not grant `list` (for example `['get']`, or the deny-all `[]`), unless the object also declared `searchable: false` or `apiEnabled: false`. It served that object's rows while every data route refused them. + + **TO.** Before an object is queried, the search asks `canServeApiOperation(enable, 'search')`, and an object it refuses is skipped. A skipped object is never queried, named or counted. An explicit `objects=` naming it answers like one naming no object at all. + + - **Every caller.** The decision takes no user, so administrators and context-less in-process calls skip the same objects. + - **`searchable: false` keeps its own skip.** The decision answers an object with no whitelist without reading that flag. + - **Unchanged.** An object with no whitelist, or with one that grants `list`, is swept exactly as before. + + **Measured producers.** Read on `origin/main` `cb3bb933`: every object in this repository whose whitelist withholds `list` also declares `searchable: false` or `apiEnabled: false`, and no example app declares `apiMethods`. So no shipped object's search results change. Deployed and cloud-held object definitions were NOT MEASURED. +- 615cba8: fix(objectql,metadata-protocol)!: the data door honours a field's `internal: true` in its filter (related-object conditions included), sort, group-by, aggregate, per-aggregation-filter, `$search` and `$expand` positions at every depth, not only the row (#22646) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A field declared `internal: true` is withheld from every generic exit (#21197): the engine omits it from every row, select-named included. The ROW position honoured that; the generic data door's EVALUATE positions did not, so a value the platform withholds from rows could still be learned through a position that answers by its stored value. + + **FROM.** On `origin/main`, for a member holding read and for an administrator alike, on the list and query data routes (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query`): + + - A **filter** naming such a field was admitted — the predicate reached the driver, so the row came back only when the guess matched: a confirmation oracle over a withheld value. + - A **sort** by such a field was admitted — the order leaks the comparative stored value. + - A **group-by** of, or an **aggregate operand** over, such a field was refused by the engine, but as an undeclared `500 INTERNAL_ERROR` (a bare `Error` with no `code` or `status`), not a declared refusal. + - A **per-aggregation filter** (`aggregations[].filter`) naming it, and a **filter beside an aggregation**, were admitted — the same oracle at a second filter position. + - A **`$search`** whose resolved set included such a field (a non-hidden `internal` text column enters the auto-default) scanned it — a cross-field substring match that confirms a guessed prefix. + - A **nested-relation condition** (a condition on a lookup's related object) naming the related object's `internal` field was evaluated on that object — the parent row came back only when the related value matched, the same oracle one hop away. + - An **`$expand`** entry's own `where` / `orderBy` on its TARGET object's `internal` field was admitted into the expansion sub-read, at the first level and at every deeper level — the expanded record's presence is an oracle on the related row. + + **TO.** Each evaluate position is refused `400 INVALID_FIELD`, a declared ADR-0112 envelope naming the object and the field, before the engine is asked: + + - Filter, sort, group-by, aggregate operand and per-aggregation filter are refused at the generic data door (`@objectstack/metadata-protocol`), after the field-existence gates, in the shape and envelope of the stored-metadata body/hash family one axis over. A nested-relation condition inside any filter is judged against the RELATED object's own `internal` fields, and every level of an `$expand` tree has its own filter / sort judged against that level's target object; the refusal names the object that declares the field. + - The engine's `aggregate()` refusal (`rejectCredentialAggregation`) now carries the `INVALID_FIELD` / 400 envelope instead of the bare `Error`, so the group-by and aggregate-operand positions answer a declared `400` rather than a `500` — and the `secret` / `password` case that shared that bare `Error` is upgraded with it. + - A `$search` never scans an `internal: true` column: the field is dropped from the set the engine's search expansion resolves over (a withhold, like the auto-default's `hidden` / credential-type exclusions), and an explicit `$searchFields` naming one is refused `400 INVALID_FIELD` at the door rather than silently widened back to the default set. + + **Bindings.** + + - **Every caller.** The refusal is at the generic data door and takes no user, so an administrator is refused exactly as a member is (#21197). There is no system carve-out at the door. + - **The engine's privileged consumers are untouched.** The API-key verifier's `where: { key: }` lookup, the share-link / SCIM / approval-token lookups — all call the engine directly under a system context and never pass the generic data door, so authentication keeps working, exactly as #7823 kept the engine's write results whole while the generic-data-path ingress stripped them. + - **One flag reader.** Every position judges `collectInternalReadFields` (engine) or its byte-identical `@objectstack/core` twin `collectInternalWriteResponseFields` (the door, which sits below objectql), never a second list. + - **Unchanged.** An ordinary field in every position is served as before; an object that merely has an `internal` field is served for every other field. + + **Scope.** Target-OBJECT exposure through `$expand` is a separate axis (#22661); the cross-object search sweep (`searchAll`) is #22640's region and inherits the engine's search-set withholding through `engine.find` without a change to its own code. +- 3b5475a: fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the TARGET object its declared exposure — the data door's `$expand` at every level, and the dataset door's dimension-label passes (#22661) + + Clause-②: no + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The data routes judge the ADDRESSED object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`): `enable.apiEnabled: false` answers `404 OBJECT_API_DISABLED`, and an `enable.apiMethods` whitelist that does not grant the verb answers `405 OBJECT_API_METHOD_NOT_ALLOWED`. A read that FOLLOWS a lookup reaches a second object nobody addressed, and two such reads never asked it. + + **FROM.** For an administrator and a member alike: + + - **`$expand`** — `GET /api/v1/data/:object?$expand=`, `GET /api/v1/data/:object/:id?expand=`, the relation map on `POST /api/v1/data/:object/query` (every level of the tree), and the export door (which expands every lookup to name it) served the row fields of a lookup's target whose declaration refuses it on every data route. + - **Dataset dimension labels** — `POST /api/v1/analytics/dataset/query` rendered a reference-class dimension's grouped id as the target record's display name, and sorted an `order` on that dimension by those names, for the same targets. + + **TO.** Each read asks the spec's one decision of the TARGET object, for `get` — the read both perform is "turn an id the caller holds into the record it names", the read `GET /api/v1/data/:target/:id` performs: + + - An `$expand` entry whose target the decision does not serve is withheld at the generic data door (`@objectstack/metadata-protocol`), at every level: the field answers as an UNEXPANDED lookup, its stored id — the answer the door already gives for a related record the caller may not read. Nothing below a withheld entry is read. + - A dimension label is not read for such a target (`@objectstack/service-analytics`): the stored id renders, and an `order` on the dimension sorts by it — the answer a target the reader's row scope hides already gets. A declaration that cannot be read withholds the labels too (fail-closed, at `warn`); the query itself still answers. + + Concretely, a target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']` or `['create']`) is withheld from both; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. + + **Bindings.** + + - **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same tree and the same labels. + - **The engine's privileged callers are untouched.** A flow's `config.expand` and a hook's own reads call the engine directly and never pass the generic data door, the same split the write results and the `internal: true` evaluate positions keep. + - **No new error code.** Both reads withhold rather than refuse, so no response status changes. + + **Measured producers.** Read on `origin/main` `eae3368a` over every `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false`. No example declares `enable.apiEnabled` or `enable.apiMethods`. So no shipped object's served answer changes. Deployed and cloud-held object definitions were NOT MEASURED. +- 490cb6d: A write answer now carries the advisory validation-rule hits of that write as `warnings` + + Clause-②: yes (widening) + + A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. + + - **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. + - **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. + - **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. + - **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. + - **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. + + **For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. +- ec7c7e0: fix(metadata-protocol)!: a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22737) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + + **FROM.** On `GET /api/v1/data/:object?filter=…` and `POST /api/v1/data/:object/query`, for an administrator and a member alike, such a condition was evaluated whatever the related object's `enable` block declared. A source row came back only when the related value matched, even for a related object every data route refuses. + + **TO.** The generic data door asks the spec's one exposure decision (`apiExposureDenialReason`) of the related object, for `list`, the operation `GET /api/v1/data/:target?filter=…` is judged as. A condition is a query of the related object, not a read of a record whose id the caller holds, so a `get`-only whitelist does not serve it. A related object the decision does not serve is refused, with the data door's own exposure answer: + + - `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`. + - an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set in `allowed`. + + The refusal names the related object and the condition's position. It applies wherever the door walks a filter: `where`, each aggregation's `filter`, and an `expand` entry's own `where` at every level. + + **Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + + **Bindings.** + + - **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same answer. + - **Refused, not withheld.** Dropping a condition would widen the result. A related object the caller may not read already answers this position with a refusal (`403`). + - **The engine's privileged callers are untouched.** A flow's or a hook's own query calls the engine directly and never passes the generic data door. + - **No new error code.** `@objectstack/metadata-protocol` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too. +- 038d4c9: feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) + + Clause-②: yes + + **What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: + + - `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). + - `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. + - `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. + - `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. + - `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. + + With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. + + **The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). + + **Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: + + - `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. + - `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. + - `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. + - `POST /meta/:type/:name/publish`: `package`, `member`. + - `POST /meta/:type/:name/rollback`: `toVersion`, `member`. + + A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. + + **What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): + + - A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. + - Resetting one language keeps the other languages' customizations served at once, not only after the next restart. + - Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. + - In a cluster, a save, reset or revert of one language converges the other servers on that language. + + **For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. + + - `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. + - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. + - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. + - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. + - A type whose items have no languages ignores the argument. + - `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. + - The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. +- 252a582: feat(spec, metadata-protocol): the pending-drafts list and a package's commit timeline tell two languages of one email template apart — each entry of a template carries `member` (#23073) + + Clause-②: yes (widening: a new declared response key) + + **What changed for a reader of the two lists.** An email template kept in several languages is one item per language: two languages of `acme.welcome` are two drafts, and two items of one commit. Until now `GET /api/v1/meta/_drafts` and `GET /api/v1/packages/:id/commits` answered them as two entries with the same `type` and `name`, so a reader could not tell which entry was which language, and could not address the second one. + + - Each entry of `GET /meta/_drafts` (`ListDraftsResponseSchema`) and each item of a commit in `GET /packages/:id/commits` (`ListPackageCommitsResponseSchema`) now carries an optional `member` for a type whose identity is a pair (today only `email_template`, identified by `(name, locale)`). It is the language the entry is: `zh-CN`, and `en-US` for the canonical language, spelled out rather than left blank. Two languages of one template are therefore two entries with different `member` values. + - `member` is present on every entry of such a type and absent on every other type, so the lists are unchanged for every type whose identity is its name alone. + - The value is the one the by-name doors take back as their `member` selector: `GET /api/v1/meta/email_template/acme.welcome?state=draft&member=zh-CN` reads the `zh-CN` draft a list entry names, and `?member=` on publish, history, revert and reset addresses it the same way. For a commit item, `member` is the language the stored revert plan records, the one a revert of that commit acts on. + + **For code that uses these packages directly** (`@objectstack/metadata-protocol`): `ObjectStackProtocolImplementation.listDrafts` entries and `listCommits` items have the optional `member` described above. The types `ListDraftsResponse` and `ListPackageCommitsResponse` (`@objectstack/spec/api`, which `@objectstack/client`'s `meta.listDrafts` and `packages.listCommits` return) gain the same optional field. Code that does not read it behaves as before. +- e5f9f01: fix(metadata-protocol)!: the metadata save door refuses a `protection` block with a located `400 VALIDATION_ERROR` instead of storing a lock that binds nothing (ADR-0010 §3.5, Phase 4.3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `protection` is the package author's lock declaration. ADR-0010 states one door for it: a code package's loader translates it into the item's private lock envelope when the package loads, and strips it (Phase 4.3). An item saved through the metadata API is authored in the environment, and ADR-0010 §3.5 states that such an item takes no package lock. + + **This entry supersedes one sentence of the entry `15213-unmanaged-lock-strip`, in this same release.** That entry says a malformed `protection` block is not stripped and "the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back". What holds now: on a type whose schema declares `protection`, an item the unmanaged install hands on with its block still in it (a malformed block, or a block beside a `_lock*` value its declaration refuses) meets this entry's door refusal, `400 VALIDATION_ERROR`, and the install is still rolled back. An item carrying a refused `_lock*` value and no block, and a block on a type that does not declare one (`action`, `hook`, `page`), still answer `422 INVALID_METADATA`. Where the two entries disagree, this one is current. + + **FROM.** `PUT /api/v1/meta/{type}/{name}` with a `protection` block answered `200` and stored the block verbatim. The item was then served with `protection.lock: 'full'` while it read `lock: 'none'`, `editable: true` and `deletable: true`, and a second save and a delete both answered `200`. Measured on the showcase for an `app` and a `dashboard`. An author, or an AI, reading that item was told it was protected when anyone holding the authoring capability could change or delete it. + + **TO.** The same save answers `400 VALIDATION_ERROR` and writes nothing. The message names the block and the door that does honour it, and the envelope's `issues[]` carries `protection` as the path, so the Studio designer can highlight it. + + - **Every type that declares the block**, in draft and in publish mode, for a new item and for an overlay of a packaged one. A malformed block is refused the same way, because the block is not accepted at this door at all. On a type whose schema does not declare `protection`, the schema's own `422 INVALID_METADATA` answers, as before. + - **Every authoring write**: the REST and dispatcher saves and the domain doors that save through them. The unmanaged install already drops a block it can read before it saves a copy, and a malformed one is refused here. + - **Server-side rewrites strip it.** A row stored before this change may carry the block. When the server itself rewrites such a row (`os migrate meta --stored --apply`, the stored flow credential move, package duplication), it drops the block instead of failing the rewrite: the block never bound, so nothing changes but the stored bytes. + - **Unchanged.** A body without `protection` saves as before. A packaged item's lock still binds: its loader translated the block at load time, and a save against it still answers `403 ITEM_LOCKED`, or `403 NOT_OVERRIDABLE` on a type with no environment overlay, before this refusal is asked. A row stored before this change keeps its bytes. + - **Package export.** `GET /packages/:id/export` copies a writable base's rows into a manifest, so a block saved into a base row rode into that manifest, where the package loader translates it on install. A block can no longer be saved into a base row. Declare it in the manifest you install instead. + - **Remedy.** Delete the `protection` block from the body you send to the metadata API. To lock an item, declare `protection` on it in a code package's source. + + `@objectstack/spec`: the `protection` TSDoc on every type that mounts it (app, view, dashboard, report, dataset, object, flow, webhook, agent, tool, skill, position, permission set, email template) now says that only the package loader translates the block, and that the metadata API's save door refuses it. +- aa5bfdf: fix(metadata-protocol)!: the metadata save door never sets, changes or removes an item's lock: a body's `_lock*` family that would do so is refused with a located `400 VALIDATION_ERROR`, an echo of the served lock is accepted and ignored, and the row keeps the lock it stores (ADR-0010 §3.5, §4.1) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The `_lock` family (`_lock`, `_lockReason`, `_lockDocsUrl`, `_lockSource`) is the runtime's private lock envelope. A code package's loader stamps it from the package's `protection` block, and an install or a migration's re-save carries a stored one. ADR-0010 strips it on write and reattaches it on load (§4.1), and an item saved through the metadata API is authored in the environment, which takes no package lock (§3.5). + + **This entry supersedes one sentence of the entry `23096-save-door-protection`, in this same release.** That entry says, under its unchanged behaviour, "A body without `protection` saves as before". What holds now: a body without `protection` saves as before only when it also sends no `_lock*` key, or sends back exactly the `_lock*` family a read of that item serves; a body whose `_lock*` family would set, change or remove the item's lock answers `400 VALIDATION_ERROR` and writes nothing, as this entry states. Where the two entries disagree, this one is current. + + **FROM.** `PUT /api/v1/meta/{type}/{name}` stored a body's `_lock*` family verbatim. A hand-written `{ "_lock": "full" }`, or one an author or an AI copied from another item's served body, answered `200`, and the item then read `lock: 'full'`, `editable: false`, `deletable: false`: its next save and its delete both answered `403 ITEM_LOCKED`, and no door unlocks it. Any holder of the authoring capability could make an environment item permanently uneditable and undeletable. The other way round, an author save that sent no `_lock*` key dropped a lock the platform had stamped on the row, so a `no-delete` stopped refusing the delete. + + **TO.** An author's save keeps the lock family the row it writes already stores. + + - **No `_lock*` key in the body.** The save goes through, and the row keeps its stored lock. A body that says nothing about the lock does not remove it. + - **An echo is accepted and ignored.** Studio and the AI authoring surfaces send the served body back on every edit, so a family equal to one a read of the item serves passes: the family the item read, the layered read's `effective` and the list serve (the item's resolved lock), or the family stored on the row the save writes (what the draft read and the layered read's `overlay` layer serve). The row still keeps its own stored family, so an echo of a packaged item's lock on its first overlay stores none in the environment row; the package's lock still binds. + - **Anything else is refused**, `400 VALIDATION_ERROR`, and nothing is written: a family that would set a lock (on a new item, or on one that has none), change one (its state or its prose) or remove one (`_lock: 'none'`, or a partial echo that drops part of it), and a family copied from another item. The message names each `_lock*` key sent and the door that does lock an item, and `issues[]` carries each key as a path, so the Studio designer can highlight it. + - **Every authoring write**: the REST and dispatcher saves, draft and publish, the external-table import, the unmanaged install, and the domain doors that save through them (the flow and permission-set clones, the permission-set and position write-throughs, the package publish's app flip). A type with no schema (`external_catalog`) is judged too. + - **Unchanged.** A `_lock*` value the type's declaration refuses (`_lock: 'locked'`, a non-string reason) is still the schema's `422 INVALID_METADATA`, so the unmanaged install's refusal of such an item does not move. The server-side rewrites (`os migrate meta --stored --apply`, the stored flow credential move, package duplication) keep the family their body carries. A packaged item's lock still binds, and a save against a locked item still answers `403 ITEM_LOCKED` first. No lock stored today is released. + - **Remedy.** Delete the `_lock*` keys from the body you send to the metadata API, or send them back exactly as a read of that item served them. To lock an item, declare `protection` on it in a code package's source, where the package loader translates it into the item's lock. + +### Patch Changes + +- 8c105e3: The legacy-organization boot report reports each of its reads on its own, and a stored row under a code-defined datasource name is named once at boot + + Clause-②: no + + - `[metadata_org_scoped_unserved]` reads `sys_metadata` and counts the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit` as four separate terms. A read that fails for any reason but a missing table now costs only its own term: the line names it NOT MEASURED, with the reason, and still reports the other terms. Before, one failing read silenced the whole line. + - `[metadata_sealed_overlay_unserved]` no longer lists an environment row stored under the name of a datasource the host defines in code. The datasource restore's boot warning already names that row, with its own remedy, so the row was named twice. No read serves the row either way, and nothing is deleted or rewritten. +- b88c356: The remaining platform producers in these four packages now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-messaging, the inbox read state.** `listInbox` (and its unread total), the receipt read behind it, and mark-read / mark-all-read take the opt-in inside the service. Their scope is unchanged: every read of a user's rows is keyed on the user id the door derived from the session, the receipt a mark-read inserts is stamped with it, and the receipt it updates is one a user-keyed read returned. + - **service-messaging, `owner_of:` audiences.** The record read takes the opt-in, the same posture as the email lookup beside it. It reads only `id` and the owner fields, and only the owner id leaves the resolver. An `owner_of:` audience on an object whose sharing model is `private` now resolves its owner; before, it resolved to nobody. + - **service-messaging, the rest of the fan-out and the outboxes.** The `role:` and `team:` membership reads, the email and SMS recipient reads, the notification template read, the dedup lookup in `emit()`, and both outboxes' enqueue, ack and list. + - **service-storage.** `StorageMetadataStore.createFile` and `createSession` insert under the opt-in. The organization still reaches the driver beside it, so the stored organization is unchanged, and the file's `owner_id` is still the uploading user. + - **service-settings.** The `sys_secret` store the plugin builds (insert, get, update), and the read that verifies a rotation before the old secret is reaped. A store `update` now writes the `ciphertext` it is given; without a context the engine's read-only strip dropped it. No caller in this repository uses `update`. + - **metadata-protocol.** `SysMetadataRepository.getByHash`, `list`, `history` and the history replay of `watch()`. + - None of the gates the middleware runs before its hand-off applies to these calls. ⛔ No new export on any package entry, and no new elevation API. +- a3bcbcf: feat(plugin-security)!: a data-engine context that carries no principal and is not a system context is refused (ADR-0096 D5 strict mode) + + Clause-②: no (narrowing) + + + + **BREAKING** for in-process code: an accept-set narrowing of the data engine's security middleware, shipped as `minor` under the launch-window convention for breaking changes. + + **What is refused now.** A data-engine operation whose execution context carries no principal (no user id, no position, no permission set) and is not a system context. That covers a call that passes no context at all, an empty one, one with only a tenant id, and one with only provenance fields. Every layer answers it the same way: + + - the engine middleware throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb (find, findOne, count, aggregate, insert, update, delete), before the operation runs; + - `canReadObject`, `canExport` and `canWriteObject` answer `false`; + - `getReadFilter` answers the deny filter (zero rows). + + It used to be handed straight through, with no CRUD gate, no row-level security, no field mask and no tenant wall. That contradicted the published contract for an empty tool-execution context, "unauthenticated (RLS-on, sees-nothing)", which this change now keeps. The field projections (`getReadableFields` and its siblings) answer such a context as they answer any caller that resolves no permission set. + + **What is unchanged.** + + - A system context (`isSystem: true`) is admitted everywhere, as before. + - A context that carries a principal without a user id is decided by what it carries: a named permission set, the guest principal, or the public-form grant. + - An unauthenticated HTTP request is answered as before: a door that requires a session answers `401 UNAUTHENTICATED` before the engine is asked anything, and an endpoint an application declared open runs it as the guest principal. + + **What to do.** Code that calls the data engine in-process must state who it acts for. FROM: an engine call with no context, or with a context that names nobody. TO: either + + - pass the caller's execution context, so the caller's own permissions decide; or + - for platform plumbing whose own door already authorized the caller (a store read or write the platform owns), pass the explicit system opt-in, `context: { isSystem: true }`. + + The one-line fix: give every in-process engine call a context, the caller's or `{ isSystem: true }`. The refusal message names both. + + `@objectstack/metadata-protocol`: the protocol's own platform-store reads and writes behind the publish, history, audit, diff, commit, migration and code-only delete doors now pass the explicit system opt-in, like its other store calls. Nothing those doors answer changes. +- 1abfc58: The metadata door serves a code-defined datasource's code definition while a stored row under its name still exists + + Clause-②: no + + - `GET /api/v1/meta/datasource/:name`, the `GET /api/v1/meta/datasource` list and the `effective` layer of `GET /api/v1/meta/datasource/:name/layers` now skip a stored `sys_metadata` row under a datasource name the host registers from code: one an installed package declares in `*.datasource.ts`, or the host's `default`. They serve the in-memory code definition instead. The datasource admin door and the boot restore already serve that ("code wins on collision"). Before this change the stored row was served first, so the two doors answered with two different bodies for one name. + - The decision is made by name, through the same predicate the reads already ask for a shipped flow name. It never reads a row's `origin`. Every other type keeps ADR-0005's read order, in which the stored overlay wins. + - Unchanged: the row stays at rest and is still reported in the layered read's `overlay`. The read envelope stays `deletable: true` while the row exists, and `DELETE /api/v1/meta/datasource/:name` still removes it as the repair. A draft read (`state: 'draft'`, or the draft preview) is still answered from the draft row. A runtime datasource's stored row is served as before. + - The `/meta` and admin doors already refuse to write such a row. This change affects only how a row left from before that refusal is read. + - Not moved: `GET /api/v1/meta/datasource/:name/published` still serves the stored row, which is the active overlay row that route describes. + - ⛔ No public export, signature, schema or accept-set change. The built entry declarations gain two `private` member names on `ObjectStackProtocolImplementation`. +- db87a02: fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved + + Clause-②: no + + - **What was wrong.** Where packages ship the same view container, the view list (`getMetaItems` for `view`) served one item for each name a saved environment-wide copy of that container expands: the copy's own expansion. Every other package's item of that name, shipped or saved, was left out. The anonymous form endpoints judge a withdrawal against the environment-wide view list, so they could miss another package's withdrawal of such a form. + - **What it does now.** The view list serves each package its own item of such a name: + - a package's saved copy of the container serves that package's item of each name it expands; + - a package-less saved copy stands in for every package that has no copy of its own (ADR-0048); + - any other package keeps its own item. + + So another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of the container are saved. The organization-scoped save check reads the same list, so it judges each package's item too. + - **A stored view row of exactly such a name** keeps its own package's slot only. A package-less row still serves every package's slot. Before, any package's row of the name kept every package's copy expansion of it out of the list. + - **The by-name read agrees.** `getMetaItem` naming a package serves the item that package's slot in the list serves. Where no copy belongs to that package, a package-less copy now stands in for it. A list scoped to a package (`GET /api/v1/meta/view?package=`) serves the same item in each slot the package lists. A package-less copy adds no item to that list. + - **What does not change.** Within one package, a later expansion of a name still replaces an earlier one, and the save door's view container collision check is unchanged. A by-name read that names no package answers as before. No key, export, status or error code changes. +- 6befe19: A metadata row stored with no `checksum` can be edited and removed through the metadata door (#21978) + + Clause-②: no + + - `SysMetadataRepository` serves a `sys_metadata` row that carries no `checksum` as the hash of its stored body, but its `put` and `delete` compared the caller's parent with the raw column (`null`). So `PUT` and `DELETE /api/v1/meta/:type/:name` answered `409 METADATA_CONFLICT` ("Expected parent … but current is null") for every such row, with `If-Match` set to the version the door served and with no `If-Match` (last-write-wins) alike. A publish over such a row was refused the same way, as were the rollback and commit-revert doors, which take their parent from the same read. The datasource admin door stored such rows before it stamped them. + - `put` and `delete` now accept the version such a row is served as. A `null` parent still matches it, and a row with a `checksum` is judged exactly as before. A stale version is still refused with `409 METADATA_CONFLICT`, and the refusal now names the row's served version as the current one instead of `null`. + - The next write stamps the row's `checksum`, as every write does. Stored rows are not rewritten. + - Publishing a draft row stored with no `checksum` now also removes that draft row. Before, the post-promotion cleanup was refused by the same lock and the draft stayed pending, with nothing reported. +- 2015c54: A by-name metadata read that names no package now wears the package of the body it serves + + Clause-②: no + + - **What was wrong.** `getMetaItem` with no `packageId` (behind `GET /api/v1/meta/TYPE/NAME` when no package is named) merges the registry artifact's protection envelope, `_packageId`, `_packageVersion` and `_provenance`, over the body it serves. With no package named, that envelope was the first-registered package's. When two installed packages ship one name and the body served was the other package's, the answer carried that body under the wrong package. Two cases were measured. In the first, both packages ship a view container and one of them stores a copy of it: the read served the copy's view and named the first-registered package. In the second, a stored row of the name is bound to one package. The answer's top-level `packageId` / `provenance` / `packageVersion` fields, which are read off the served item, said the same wrong thing. + - **What it does now.** With no package named, the envelope is looked up at the package the served item is bound to. That is the stored row's package, the package of the container copy that expands the name, or the `_packageId` of the MetadataService or registry item. This is the rule the `GET /api/v1/meta/TYPE` list already applies to each item it serves, so the list and the by-name read now give one envelope for one served body. + - **Unchanged.** Which body the read serves. A read naming a package. The lock family and the `lock` / `editable` / `deletable` envelope, which still come from the item-lock resolution over the read's own address. A served item bound to no package (a package-less stored row or copy, or a registry entry with no package) keeps the package-less lookup it had, so for a name only one package ships, a tenant's package-less overlay still wears that package's envelope. The layered read (`/layers`) is not changed. + - ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently. +- ae97841: A by-name view read naming a package now serves that package's view when another package's stored row has the same name + + Clause-②: no + + - **What was wrong.** Two installed packages can ship a view of one name, and a stored `sys_metadata` view row can be bound to one of them. The registry hydration registered that row under the view's bare name. The registry answers the bare name ahead of every package's own entry, whichever package the read names. So `getMetaItem` naming the OTHER package found no row of its own and served this row's body at its registry step, under the other package's envelope. That is the read behind `GET /api/v1/meta/view/NAME?package=…`. Meanwhile the list's slot for that package served its own view. This was measured on an unscoped kernel after a save, and on either kernel after a cold boot, because `loadMetaFromDb` hydrates through the same door on every kernel. + - **What it does now.** A view row bound to one package is not registered under a name another package ships. This is the shape the view-container expansion registration already takes. The reads answer the row from the row itself: the read naming its own package, the read naming no package, and the list's slot for that package. The read naming the other package serves that package's own view and envelope. The delete's registry heal also stops re-registering a metadata-service view baseline bound to one package under such a name. + - **Scope: `view` only.** Every other type registers as before: a row bound to one package, of a name two packages ship, keeps the bare entry with its own body and its own package's envelope. A package-less view row, and a view row of a name that only its own package ships or that no package ships, also register as before. The environment-scoped kernel's answer after a save is unchanged, because it registers nothing on a save. + - ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently. The built entry declarations gain one `private` member name on `ObjectStackProtocolImplementation`. +- 35afb15: fix(metadata-protocol): a save of a packaged item whose type allows no overlay answers `403 NOT_OVERRIDABLE` before any check that judges its body, on every kernel topology + + Clause-②: no + + - **What was wrong.** `PUT /api/v1/meta/:type/:name` refuses an in-place write onto an item a code package ships when the type has no per-organization overlay channel (`allowOrgOverride: false`, for example `object`, `permission`, `position`). An environment-scoped kernel answered that refusal before it judged the body. A host-config kernel (the CLI's assembler, the showcase's boot shape, `OS_MODE=off`) answered it only at the repository write, after every other check. So on that kernel a publish of a packaged object whose body the authoring gate refuses answered `422 INVALID_METADATA` with findings the author could not land through this door, and a body the spec parse refuses answered `422` in draft and publish mode. After fixing the findings, the author got the `403`. + - **What changes.** The package check now runs on every topology, at the position it already had on an environment kernel: after the code-only and organization-scope refusals, before the item lock and every check that reads the body or the store. The same request now gets the same refusal on both kernels: `403 NOT_OVERRIDABLE`, or `403 ITEM_LOCKED` when the save names the read-only package, with the same sentence. On a host-config kernel that sentence replaces the repository's "is not allowOrgOverride in the registry" text for these saves. + - **What does not change.** Every request refused before is still refused, and every request admitted before is still admitted. The repository refused the same writes on every topology, and it still does, for the doors that reach it without this check. An environment-local item, an item of a type that allows overlays, and a save with `OS_METADATA_WRITABLE` open for the type are judged by the same checks as before, and drafts are still not judged by the authoring gate. +- 3f80f17: A cel-dated seed row reaches every hook in the stored form of its temporal field + + Clause-②: no + + `` cel`daysFromNow(n)` ``, `daysAgo(n)`, `today()` and `now()` in a seed record resolve to a JS `Date`. The seed loader handed that `Date` to the engine, so what a hook saw depended on how the hook ran: + + - An in-process `handler` (a source config, which `@objectstack/verify`'s `bootStack` boots) saw the `Date` object. + - A sandboxed `body` (the compiled artifact `objectstack dev` boots) saw a full ISO instant, even on a `date` field. + + A hook that reads a date field as a string therefore accepted the row under `objectstack dev` and refused it under `bootStack`, and the row was not stored. + + The loader now puts each `Date` on a `date`, `datetime` or `time` field into the stored form the drivers write (`YYYY-MM-DD`, the UTC ISO instant, `HH:MM:SS`) before any hook runs, so every hook sees the value the column stores. A body hook on a `date` field now sees `2026-10-09` where it saw `2026-10-09T00:00:00.000Z`. Stored values do not change on any driver, because each driver already normalises a declared temporal field on write (`@objectstack/driver-memory` through the same `temporalStorageForm`). A `Date` on a field that is not temporal, or on a field the object does not declare, is handed over unchanged. +- cb3bb93: fix: a hook's refusal reached through an action or another hook answers in the hook's own words, and `$top=0` reports the real `total` (#22588) + + Clause-②: no + + - **A nested sandboxed refusal keeps its sentence (`@objectstack/runtime`).** A script action whose body writes through `ctx.api` and is refused by a sandboxed hook used to answer with the hook's debug wrapper in front of the sentence: `POST /api/v1/actions/:object/:action/:id` → `400 VALIDATION_ERROR` "`hook 'guard' threw: Error: A finished task cannot be reopened.`". It now answers the hook's sentence alone, as `PATCH /api/v1/data/:object/:id` and a declarative `operation: 'update'` action already did. The same holds for a hook that writes another object through `ctx.api` and is refused there: `/data` answers the inner hook's sentence. Status and `code` do not change. The cause was the sandbox's VM hop: a host error crosses into a body's VM as `name` and `message` only, so the refused write's sentence was left behind and the wrapper became the outer error's caller-facing text. The sentence now travels beside the wrapper and is used when the body lets that error escape unchanged. A body that catches the error and throws its own (or rewrites its message first) is still answered in its own words, a nested crash is still a `500`, and the server log still names every frame. + - **The `/actions` door reads its sentence through `sandboxBusinessMessage`** (`@objectstack/types`), the same read `/data`, the bulk doors and `/analytics/dataset/query` make, instead of its own copy. The wire answer is unchanged. + - **`$top=0` reports the filtered `total` (`@objectstack/metadata-protocol`).** `limit: 0` asks for no rows, and `findData` treated it as "no limit", so the zero-length page was reported as the total: `GET /api/v1/data/:object?$top=0` answered `total: 0` while `$top=1` answered the real count. The Console reads its list footer's record count from exactly that `$top: 0` request. A zero-row request is now a paged request: it returns no rows and the same `total` as any other page size, filters included. `$count=false` still skips the count and now omits `total` for `$top=0` too, instead of reporting `0`. A `search` query still reports its page-local estimate, as it does at every page size. +- c4e7fa5: feat(core): export `relationConditionSites`, the one walk that finds a filter's nested-relation conditions and each one's target object (#22777) + + `relationConditionSites(fields, filter)` returns every `{ : { : … } }` condition a filter holds, at the top level and under `$and` / `$or` / `$not`, with the reference field, the target object (resolved by the spec's `referenceTargetOf`) and the condition. An id comparand, an operator bag and a non-reference field are not conditions. + + It moved unchanged from `@objectstack/metadata-protocol`, where it was module-private, so that the MCP stdio data bridge (`@objectstack/mcp`) and the generic data door ask each condition's target its exposure over the same sites. `@objectstack/metadata-protocol` now imports it; its behaviour does not change. +- 55382dc: A paged list under a search now reports the real number of matching records as `total`, and `hasMore` agrees with it on every page. + + Clause-②: yes (widening) — `EngineCountOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the count options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. + + Until now `GET /api/v1/data/:object?$top=25&$search=Line` over 75 matching records answered `total: 26` (offset + page + 1, a page-local estimate), and the last page claimed `hasMore: true`; `POST /api/v1/data/:object/query` with a `search` did the same. Nothing on the response marked the number as an estimate, so the Console rendered it as the record count and offered the wrong number of pages. + + - **`@objectstack/spec`** — `EngineCountOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. FROM `count(object, { where })` only → TO `count(object, { where, search, searchFields })`. The structured `search` form carries flag defaults, so the parsed state is named `EngineCountOptionsParsed` (ADR-0122). `HookCountQuery` (`ctx.api.object(name).count()`) derives from it and gains the same two keys. + - **`@objectstack/objectql`** — `engine.count()` (and `ctx.api.object(name).count()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` and `aggregate()` use: the same server-resolved searchable fields, the same `internal: true` exclusion, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two, so a count under a search is the number of rows `find()` returns for the same bag. + - **`@objectstack/metadata-protocol`** — `findData`'s paged branch passes `search` / `searchFields` to `engine.count()`, and the page-local estimate branch is gone. `$count=false` still skips the count and omits `total`. + + Nothing to migrate. A client that worked around the estimate, for example by labelling the count as page-local under a search, can read `total` as the match count again. +- 5677add: fix(metadata-protocol): two locales of one email template saved through the metadata door both survive a restart — an overlay row is keyed by its bundle member (#22877) + + Clause-②: no + + **What an administrator sees.** Saving the same email template in two languages through the metadata door (`PUT /api/v1/meta/email_template/:name`, the Studio editor's save) keeps both. Before, the store kept one overlay row per template name, so the second language's save replaced the first language's row: both read back until the next restart, and then the first language mailed the package wording again. Each language now keeps its own wording across restarts, and each send in that language uses it. + + **What changed in the store.** + + - `sys_metadata` gains one nullable column, `bundle_member`. It holds the member of a metadata type whose identity is a pair — today `email_template`, identified by `(name, locale)`. NULL is the canonical member (`en-US`, the locale a send falls back to). Every other row stays NULL, so rows of every other type, and an `en-US` template row, are byte-identical to before. + - The value is taken from the saved body's `locale`; it is never authored. A body that declares no `locale` is the `en-US` member. + - The overlay uniqueness index becomes `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))`, for active rows and for draft rows, and the declared index in `metadata-core` carries the column too. A second row for the same template, language, package and state is refused by the index. + + **What a read or a door addresses.** A save addresses the language its body names. A read, delete, publish, history or revert by name that names no language addresses the `en-US` member: `GET /meta/email_template/:name` serves the `en-US` wording (the stored one when it was customized, else the package's), where before it served whichever row the name had. The layered list and the send path serve every language as before. A non-`en-US` language's own customization is reached through the list, and is replaced by a save that names its `locale`; a by-name reset (`DELETE`) removes the `en-US` customization only. The lineage counter stays one per template name, so a version number names one saved snapshot, and a revert by name restores only a version of the `en-US` member. Resetting, publishing or reading a non-`en-US` language by name needs the by-name member selector, which arrives with stage 2 of #22877; until then those doors address the `en-US` member. + + **What happens on the first boot after upgrading.** Nothing to configure. + + 1. The schema sync adds the `bundle_member` column. + 2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written. + 3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`. + + **On a database without partial indexes (MySQL).** The rebuild above is refused there, as before, and reported in the server log at the first metadata write. A store that booted an earlier version keeps the four-column declared index under `idx_sys_metadata_overlay_active`, because the schema sync skips an index whose name already exists. That index has never constrained an environment-wide overlay row: those rows store no organization, and a UNIQUE key does not collide NULLs. So two languages of one template coexist there too, and no save is refused. `os migrate plan` lists the declaration change on that index as a destructive `recreate_index`. To bring the index to the five-column declaration, run `os migrate apply --allow-destructive`. A fresh store gets the five-column declaration. + + `os migrate duplicates` boots read-only and adds no column. Run against a store this version has not booted yet, it lists the two overlay indexes with every row read as the empty member — the value the column will hold — so a duplicate that will block the rebuild is still reported, and the entry's `duplicateSql` is the member-less statement that ran there. +- 6a6a4d3: fix: a refused on-demand schema sync (`syncObjectSchema`) is logged at `error`, naming what was not created and how to fix it (#22985) + + Three places create an object's storage after the boot's own schema sync has run. When the driver refused that DDL, each of them logged a `warn` and carried on, so the deployment looked healthy while a table did not exist. Each now logs one `error` line that says what was not created, that nothing else will look wrong, and what fixes it. The driver's refusal is kept out of the line itself. Nothing else changes: the boot, the connect and the publish still complete as before. + + - **`@objectstack/service-messaging`**: boot-time provisioning of the eight notification tables. A refused table logs `[messaging] could not provision storage — its storage was NOT created. …` at `error`, once per table per boot, with the refusal in the record's meta. The fix it names: resolve the refusal, then restart or run `os migrate apply`. This used to be a `warn`. Under `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` the provisioning still issues no DDL, so this line cannot appear. + - **`@objectstack/service-datasource`**: connecting a declared datasource re-runs the schema sync of every object bound to it. A refused object logs `datasource '': object '' has NO working storage here — …` at `error`, once per object per connect, with the refusal in meta. On a logger that has no `error`, the same line is logged at `warn`. The fix it names: resolve the refusal, then restart. This used to be a `warn` reading `syncObjectSchema('') failed`. + - **`@objectstack/metadata-protocol`**: publishing an object whose storage sync is refused logs `[Protocol] object '' was published but its storage was NOT created or altered — …` with `console.error`. The driver's refusal is the second argument. The fix it names: resolve the refusal, then publish the object again or restart. The publish still answers success. This replaces the `console.warn` line `[Protocol] table sync failed for object '': …`, so a log search for that text now finds nothing. +- 0a29bec: fix(metadata-protocol): a metadata row records who created it and who last changed it + + Clause-②: no + + - **What was wrong.** A metadata write that named its author stored no author on the `sys_metadata` row. `created_by` and `updated_by` read null on every row, though the repository's own schema mapping declared `actor → updated_by` and its read side reads that column. Only the history row's `recorded_by` named the writer, so "who changed this object?" could be answered from history alone. + - **What it does now.** Every metadata write stamps the row from the write's actor: `created_by` when the row is first written, and `updated_by` on every write. Studio saves, `PUT /api/v1/meta/:type/:name`, publishing a draft, restoring a version and an in-process `saveMetaItem({ actor })` all write through this one path. A write with no actor (a boot sync, a migration with no user, an unauthenticated internal call) stores null in both columns, never a placeholder user. A save that changes nothing writes nothing, so it leaves both columns as they were. + - **What an operator or a consumer sees.** A row written from now on names its creator and its last writer, so a consumer (such as cloud's AI build agent, whose staged drafts name their author) can read the author from the row itself. The pending-changes list (`listDrafts`' `updatedBy`) names the draft's last writer. Rows written before this release keep their null columns until their next write. History's `recorded_by` is unchanged. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- abf8655: A language the environment added to an email template, which the package does not ship, is no longer treated as packaged code + + Clause-②: no + + An `email_template` is identified by its name and its `locale`, so the languages of one template form a bundle. When an administrator customizes a template in a language its package does not ship (for example a `zh-CN` version of a template the package ships in `en-US` only), that version is the environment's own item. Until now it was registered and served wearing the package's protection envelope, taken from the shipped `en-US` version: + + - the registry's artifact lookup answered the environment's `zh-CN` version as the package's own `zh-CN` artifact; + - `GET /meta/email_template/:name?member=zh-CN` and the `/meta/email_template` list served it with `provenance: 'package'`, the package's id and its version; + - the registry heal that runs after `DELETE ?member=zh-CN` refused to retire it as packaged code. + + - `@objectstack/metadata-protocol`: the artifact envelope (`_packageId`, `_packageVersion`, `_provenance`, and an artifact lock) is copied only onto a version of the same language as the shipped artifact. Each place that copies it looks up the artifact of the body's own language first. A language the package does not ship is registered and served as the environment's item. A language the package does ship keeps its envelope, its lock and its reset exactly as before. Types without languages are unchanged. + - `@objectstack/objectql`: `SchemaRegistry.removeRuntimeShadow(type, name, member)` lifts a named language's shadow only when the package ships that language. For any other language it declines, and the heal's last step (`removeOverlayEntry`) retires the entry. Naming no language still sweeps the whole bundle, as before. + + A lock still binds the template name, at the read and at the write gate alike: a template the package locks stays locked in every language. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [6c17a50] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [d8c7d38] +- Updated dependencies [2e11d90] +- Updated dependencies [7dbb89d] +- Updated dependencies [6a7eca6] +- Updated dependencies [63d5db2] +- Updated dependencies [a9bcbc6] +- Updated dependencies [33ae4bf] +- Updated dependencies [ebbb8f9] +- Updated dependencies [07ee6f6] +- Updated dependencies [61bea24] +- Updated dependencies [762db99] +- Updated dependencies [d1c226d] +- Updated dependencies [996aa86] +- Updated dependencies [23419ba] +- Updated dependencies [54d32e5] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [d1dbe70] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [6981246] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [0f77ff5] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [9f15939] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [eed637c] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [32fa194] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/lint@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/metadata@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + - @objectstack/sdui-parser@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 693a62336ba..1998936961d 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "ObjectStack metadata management protocol: sys_metadata CRUD, draft/publish, locks, package ownership, diagnostics (ADR-0076).", "type": "module", diff --git a/packages/metadata/CHANGELOG.md b/packages/metadata/CHANGELOG.md index c561e1b82fb..b587fe86711 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,318 @@ # @objectstack/metadata +## 18.0.0-next.0 + +### Minor Changes + +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- 0fea05f: `os serve `: a multi-package config now serves the top-level metadata no package declares, under the stack's `manifest.id`, and the boot says so, as an artifact boot of the same project already did + + Clause-②: yes + + A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]` registers each package body and never the stack's top level. A top-level item that no package body declares was served by no door, and the boot said nothing. Three shapes reached it: flat `src/docs/*.md` pages when the stack's `manifest.id` names no `packages[]` entry, inline `docs` spread onto a composed stack's top level, and any other top-level collection (objects, views, …) that no package carries. `os build` of the same project, booted as an artifact, registered those items under the stack's `manifest.id` and warned. + + - **What changes.** The config boot now runs the same residual rule as the artifact boot, `MetadataPlugin.registerUnclaimedTopLevel`. Every top-level item no package body declares is registered under the stack's `manifest.id`, and the boot prints one `warn` line with the count: `config stack '' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy. `GET /api/v1/meta/` lists those items under that id, as on an artifact boot of the same project. This includes the flat `src/docs/` pages of a stack whose `manifest.id` names no package, which a config boot did not list before. This entry supersedes, in the same release, the note in the `os serve ` flat `src/docs/` entry that "A config boot of such a stack still does not list them": it now does, under the stack's `manifest.id`, with the warning. + - **A new refusal, in one corner.** A residual view container is judged like any other: a top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot with ``Invalid `views:` container from manifest '': the container's own `name` is …``. On the previous release that config booted, because its top level was never read; an artifact boot of the same project already refused it. `os validate` and `os build` refuse the same container first, in the same words, so the refusal is never a boot-time surprise. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to. + - **The remedy the line names.** Declare each such item inside the `packages[]` entry of the package that owns it. A flat `src/docs/` page moves under `src//docs/`, where `` is the package id or its last dot-separated segment. + - **What widens, in `@objectstack/metadata`.** Its root entry now exports `unclaimedTopLevel(stack)`, the pure residual decision both boot doors and `os validate` read, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call. + - **What stays the same.** An artifact boot registers the same items and prints the same line, byte for byte. A single-package config (no `packages[]`) has no residual and is unchanged. A composed stack whose top level repeats its package bodies, as `composeStacks([…], { manifest: 'preserve' })` writes it, registers nothing extra and prints nothing. + - **What the residual is, on both doors.** It is metadata: a residual object is listed on `GET /api/v1/meta/object` under the stack's `manifest.id`, and `GET /api/v1/data/` still answers `404`. That was already true on an artifact boot, and the config boot now gives the same answer. + +### Patch Changes + +- d0bb78e: fix(runtime,metadata): publishing or reverting a read-only package is refused with `422 WRITABLE_PACKAGE_REQUIRED`, and package membership reads the `_packageId` stamp + + Clause-②: no + + ADR-0070 D2 makes a code or installed package read-only. `POST /api/v1/packages/:id/publish` and `POST /api/v1/packages/:id/revert` did not check this. These two answers change: + + - **Publish of a code or installed package: 200 → 422.** Before, the publish door answered 200. For a package whose items carry an authored `packageId`, for example `com.example.showcase`'s two capabilities, it answered `success: true` and wrote `publishedDefinition`, `state` and `version` onto those read-only items. For every other code package it answered `success: false`, "No metadata items found". It now answers `422 WRITABLE_PACKAGE_REQUIRED` before anything is written, the same refusal that `PATCH /packages/:id/disable` and `DELETE /packages/:id` already give. + - **Revert of a code or installed package: 404 or 409 → 422.** Before, the revert door answered `404 RESOURCE_NOT_FOUND` "No metadata items found" (for example `com.objectstack.setup` and the platform packages that ship objects) or `409 RESOURCE_CONFLICT` "Package '…' has never been published" (for example `com.example.showcase`). It now answers `422 WRITABLE_PACKAGE_REQUIRED`. The check runs after the protocol's stored-row answer, so a revert of a code package that has a stored row bound to it, such as an organization overlay draft, keeps its answer. + + To customise what a code package provides, use an ADR-0005 organization overlay. Overlay drafts publish through `POST /api/v1/packages/:id/publish-drafts` and the per-item publish door, and this change leaves both alone. + + **Unchanged:** a writable package's publish and revert, and an id that nothing carries (revert 404; publish 200 with `success: false`). + + `MetadataManager.publishPackage` and `revertPackage` now find a package's members by `packageId`, `package` or the private `_packageId` stamp. The artifact loader writes that stamp through `applyProtection`, and the ObjectQL object bridge copies it onto every object it registers. Before, an item that carried only the stamp was not a member. `MetadataManager` has no notion of package kind; the refusal of read-only packages lives at the two doors above. +- 1959aac: feat(spec)!: `defineStack` refuses two entries of one named collection under one identity, naming the collection, the identity and every index — for 27 collections (#22934) + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + The runtime registers the entries of these collections under one identity each, and a stack could author two entries under one identity with nothing refusing it: `defineStack` and `objectstack validate` accepted the pair (exit 0, `valid: true`, no finding naming it). Measured on a source boot (`objectstack serve`, no build) and on `objectstack build` followed by an artifact-backed boot: + + - `objects`, `pages`, `dashboards`, `reports`, `datasets`, `flows`, `jobs`, `emailTemplates`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `webhooks`, `mappings`, `analyticsCubes`, `connectors`, `picklists`, `tools`, `skills`, `views`, `apis`, `apps` and `objectExtensions` kept only the LAST entry on both paths, with no notice at the default log level (for `apis`, the first entry's path answered 404); + - `datasources`: the metadata described the last entry while the driver connected with the first one's configuration; + - `hooks`: the metadata listed one hook while both handlers were bound and ran; + - `docs`: `objectstack validate` and `objectstack build` refused the pair (`docs/duplicate-name`), while a source boot kept the last. + + **What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per collision, naming the collection, the identity and every index: + + ```text + ✗ Datasource 'crm_external' is declared twice in `datasources`: stack.datasources[0] and stack.datasources[1]. … + ``` + + The identity is what the runtime keys an entry by: its `name`; the `(name, locale)` pair for `emailTemplates` (one name across several locales is an i18n bundle, and an entry with no `locale` counts as `en-US`); the extended object (`extend`) for `objectExtensions`; and for `views` the object a container binds to (`object`, else `list.data.object`, else `form.data.object`, else its `name`). The check runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. On an `app` package, two apps under one name now get this refusal, which runs before the single-app count. + + **Unchanged.** Distinct identities; an email-template i18n bundle (one name, one entry per locale); the map form under distinct keys (two keys whose entries spell one explicit `name` are one identity, and are refused); the same name reused by another package of a multi-package project (`composeStacks(…, { manifest: 'preserve' })` keeps each package's collections in its own `packages[i]` body, which this check does not read); `plugins` (last-one-wins with a warning is the kernel's declared contract), `agents` (withdrawn for app packages) and `picklistExtensions` (merged by option value); and the existing `actions` and `functions` refusals. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `pages: [{ name: 'home', … }, { name: 'home', … }]` (any of the 24 name-keyed collections) | one entry per name: rename one (`home_admin`) and every reference that meant it, or merge the two into one entry | + | `emailTemplates: [{ name: 'acme.welcome', … }, { name: 'acme.welcome', locale: 'en-US', … }]` | one entry per name and locale: give the second its own `locale` (`zh-CN`), or merge the two | + | `datasources: [{ name: 'ext', config: a }, { name: 'ext', config: b }]` | one datasource per name — the driver used to connect `a` while the metadata described `b` | + | `hooks: [{ name: 'audit', object: 'a', … }, { name: 'audit', object: 'b', … }]` | one hook with `object: ['a', 'b']`, or two hooks with two names | + | `apis: [{ name: 'feed', path: '/api/v1/apps/x/a' }, { name: 'feed', path: '/api/v1/apps/x/b' }]` | give each endpoint its own name | + | `views: [defineView({ object: 'task', list }), defineView({ object: 'task', form })]` | one container per object: `defineView({ object: 'task', list, form })` (named views go in `listViews` / `formViews`) | + | `objectExtensions: [{ extend: 'task', fields: { a } }, { extend: 'task', fields: { b } }]` | one extension per target: `{ extend: 'task', fields: { a, b } }` | + + **The one-line fix: give each entry of a collection its own identity, or merge the duplicates into one entry.** + + No D2 conversion rewrites it: which entry keeps the identity, and what the other one becomes, is the author's call. Its D3 record is the semantic entry `stack-collection-duplicate-entry-refused` (protocol 18). + + **`@objectstack/metadata`.** `deriveViewContainerObject` — the "which object does a `views` container bind to" derivation the registrars file containers by — moved into `@objectstack/spec` (exported from its root and `./ui`), because the stack contract now judges the same slot. `@objectstack/metadata` re-exports that one declaration under the same name, from its root and from `@objectstack/metadata/view-container`; behaviour is unchanged. That subpath used to import nothing and now loads the `@objectstack/spec` root entry, which every in-repo importer of it already loads. + + **Who is affected, measured.** The four example apps (`examples/app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) carry no duplicate under these identities. Deployed metadata and other repositories were not measured. +- 58f5c90: fix(metadata-core): an artifact declaring `engines.protocol: '^18'` that was built during the v18 prerelease boots on a later runtime that retired one of its shapes — the artifact door replays every retired conversion of protocol 18's step for a floor of `18.0.0` + + Clause-②: yes + + **What an operator saw.** A compiled artifact (`os build` → `dist/objectstack.json`) that declares `engines.protocol: '^18'` and was built before a protocol-18 retirement landed carries the retired shape, for example a defaulted key that every earlier parse emitted. Booted on a runtime that enforces the retirement, the metadata plugin's startup refused it at the strict parse and the boot rolled back. The artifact door compared the floor `18.0.0` with each retirement's `retiredAfter` stamp, and every retirement that lands during the v18 prerelease is stamped with the last 17.x release, below that floor, so nothing was replayed. + + **What an operator sees now.** The same artifact boots. One warning per conversion names it and its site count: `[MetadataPlugin] artifact 'LABEL' declares the first release of protocol 18 (authored engines.protocol floor 18.0.0), which a build from any 18.0.0 prerelease declares too, so it may predate retirements in the conversion step into protocol 18 that this runtime enforces (runtime spec VERSION) — converted N site(s) forward via ADR-0087 conversion 'ID' …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. An artifact built after the retirement boots exactly as before: the replay finds nothing to convert, and nothing is logged. + + - **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` opens its window on a third condition. A declared floor of `M.0.0` at or above the runtime label replays every retired entry whose `toMajor` is `M`, except the default flips the door already refuses. An artifact cannot say which of those entries it predates: it carries no field naming the spec or CLI that built it. Each entry rewrites only its own old shape, so the replay changes nothing in an artifact that lacks that shape. `ArtifactForwardConversionVerdict` gains `'converted-major-step'`, so a consumer that switches exhaustively over the verdict adds that arm. `replayedRetirements` still lists only the entries the per-entry rule opened. The rule holds for every 18.0.0 prerelease label and at 18.0.0 itself. From 18.0.1 on, the label half replays the whole chain for such an artifact, as before. + - **`@objectstack/metadata`.** The artifact door places the new verdict on the open side of its window table and gives it its own clause, so the summary never claims that a `^18` artifact "predates" a runtime labelled below it. The bare-root form-predicate notice uses the same window. + + **Unchanged.** The protocol handshake still refuses a `^17` artifact on a protocol-18 runtime and prescribes `objectstack migrate meta --from 17`. A floor above `M.0.0` (such as `^18.0.1`) and a 17.x floor keep their previous verdicts. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [a3d47c2] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/metadata-fs@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 1cbe5edd757..e2ca6a5dd26 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Metadata loading, saving, and persistence for ObjectStack", "type": "module", diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md index c3b84440ee1..45f729dacf6 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,1028 @@ # @objectstack/objectql +## 18.0.0-next.0 + +### Major Changes + +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- fe98cc6: feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a package registering a name that an installed package, the environment catalog or a built-in already holds is refused, naming both holders + + Clause-②: no + + + + **BREAKING** — an accept-set narrowing at the package registration door, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose packages share a position, permission set or capability name booted before this release and is refused at boot after it. + + **Why.** An assignment names a position or a permission set by its bare name, with no package to tell two definitions apart (ADR-0131 D4). Before this release two installed packages could ship one name, and which definition granted depended on registration order. Measured on a booted kernel with two packages sharing one name per type: the by-name catalog read resolved the permission set and the capability to the first-registered package, and the position to the last-registered one. An app declaring the platform's own `admin_full_access` registered beside it, and the by-name read answered the app's set. The maintainer ruled the security catalog out of ADR-0048 §3.4's cross-package coexistence: each of the three types holds one namespace per deployment. Every other metadata type keeps §3.4's coexistence unchanged. + + **What is refused, and where.** `SchemaRegistry.installPackage` refuses a package whose declared `positions`, `permissions` (permission sets) or `capabilities` — top level, or on a nested `plugins[]` entry — name something another holder already holds. It refuses ahead of every mutation, so a refused package leaves no record behind. The holders are: + + - another installed package; + - the environment catalog: an item authored in this environment, in the registry's bare slot with no package; + - a built-in: the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) and the curated platform capabilities (`manage_users`, `setup.access`, `studio.access` and the rest of `PLATFORM_CAPABILITIES`). + + The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. The platform's own declaration of a built-in name there (`@objectstack/plugin-security` declaring the six built-in positions) is the built-in holder's, so it is never refused. An environment item already stored under that name keeps answering first. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `install-local`'s offline (inline-manifest) import answers `422` under that route's own `PLUGIN_REGISTER_FAILED` code, with this refusal's message in `error.message`, and records nothing. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`. + + **What an author sees.** The boot, or the install, fails with an ADR-0112 envelope: `code: 'NAMESPACE_CONFLICT'` (the code the namespace gate already carries; `NAMESPACE_CONFLICT_CODE` is exported) and `status: 422`. The message names the incoming package and the existing holder of each conflicting name, all conflicts in one message. The thrown error carries `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder }`, where `existingHolder` is `{ kind: 'package', packageId }`, `{ kind: 'environment' }` or `{ kind: 'built-in' }`. **The one-line fix: rename the item in one of the two packages, or uninstall one of them.** A built-in name is never available to a package. An assignment that named the old name must name the new one; nothing rewrites stored assignments. + + **What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. At cold boot, packages register before the environment catalog loads from `sys_metadata`, so this door cannot see an environment-held name then; the engine checks every package-held position and permission-set name against the environment catalog right after it loads, and refuses the boot with this envelope (the cold-boot entry in this release). + + **Measured producers.** On objectstack `1604e094f5`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) and the platform built-ins carry 50 catalog declarations, and no name has more than one holder. Deployed and marketplace packages NOT MEASURED. +- e030d43: feat(objectql)!: a cold boot refuses a package-held position or permission-set name the environment catalog already holds, as a hot install does + + Clause-②: no + + + + **BREAKING** — an accept-set narrowing at boot, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose environment catalog holds a position or permission-set name that a configured package also declares booted before this release and is refused at boot after it. + + **Why.** Positions, permission sets and capabilities hold one name per deployment, and a package registering a name the environment catalog already holds was already refused on a hot install. A cold boot did not refuse it: every package registers in the kernel's first phase, before the environment catalog loads from `sys_metadata` in the engine plugin's `start()`, so the package door could not see the environment's name. The stored row loaded over the package's definition, the registry printed a `[Registry] Collision` warning, and the by-name read served the environment's definition in place of the package's. The maintainer ruled that the cold boot refuses too, so that a cold boot, a hot install and an artifact boot answer alike (ADR-0048 addendum N.3). + + **What is refused, and where.** Right after `sys_metadata` hydration in `ObjectQLPlugin.start()`, before any plugin that depends on the engine starts, the engine checks every package-held position and permission-set name against the environment catalog's items. One such name fails the boot. Every conflict is listed in one refusal. The check reads the two types an environment can author: the runtime metadata API refuses to create a capability (`403`, a code-only type), so the environment catalog holds none. The hydration write itself is still not judged. + + **What an operator sees.** The kernel reports `Plugin com.objectstack.engine.objectql failed to start`, and the cause is the package door's envelope: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE` is exported), `status: 422`, and `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder: { kind: 'environment' } }`. The message names the package that declares each name and the environment catalog that holds it. + + **The upgrade shape.** A deployment fails to boot after this release when an active, environment-wide `sys_metadata` row of type `permission` or `position` (or the legacy plural `permissions` / `positions`, which the boot's load folds to the same types) has the name of a permission set or position that a configured package declares. That includes a row saved over a package-held name before the packaged locks refused such saves, whether or not the row was bound to the package, and a row over one of the platform security plugin's own permission sets (`member_default`, `admin_full_access` and the rest it declares). **Run `os migrate security-catalog-overlays` before the first v18 boot**, with the flags and environment the deployment boots with: it lists exactly these rows, and with `--apply` deletes them. + + **The one-line fix: before the first v18 boot, run `os migrate security-catalog-overlays` to list the rows, then `os migrate security-catalog-overlays --apply` to delete them; or rename the item in the package.** + + - **Before upgrading, for a permission set.** On the release you run now, a boot whose environment catalog overlays a package-declared permission set logs at `kernel:ready`: `[security] N package-declared permission set(s) are being shadowed by an environment overlay`, with the set names. Those are the permission sets this release refuses at boot. The audited **Discard Overlay** action on the set's record in Setup (`POST /api/v1/security/permission-sets//discard-overlay`, documented under "Declared ≠ enforced" on the Permission Sets page) removes the overlay and resyncs the set to the package's definition. So does `DELETE /api/v1/meta/permission/`, which answers "Customization overlay deleted … reset to artifact default". Either works for the platform security plugin's own sets too, and neither needs direct database access. Positions have no such reading and no such action. + - **After upgrading, for any refused name.** The same step: `os migrate security-catalog-overlays`, then `--apply`. It needs no server, so a deployment whose boot is refused can run it, and it covers permission sets and positions, a name the platform security plugin declares, and a row stored under a legacy plural. + - **What the step deletes.** The rows that refuse the boot are the active, environment-wide ones of a package-held name: `organization_id IS NULL` and `state = 'active'`, whatever their `package_id`, under the type or its legacy plural. In SQL, for one permission-set name, the step's deletion is `DELETE FROM sys_metadata WHERE organization_id IS NULL AND state = 'active' AND type IN ('permission', 'permissions') AND name = '';` (for a position, `type IN ('position', 'positions')`). The step runs it through the metadata write path, so each deleted row leaves a `sys_metadata_history` tombstone. A draft row and an organization-scoped row are not loaded at boot and do not refuse it. + + `DELETE /api/v1/meta/permission/` and `DELETE /api/v1/meta/position/` reach a row stored under `permission` or `position` only, one row per call. A row stored under the legacy plural `permissions` / `positions` is not reached: the call answers `200` that nothing was found and removes nothing. Where a name has two active rows, for example one bound to no package and one bound to the package, each call removes one. A plural-typed row is removed by Discard Overlay before upgrading (a permission set), or by `os migrate security-catalog-overlays --apply`, for any name. + + `os migrate security-catalog-overlays` is the one `os` command that deletes these rows with no server running; `os meta delete` and `os data delete` call a running server. Nothing renames or removes either item automatically: the step deletes only under `--apply`, and adopts nothing. + + **What is NOT refused.** A stored definition under a built-in position name (`org_admin`, `everyone` and the other four): the platform declares its built-in positions itself, after this check, and the stored definition keeps answering first. The same package restarting with its own names. A package whose names the environment catalog does not hold, booting beside the environment's own items. + +### Minor Changes + +- 34dba5a: A system-context insert under the `single` tenancy posture is refused when the install holds no organization, instead of landing with no owner + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9). + + - **Before.** Under the `single` posture, in a composition that registers the organization object, a system-context insert on a tenant-scoped object with no organization anywhere landed with `organization_id` NULL when the install held no organization yet. That was the normal state of a first boot: the organization arrived with the first sign-up. + - **Now.** The Default Organization is a boot invariant under `single` (ADR-0131 D3): `@objectstack/plugin-auth` creates it before the application seeds load and before the server accepts a request. An install that registers the organization object and holds none of it therefore has no owner to derive, and the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500, `reason: 'no-organization'`). The message names the missing organization. Nothing is written. + - **Unchanged.** Exactly one organization is derived and stamped. Several organizations, or a walled posture, are refused as before. A write that carries an organization, on the execution context or on the record, is never refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. The platform-namespace objects the tenancy inventory has not admitted keep their per-object exclusion; ADR-0131 C8 retires it. A composition that registers no organization object at all (a lean embedding) still lands the write unstamped. + + **The remedy.** On a `single` deployment, the refusal means the Default Organization is missing: restart, and the auth plugin recreates it at boot, or carry the organization on the write (`{ context: { isSystem: true, tenantId } }`, or `organization_id` on the record). A TypeScript caller of `resolveSystemWriteOrganization` passes whether its composition registers the organization object. +- c8a9783: feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. + + - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. + - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. + - **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. + - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. + + Added (additive, nothing an author writes changes): + + - `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. + - `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. + - `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `bootstrapDeclaredPermissions` + - `normalizeManagedByVocab` + - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` + - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` +- 4806b18: Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). + + **Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): + - the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; + - the owner's `install-local` uninstall of that package took the set away from that member; + - the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. + + The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. + - `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. + - `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. + + **Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. + + **The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 4578c56: fix(import): `createMissingOptions` now keeps an unmatched option value through the engine's option check + + Clause-②: yes (widening) + + - **What was wrong.** With `createMissingOptions: true` on an import (objectui's Import Wizard sends it for *Keep unknown option values*), the import coercion kept a `select` / `radio` / `multiselect` cell that matched none of the field's options. The engine's write-path option check then refused the same value ("Priority must be one of: high, low"), so on every writable field the row still failed. Only the stage that refused it changed. + - **What happens now.** The import stores the kept value: a single-value cell trimmed, and each unmatched item of a multi-value cell. The dry run admits the same rows the write does. The engine admits exactly the values the import kept, on that import's writes. Every other value outside the options is still refused, and a field bound to a shared picklist that did not resolve is still refused. + - **The field's options do not change.** No option is added to the field. A later write that sends the field is judged against the options again, so sending the kept value back is refused and picking an option is accepted. A write that leaves the field out, such as an edit form saving only the fields it changed, is not judged on that field. + - **`@objectstack/spec`.** `ExecutionContext` declares `keptOptionValues`: per field name, the option values an import kept. It is server-constructed only, like `skipStateMachine` and `preserveAudit`, and is never client-supplied. The `ImportRequest.createMissingOptions` description now states exactly the behaviour above. + - **`@objectstack/core`.** `coerceRow` returns `keptOptionValues` beside `data` and `errors`, and `coerceFieldValue` reports the values it kept. `runImport` puts exactly those values on each write's context. A per-row write carries its own row's values, and a batched create carries the union of its rows' values. A row that kept nothing is written with the import's context unchanged. + - **`@objectstack/objectql`.** `ValidateRecordOptions.keptOptionValues` lets the single-value and multi-value option arms admit a listed value. The engine fills it from the write's `ExecutionContext.keptOptionValues` on insert, update and `validate`. +- f05649f: A `beforeInsert` hook is no longer shown a caller-supplied value for a `readonly` field, so a hook that stamps an absent read-only column stamps it even when the caller sent one. + + Clause-②: no (narrowing) + + + + **BREAKING** — what a `beforeInsert` handler reads on `ctx.input.data` changes on a non-system create. This ships as `minor` under the launch-window convention for breaking changes. + + - **What it reads now.** A caller-supplied value for a field the create-side static `readonly` strip takes is not on `ctx.input.data`. The field holds its `defaultValue`, or there is no key, which is exactly what the hook sees when the caller did not send the field. + - **Which fields.** The withheld set is the create strip's own subject (`staticReadonlyInsertSubject`): author-declared `readonly: true` fields that are not runtime-owned, on an object outside the `sys_` namespace and the platform-internal `managedBy` buckets. It has two edges: + - a runtime-owned `autonumber` value stays visible to the hook; + - under `preserveAudit`, the audit timeline (`created_at`, `created_by`, `updated_at`, `updated_by`) stays visible, because the historical-import channel reinstates it through a `beforeInsert` hook. + - **Two answers at the REST door change.** + - A hook that self-assigns a withheld field with no `defaultValue` (`data.x = data.x`) used to turn the caller's forged value into a hook write, which was stored. Under `strictReadonlyWrites` that create now answers 400 where it answered 201. + - A hook or sandboxed `body` that reaches through a withheld field (`ctx.input.meta.who = …`) now faults, and the create answers 400 naming the field where it answered 201. + - A `system`-context create (`isSystem`) is unchanged: the strip does not apply to it, so nothing is withheld. + + ## The defect + + On a non-system create, the engine took a caller's value for a static `readonly` field out of the row only after `beforeInsert` had run. A hook that stamps such a field only when it is absent (`if (!data.stage_entry_date) data.stage_entry_date = today`) saw the caller's value and did nothing, and the strip then dropped that value. The row stored NULL where the hook would have stamped today. Measured on a real engine over the SQL driver: with the key in the payload, the row stored `stage_entry_date` and `days_in_stage` NULL; without it, the hook stamped both. This is the insert-side twin of the `beforeUpdate` rule: a hook is never handed a value that will not be stored. + + ## What did not move + + - **The strip still runs after the hooks.** The caller's values are handed back to it there, wherever no hook wrote the key. So `onFieldsDropped`, the read-only WARN and `strictReadonlyWrites` judge the same payload as before. + - **A hook that assigns a read-only field still has its value stored.** + - **A self-assignment (`data.x = data.x`) of a withheld field** follows the engine's rule: + - with no `defaultValue` the hook reads `undefined`, the write is undone, and the caller's value is stripped and reported. `undefined` is never stored. + - with a `defaultValue` the hook reads and re-assigns the default, which counts as the hook's write. The default is stored, and nothing is reported. + - **Faults are explained.** A hook that faults reaching through a withheld field is answered 400 naming the field, the way `beforeUpdate` already answers. + + ## Who is affected + + A `beforeInsert` handler that reads a `readonly` field from `ctx.input.data` on a non-system create. + + - **A handler that derives a value from the field:** the value it used to read was never stored, so it should derive nothing from it. + - **A handler that owns the field:** it assigns the field. + - **A handler that reports what the caller sent:** a create has no `ctx.submitted` to read it from. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- 440bed6: A select option whose `visibleWhen` cannot be evaluated refuses the write instead of admitting the value with one warn line (#22402) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing on `@objectstack/objectql`, shipped as `minor` under the launch-window convention for breaking changes (recorded in `scripts/check-changeset-no-major.mjs`). The governing decision is ADR-0137 D2, which the maintainer's ruling on #22402 (letter A) extended to the option gate on the write path. + + A select, multiselect, radio or checkboxes option may gate itself with a `visibleWhen` predicate, and the server re-checks it for every value a write picks. ADR-0137 D2 says a field-rule predicate that cannot be evaluated refuses the submit. The option gate is the server's enforcement of who may pick the option (ADR-0124 D1), so D2 now reaches it on the write path too. + + - **Before.** When an option's predicate faulted on a write (an undeclared key, a root or member the gate does not bind, a read through a reference, a computed key or receiver, `current_user.can()` with no permission data), the server logged "the option's gate was NOT enforced on this write" and admitted the value. A role-gated option whose rule was broken was open to every caller. + - **Now.** The write is refused with `VALIDATION_FAILED` (HTTP 400) and one field entry in the envelope a faulting `requiredWhen` or `readonlyWhen` already uses: `code: 'rule_violation'`, `constraint.reason: 'unevaluable'`, `constraint.rule: 'visibleWhen'`, `constraint.fault` naming the fault, and the picked option as `value`. The message names the option, the field and the fault, for example `Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected.` Nothing is written. This holds on insert (one row or a batch), by-id update and bulk update, and so on an import, which writes through them; `validate()` previews the same refusal. + - **Unchanged.** A predicate that evaluates is judged exactly as before: false refuses with `invalid_option`, true admits. A system write with no acting user whose predicate reads the acting user (`'admin' in current_user.positions` on a seed) is still admitted and logged with reason `no-acting-user`, because the gate has nobody to ask about. A system write whose predicate faults for any other reason is refused, as a faulting `requiredWhen` is. The form still offers an option whose predicate faults (ADR-0137 D3); the refusal comes at save. + - **`current_user.can()` with no permission data.** On an engine with no effective-permission resolver registered (no security plugin composed), a write by an acting user that picks a `can`-gated option is now refused, naming the missing permission data, instead of admitted. + - **A preview limit this inherits.** An `update`-mode `validate()` preview (an import dry run of a matched row) reads no stored row, so a cascade predicate whose parent column the patch omits faults there and the preview now refuses the row, as it already refuses a `requiredWhen` that reads an omitted column. The write itself reads the stored row and judges the predicate cleanly. + + **The remedy: fix the predicate, which `os build` names for every statically judgeable shape.** The shapes no build can judge (a computed key, a computed receiver, a row stored before the build verdicts, or a save under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`) now meet this refusal at the first write that picks the option, naming the fault. + + Measured before the change, on `origin/main` 3054516ef1: the objects this repository ships carry 5 option predicates, all on the showcase's `showcase_cascade` (four `record.country == …` cascades and one `'org_admin' in current_user.positions` role gate), and none of them faults on a write. Insert and update evaluate them over a total record, so a payload without `country` reads `null == 'cn'`, a clean false. No seed or dogfood write picks a gated option. + + Earlier entries in this release that say an option predicate which faults is still logged and admitted describe the gate before this change. + + `@objectstack/lint` and `@objectstack/spec`: the published text that said the server admits a faulting option predicate now says it refuses the write. This covers the `objectstack validate` finding for an option predicate that reads through a reference, a root or member the gate does not bind, and the `SelectOption.visibleWhen` description. No verdict moves. +- 35ef501: An `update`-mode `validate()` preview judges the stored row merged with the patch, as the by-id update does, so an import dry run of a matched row admits the rows the import's update admits (#22445) + + Clause-②: yes + + The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. + + - **Before.** An `update`-mode preview (`engine.validate(object, rows, { mode: 'update' })`, `validateData`, and the import dry run of a row that matched a stored record) read no stored row, so the record its rules judged held only the patch. A `requiredWhen`, an option `visibleWhen` or a `validations[]` rule that reads a column the patch omits faulted there, and the preview refused the row with `rule_violation` / `unevaluable`, although the real update reads the stored row first and admits it. The refusal also said the omitted column was one "which this object does not declare", sending the author looking for a field that exists. + - **Now.** A row that carries its `id` (the address every update door already folds into the payload) is judged against the row that id names: the stored row is the rules' `previous`, and their record is the stored row merged with the patch, exactly as the by-id update judges it. A reference field a traversing rule reads is resolved from that merge too. The import dry run of a matched row sends the matched record's id this way, so nothing new crosses the protocol. + - **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it). A stored column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a value the caller could not read in full. File fields are compared in their stored form, so a readable file reference is judged as the id it holds. Measured on `driver-sql` (SQLite) and `driver-memory`, a plain readable column (number, currency, percent, boolean, date, datetime, JSON, multiselect, select, text) reaches the rules as its stored value. + - **Unchanged.** A row with no `id`, or whose id names no row the caller can read, is judged on the patch alone, as before. `required` and value checks still look only at the supplied keys, matching a PATCH. A merge that really violates a rule is refused, as the write refuses it. The preview still runs no `readonlyWhen` or primary-key strip, so it can report fewer dropped fields than the update. + - **The refusal names the real cause.** Where a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row), the refusal now says the value was not supplied and no stored row was read, instead of saying the object does not declare the column. A column the object really does not declare keeps the old sentence. + + `@objectstack/core`: the import runner's dry run passes the matched record's id with an update row. `@objectstack/spec`: the `ValidateDataRequest.mode` description and the `ValidateDataResponse` note say what an `update` preview now reads. + + The pending entry for the option-gate change in this release says an `update`-mode preview reads no stored row and refuses a cascade pick whose parent column the patch omits. That describes the preview before this change: with the row's `id`, the preview now reads the stored row and judges the pick as the write does. +- da989bb: A `validate()` preview binds the master-detail header the write binds, so an import dry run of a detail row whose field rule reads `parent` admits the rows the import admits (#22474) + + Clause-②: yes + + The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. + + - **Before.** A preview (`engine.validate(object, rows, { mode })`, `validateData`, and the import dry run) bound no master-detail header. A field `requiredWhen` that reads the header, such as `parent.status == 'sent'` on an invoice line, faulted there and the preview refused the row with `rule_violation` / `unevaluable`, in both modes, although the insert and the update resolve the header and admit it under a draft header. + - **Now.** The preview binds `parent` as the write binds it: from the row's master-detail reference on an insert, and on an update from the reference the patch carries, else from the stored row the row's `id` names. An update that moves the row to another header also binds the header the stored row hangs off, which the write reads to decide whether the stored row already violated the rule. So a row under a draft header is admitted and a row that leaves the field empty under a sent header is refused with `required`, as the write refuses it. Only an object that declares a field `requiredWhen` reading `parent` reads a header, as on the write. + - **Read under the caller's access.** The write reads the header with system access, kept to the caller's organization. The preview first reads it through the engine's read door, under the caller's own context: a header the caller cannot read gets the verdict a missing header gets. A header column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a header value the caller could not read in full. For such a caller the preview can therefore answer differently from the write, which judges the stored header. + - **Unchanged.** An object with no field `requiredWhen` reading `parent` reads no header and gets the verdict it got before. The preview still runs no `readonlyWhen` strip, so an update whose `readonlyWhen` would keep the row on its stored header is judged against the header the patch names. +- 018b077: fix(objectql)!: a detail write asks the caller's read door for its master-detail header before judging it — a header the caller cannot read answers as a header that exists nowhere (#22519) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A write of a master-detail DETAIL reads its header for two things: the reference check of the `master_detail` value the caller supplied, and the `parent` that a parent-scoped `requiredWhen` / `readonlyWhen` judges. + + **FROM.** Both read the header elevated (row-level access bypassed, the caller's tenant kept), on the premise that the caller's right to write the detail settled its right to see the header. ADR-0055 §3 guarantees that only for a `controlled_by_parent` detail (its master-edit gate). For any other detail, a non-system caller who could write the detail but could not read its header met: + + - a reference check that admitted the header id, where a header id that exists nowhere answers `reference_not_found`; + - a parent-scoped rule judged against the header's stored state, so the write's answer depended on a row the caller cannot read. + + **TO.** The header is read in two steps, the shape `validate()`'s preview already uses: + + 1. The caller's read door, under the caller's own context, so every enforcement layer in the middleware chain answers it. A header it does not return (hidden by row scope, a refused read of the header object, or a failed read) binds as a missing header binds: the reference check answers `reference_not_found` (`400 VALIDATION_FAILED`), and a parent-scoped `requiredWhen` / `readonlyWhen` meets an unbound `parent` (the requirement is refused as unevaluable, the lock holds), exactly as for a header id that exists nowhere. + 2. The elevated, tenant-kept read, only for the headers step 1 returned. A header the caller can read is still judged on its STORED values, a column field-level security hides or masks for the caller included. The preview's per-column masking stays the preview's own. + + **Bindings.** + + - Every detail, `controlled_by_parent` or not, on every write door: `insert` (single and batch), `insertMany`, update by id (a repoint included) and the predicate (bulk) update. + - System callers (`isSystem`: seed replay, boot, package install) ask no read door and resolve exactly as before. + - A plain `lookup`'s reference check keeps its elevated existence probe, unchanged. + - No new error code and no status change: the answers are the ones a header that exists nowhere already got. + - Cost: one id-projected read under the caller's context per header resolution (once per batch), and one per supplied `master_detail` value in the reference check. + + **Fix, if a write relied on the old answer:** give the writer read access to the header — an object permission or sharing rule on the master, or declare the detail `controlled_by_parent` so the header's access governs it. + + **Not measured.** Which deployed configurations let a caller write a detail whose header it cannot read was not measured: the rule only tightens, so it holds whatever that answer is. +- 02d9f69: A file field whose `sys_file` read was refused to the reader reads as refused, not as "no file" + + Clause-②: yes + + The engine expands a stored file id (`file` / `image` / `avatar` / `video` / `audio`) by reading `sys_file` as the caller. A reader who may read the record but not `sys_file` (the default for any member whose application grants no `sys_file` read) is refused that read, and the field used to come back as the bare id: the same value an id with no committed file row produces. A refused file and an absent one were one answer, and the server logged a `warn` per read telling the operator to check storage availability. + + - **From.** `{ "attachment": "f_7cQx2Lm90a" }` for a reader refused `sys_file`, plus `warn: sys_file lookup failed; file fields keep their raw ids and will render as "no file"`. + - **To.** `{ "attachment": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }`. Multi-value fields mark each id. Nothing else is served, because nothing else was read: no `name`, `size`, `mimeType` or `url`. A consumer that needs the download link derives the stable `/api/v1/storage/files/:fileId` endpoint from the id, as it does for a bare id; the download door judges access by the record that owns the file. The refusal is logged at `debug` only, because the reader is told in the value. + - **Unchanged.** A reader allowed to read `sys_file` gets the hydrated `{ id, name, size, mimeType, url }`. An empty field reads empty. A missing `sys_file` table still leaves the bare id, silently. Any other read failure (connection, timeout, a database-level ACL fault such as `42501`) still leaves the bare id with the `warn`. Only the security layer's `PERMISSION_DENIED` refusal takes the new form. + + `@objectstack/spec` declares the shape: `FileRefusedValueSchema` / `FileRefusedValue` (`{ id, metadataRefused: true }`, closed), and `valueSchemaFor(def, 'expanded')` for the file types admits it beside the bare id and `FileValueSchema`. It is a read form only. The stored form stays the bare id. + + **For consumers.** Code that reads a file field's expanded value as `string | FileValue` should handle the third form: an object with `metadataRefused: true` and no `url` is a file this reader may not see the details of, not an empty field. +- 1bb1da1: A reader refused `sys_file` read now sees the name, size and type of a file the record they are reading owns, whenever the download door would serve them its bytes + + Clause-②: yes + + The download door already judges a field-owned file by the record that owns it (ADR-0104 D3): a declared `fileAccessDelegate` decides where the owner object names one, otherwise the caller's read of that record. Record file-field hydration judged the same file by `sys_file` object read instead, which the default member grants do not include. So one reader could download a contract's signed PDF but saw the field as `{ id, metadataRefused: true }`: the bytes yes, the name no. + + - **From.** A reader with read on `contract` and no `sys_file` read gets `{ "signed_pdf": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }` for every file id the record holds. + - **To.** For a file whose `ref_object` / `ref_id` name the record being read, that reader gets the hydrated `{ "id": "f_7cQx2Lm90a", "name": "signed.pdf", "size": 2048, "mimeType": "application/pdf", "url": "/api/v1/storage/files/f_7cQx2Lm90a" }`, exactly as a `sys_file` reader does, when the door's field-owned verdict allows it. A delegate-governed object (for example `sys_approval_action`, delegate `approvals`) gets its delegate's answer, not the raw record read. + - **Unchanged.** Every other id keeps `{ id, metadataRefused: true }`: an id copied in from another record, an attachment-only or unclaimed file, a `public_read` file the record does not own, and an owned file the delegate or the record read refuses. `sys_file`'s own read rule is unchanged, so a direct `sys_file` query by the same reader is still refused. A reader with `sys_file` read is served by their own read, as before. The download door's verdict for every request is unchanged. + + **One verdict, two callers.** `@objectstack/service-storage` moves the download door's field-owned arm into one module-private function (not exported) and hands it to the engine through a new seam, `ObjectQL.registerFieldOwnedFileReadAuthorizer` (type `FieldOwnedFileReadAuthorizer`, exported from `@objectstack/objectql`), the same handover `registerHeldFileResolver` already makes. The engine never re-derives it. An engine with nothing registered keeps the refused marker for every id. On a refused hydration the engine makes one more `sys_file` read under the caller's tenant (it reads only ids whose ownership pair names a record in the result) and asks the verdict once per read. + + **For consumers.** Nothing to change. A refused reader may now receive the full file object where it used to receive the marker, and code that already handles all three `expanded` read forms handles this. +- 615cba8: fix(objectql,metadata-protocol)!: the data door honours a field's `internal: true` in its filter (related-object conditions included), sort, group-by, aggregate, per-aggregation-filter, `$search` and `$expand` positions at every depth, not only the row (#22646) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A field declared `internal: true` is withheld from every generic exit (#21197): the engine omits it from every row, select-named included. The ROW position honoured that; the generic data door's EVALUATE positions did not, so a value the platform withholds from rows could still be learned through a position that answers by its stored value. + + **FROM.** On `origin/main`, for a member holding read and for an administrator alike, on the list and query data routes (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query`): + + - A **filter** naming such a field was admitted — the predicate reached the driver, so the row came back only when the guess matched: a confirmation oracle over a withheld value. + - A **sort** by such a field was admitted — the order leaks the comparative stored value. + - A **group-by** of, or an **aggregate operand** over, such a field was refused by the engine, but as an undeclared `500 INTERNAL_ERROR` (a bare `Error` with no `code` or `status`), not a declared refusal. + - A **per-aggregation filter** (`aggregations[].filter`) naming it, and a **filter beside an aggregation**, were admitted — the same oracle at a second filter position. + - A **`$search`** whose resolved set included such a field (a non-hidden `internal` text column enters the auto-default) scanned it — a cross-field substring match that confirms a guessed prefix. + - A **nested-relation condition** (a condition on a lookup's related object) naming the related object's `internal` field was evaluated on that object — the parent row came back only when the related value matched, the same oracle one hop away. + - An **`$expand`** entry's own `where` / `orderBy` on its TARGET object's `internal` field was admitted into the expansion sub-read, at the first level and at every deeper level — the expanded record's presence is an oracle on the related row. + + **TO.** Each evaluate position is refused `400 INVALID_FIELD`, a declared ADR-0112 envelope naming the object and the field, before the engine is asked: + + - Filter, sort, group-by, aggregate operand and per-aggregation filter are refused at the generic data door (`@objectstack/metadata-protocol`), after the field-existence gates, in the shape and envelope of the stored-metadata body/hash family one axis over. A nested-relation condition inside any filter is judged against the RELATED object's own `internal` fields, and every level of an `$expand` tree has its own filter / sort judged against that level's target object; the refusal names the object that declares the field. + - The engine's `aggregate()` refusal (`rejectCredentialAggregation`) now carries the `INVALID_FIELD` / 400 envelope instead of the bare `Error`, so the group-by and aggregate-operand positions answer a declared `400` rather than a `500` — and the `secret` / `password` case that shared that bare `Error` is upgraded with it. + - A `$search` never scans an `internal: true` column: the field is dropped from the set the engine's search expansion resolves over (a withhold, like the auto-default's `hidden` / credential-type exclusions), and an explicit `$searchFields` naming one is refused `400 INVALID_FIELD` at the door rather than silently widened back to the default set. + + **Bindings.** + + - **Every caller.** The refusal is at the generic data door and takes no user, so an administrator is refused exactly as a member is (#21197). There is no system carve-out at the door. + - **The engine's privileged consumers are untouched.** The API-key verifier's `where: { key: }` lookup, the share-link / SCIM / approval-token lookups — all call the engine directly under a system context and never pass the generic data door, so authentication keeps working, exactly as #7823 kept the engine's write results whole while the generic-data-path ingress stripped them. + - **One flag reader.** Every position judges `collectInternalReadFields` (engine) or its byte-identical `@objectstack/core` twin `collectInternalWriteResponseFields` (the door, which sits below objectql), never a second list. + - **Unchanged.** An ordinary field in every position is served as before; an object that merely has an `internal` field is served for every other field. + + **Scope.** Target-OBJECT exposure through `$expand` is a separate axis (#22661); the cross-object search sweep (`searchAll`) is #22640's region and inherits the engine's search-set withholding through `engine.find` without a change to its own code. +- 490cb6d: A write answer now carries the advisory validation-rule hits of that write as `warnings` + + Clause-②: yes (widening) + + A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. + + - **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. + - **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. + - **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. + - **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. + - **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. + + **For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. +- 3f0b6f2: An `after*` hook whose `onError` is `abort` (the default) now rolls its write back on the default write door, as `HookSchema.onError` says ("abort: Rollback transaction (if blocking)") + + Clause-②: yes (narrowing) + + + + **BREAKING** stored-outcome narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0087, the ratified pre-launch launch-window exemption, as amended 2026-09-13, #18003). + + - **FROM:** `insert` / `update` / `delete` (REST `createData` / `updateData` / `deleteData`, `ctx.api`, the engine door) on an object with a blocking (not `async`) `after*` hook bound from metadata whose `onError` is `abort` or omitted. When that hook threw, the caller was refused, but the row stayed stored, changed or deleted, and so did every write the hook chain had already made through `ctx.api`. A client that retried the refused create made a duplicate. + - **TO:** that write runs inside a unit of work, so the abort rolls back the row and the hook chain's engine writes together. The caller is refused exactly as before. Hook count and order are unchanged. + - **Unchanged:** a write already inside a unit (a caller's `transaction()`, an atomic batch or cascade) joins it, and that unit's owner still decides. No unit is opened for an `onError: 'log'` or `async: true` hook that carries no `condition` (#22856), a hook registered in code with `registerHook` (no declared `onError`), a write with `skipAutomations`, an object whose datasource is not the default one, or a default driver with no transactions (ADR-0119 D1, such as a libSQL remote transport). On those the row stays stored after an abort, as before. + - **Inside the new unit, the rules of any unit apply.** An `after*` hook on such an object that writes a business row to an object on another datasource is now refused with `CrossDatasourceTransactionWriteError`, which the abort turns into a refused write. A MongoDB default store needs a replica set for this unit, as it does for `transaction()` already. If either applies to you, mark the hook `onError: 'log'` or `async: true` and give it no `condition` (#22856), or keep the objects on one datasource. + - **A sandboxed hook body's own `ctx.api.transaction`** on such an object, when no transaction is open around the write, used to open and own its transaction, so a throw from it rolled back only the body's own writes and the triggering row stayed stored. It now joins the door's unit, as it joins any open unit, and the abort rolls back the triggering row too. + - **Work a unit starts and does not await** (an `async: true` hook) no longer inherits the unit's finished transaction handle. It used to fail with "Transaction query already complete" and lose its write. It now runs on its own connection, as it would outside any unit. + - **Cost:** one transaction per write on the objects above only. Measured on the in-process SQLite driver: about 1.36x the per-insert time of the same object with an `onError: 'log'` hook. Objects without such a hook pay one map lookup. +- 9814035: feat(spec): `ReadableIds`, the branded `$in` comparand leaf meaning "the ids of object X this caller may read" (ADR-0139 D1) + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec/data`.** `ReadableIds.of(object)` builds a frozen leaf, `{ object }`, placed as the comparand of `$in` on a pointer field: `{ record_id: { $in: ReadableIds.of('cpz_doc') } }`. `isReadableIds(value)` answers whether a value is one. `READABLE_IDS_BRAND` is the `Symbol.for('objectstack.filter.readableIds')` key the brand lives under. The `ReadableIds` type carries the brand, so an object literal does not type-check as one. `ReadableIds.of()` throws a `TypeError` for an object name that is not a non-empty string. + - **No request can spell it.** No filter operator is added. The brand is a non-enumerable symbol-keyed property, so JSON, a structured clone or a spread produces an unbranded `{ object }`, and a plain object as the comparand of `$in` is refused `INVALID_FILTER` / 400, as it was before. + - **The comparand-shape door admits it only for a caller that composes it.** `assertListComparandShapes` takes an optional fourth parameter, `ListComparandShapeOptions`. With `{ admitReadableIds: true }` a branded leaf is admitted as the comparand of `$in`, and nowhere else. Without it, which is every existing caller (`parseFilterAST`, the save door, the analytics doors, the RLS compiler), the leaf is refused as a non-list comparand, unchanged. + - **`@objectstack/objectql` does not compose the leaf yet.** The engine's comparand door passes the option, then refuses a branded leaf found anywhere in a `where`, an aggregation's `filter` or a `having` with `INTERNAL_ERROR` / 500, before any driver is asked. Do not emit the leaf until the engine composes it. Until then, read the parent object as the caller and match the ids that read returns with a list. + - Nothing emits the leaf yet, and no read that ran before this change answers differently. +- 55382dc: A paged list under a search now reports the real number of matching records as `total`, and `hasMore` agrees with it on every page. + + Clause-②: yes (widening) — `EngineCountOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the count options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. + + Until now `GET /api/v1/data/:object?$top=25&$search=Line` over 75 matching records answered `total: 26` (offset + page + 1, a page-local estimate), and the last page claimed `hasMore: true`; `POST /api/v1/data/:object/query` with a `search` did the same. Nothing on the response marked the number as an estimate, so the Console rendered it as the record count and offered the wrong number of pages. + + - **`@objectstack/spec`** — `EngineCountOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. FROM `count(object, { where })` only → TO `count(object, { where, search, searchFields })`. The structured `search` form carries flag defaults, so the parsed state is named `EngineCountOptionsParsed` (ADR-0122). `HookCountQuery` (`ctx.api.object(name).count()`) derives from it and gains the same two keys. + - **`@objectstack/objectql`** — `engine.count()` (and `ctx.api.object(name).count()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` and `aggregate()` use: the same server-resolved searchable fields, the same `internal: true` exclusion, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two, so a count under a search is the number of rows `find()` returns for the same bag. + - **`@objectstack/metadata-protocol`** — `findData`'s paged branch passes `search` / `searchFields` to `engine.count()`, and the page-local estimate branch is gone. `$count=false` still skips the count and omits `total`. + + Nothing to migrate. A client that worked around the estimate, for example by labelling the count as page-local under a search, can read `total` as the match count again. +- 3caae97: fix(objectql)!: a write refused because an `after*` hook's `condition` could not be evaluated no longer leaves its row behind when that hook is `onError: 'log'` or `async: true` (#22856) + + Clause-②: no (narrowing) + + + + **BREAKING** stored-outcome narrowing, shipped as `minor` under the launch-window convention for breaking changes. + + A hook `condition` that cannot be evaluated aborts the operation whatever the hook's `onError` says. Examples are a condition reading `previous` on an insert, a key the object does not declare, or `null > null`. The condition is checked before an `async: true` hook detaches, so that hook aborts too. + + - **FROM:** `insert` / `update` / `delete` (REST `createData` / `updateData` / `deleteData`, `ctx.api`, the engine door) on an object whose `after*` hooks bound from metadata are all `onError: 'log'` or `async: true`, with at least one carrying a `condition`. The write door opened no unit of work, because it opened one only for a blocking `onError: 'abort'` hook. So a condition that could not be evaluated refused the caller while the row stayed stored, changed or deleted. Measured on REST `POST /data/:object`: `500`, and the row was stored. + - **TO:** an `after*` hook bound from metadata that carries a `condition` opens the write door's unit, whatever its `onError` and `async`. A condition that cannot be evaluated rolls back the row and the hook chain's engine writes together. The caller is refused exactly as before. + - **Inside the new unit, the rules of any unit apply,** so writes that were accepted are now refused on objects carrying such a hook: + - A blocking `onError: 'log'` hook whose body writes a business row to an object on another datasource has that write refused with `CrossDatasourceTransactionWriteError`. The hook's `onError: 'log'` logs the refusal and the row is not written, where it used to land. The caller's own write is still stored. + - The same applies to the part of an `async: true` hook's body that runs before the unit's callback settles. A write the body makes after that lands as before. + - A MongoDB default store without a replica set now refuses writes to objects carrying such a hook, because this unit needs a replica set, as `transaction()` already does. + - **Fix, if one of these applies to you:** drop the hook's `condition` (and test it in the hook's body instead), or keep the objects on one datasource, or run MongoDB as a replica set. + - **Unchanged:** a condition's verdict is read as before. `false` skips the hook, and with `true` an `onError: 'log'` hook's thrown error is logged and the caller's row is stored. A write already inside a unit joins it. No unit is opened with `skipAutomations`, off the default datasource, or on a default driver with no transactions. An `onError: 'log'` or `async: true` hook with no `condition` still opens no unit. + - **Cost:** one transaction per write on objects that carry such a hook. This is the same per-write cost as the unit for `onError: 'abort'` hooks. In the showcase example this covers updates to `showcase_task` and `showcase_project`, whose `async: true` `afterUpdate` hooks carry conditions. +- 038d4c9: feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) + + Clause-②: yes + + **What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: + + - `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). + - `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. + - `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. + - `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. + - `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. + + With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. + + **The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). + + **Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: + + - `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. + - `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. + - `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. + - `POST /meta/:type/:name/publish`: `package`, `member`. + - `POST /meta/:type/:name/rollback`: `toVersion`, `member`. + + A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. + + **What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): + + - A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. + - Resetting one language keeps the other languages' customizations served at once, not only after the next restart. + - Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. + - In a cluster, a save, reset or revert of one language converges the other servers on that language. + + **For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. + + - `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. + - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. + - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. + - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. + - A type whose items have no languages ignores the argument. + - `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. + - The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. +- 8d8f965: fix(objectql)!: a validation rule that reads its master through a master-detail field asks the caller's read door for the header first — a header the caller cannot read is unavailable to the rule as a header that exists nowhere is (#22892) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A detail write answers a header the caller cannot read exactly as a header that exists nowhere: its `parent` binding and its reference check ask the caller's read door first (the companion entry for #22519). A validation rule on the detail that reads the header through the master-detail field (`record.invoice.status == 'locked'`) still read the header elevated, and rules run before the reference check, so that rule could still answer by what an unreadable header holds. + + **FROM.** For a non-system caller who may write the detail but cannot read its header, a rule reading the header through the master-detail field judged the header's stored values. Measured with a rule refusing a line under a locked invoice: a header id that exists nowhere was refused with `_record` `rule_violation` ("the related record was not found"); an unreadable open header passed the rule and was refused `reference_not_found`; an unreadable locked header was refused with the rule's own message. Three answers, one bit of the header's state. + + **TO.** Through a `master_detail` field the rule's related read takes the write's two reads: + + 1. The caller's read door, under the caller's own context. A header it does not return (hidden by row scope, a refused read of the header object, or a failed read) is unavailable to the rule exactly as a header that exists nowhere is: the rule cannot be evaluated and the write is refused `400 VALIDATION_FAILED` at `_record` (`rule_violation`, "the related record was not found"), whatever the header holds. + 2. The elevated, tenant-kept read of the columns the rule names, only for the headers step 1 returned. A header the caller can read is still judged on its STORED values, a column field-level security hides or masks for the caller included. + + **Bindings.** + + - Every write door that runs the rule: `insert` (single and batch), `insertMany`, update by id (a repoint included), the predicate (bulk) update, and the `validate()` preview. + - System callers (`isSystem`: seed replay, boot, package install) ask no read door and are judged exactly as before. + - A rule reading through a plain `lookup` is unchanged. + - No new error code and no status change: the answer is the one a header that exists nowhere already got. + - Cost: one id-projected read under the caller's context per master-detail field a rule reads through, once per write batch. + + **Fix, if a write relied on the old answer:** give the writer read access to the header (an object permission or sharing rule on the master), or declare the detail `controlled_by_parent` so the header's access governs it. + + **Not measured.** How many deployed detail objects carry a rule reading their master was not measured: the rule only tightens, so it holds whatever that answer is. +- 21914c3: fix(objectql)!: each element of a multi-valued `lookup` / `user` / `file` / `image` field is held to the single value's stored form — an embedded record, a boolean or a number as an element answers `invalid_type` (#22908) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM.** On a `lookup`, `user`, `file` or `image` field declared `multiple: true`, the record validator checked that the value was an array and stopped there. So an element of the wrong shape was stored: + + - REST `POST` / `PATCH /api/v1/data/:object`, as a signed-in caller: an embedded record `[{ id }]` answered `201` / `200` and was stored verbatim. `[false]` and `[42]` were refused only by the reference-existence check, as `reference_not_found`. + - A system write (`isSystem`: seed replay, hooks, package install): `[false]`, `[42]` and `[{ id }]` were all stored verbatim. + - The single-valued field refused the same three `invalid_type` on both doors. + + **TO.** Each element is parsed against the single value's stored form (ADR-0104 D1, `valueSchemaFor`): a record id for `lookup` / `user`, an opaque `sys_file` id for `file` / `image`. The deployment's value-shape posture decides, the same posture a single value answers to: + + - **Strict** (a fresh store is born attested; or after `os migrate value-shapes --apply` for references, `os migrate files-to-references --apply` for media; or `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`): the write is refused `400 VALIDATION_FAILED`, with a `fields[]` entry `{ field, code: 'invalid_type' }` whose detail names the index of the wrong element (`at index 1: …`). + - **Warn-first** (an unverified deployment, or `OS_ALLOW_LAX_VALUE_SHAPES=1` / `OS_ALLOW_LAX_MEDIA_VALUES=1`): the write is admitted, logged and reported as an admitted ADR-0104 violation, exactly as a single value is. + - The shape check runs for a system write too. Whether a referenced id EXISTS is unchanged: a system write still skips that check. + - `os migrate value-shapes` already counted these elements; its report detail now names the index too, because the scan and the write path share one reading. + + **Fix, if a write relied on the old answer:** write the ids as the elements, never the record a read with `$expand` returns — `team: ['acc_1', 'acc_2']`, not `team: [{ id: 'acc_1', name: 'Acme' }]`. To find stored rows that hold such an element, run `os migrate value-shapes` (a dry run writes nothing). + +### Patch Changes + +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- 1920cf3: fix(objectql): the lifecycle reaper and archiver no longer partition an object with no tenant column by organization + + A tenant-scope `lifecycle.retention_overrides` entry gives one organization its own retention window, and the reaper and the archiver apply it by partitioning the object's rows on `organization_id`: one pass for that organization's rows, then a global pass for everyone else's. On an object that has no `organization_id` column — one declaring `systemFields: { tenant: false }`, such as the deployment-level platform tables (`sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal`, `sys_presence`), or any other object the registry injects no tenant column into and whose author declares none — both passes named a column the table does not have. The SQL driver refused them (`INVALID_FILTER`), the sweep reported the object in its errors, and the table's retention stopped. + + Such an object now has no tenant partition, the answer a federated object already got: the sweep runs its one global pass at the global window. No row of it belongs to an organization, so a tenant override naming it has nothing to select, and it is not applied. An object that has the column keeps its per-tenant windows unchanged. +- f85a83b: fix(objectql): a formula field that does not evaluate is logged once per object and field, instead of reading `null` in silence (#22019) + + A formula the engine cannot evaluate reads `null`, on `find`, on `findOne` and on the write response. Before this change nothing said why. A formula calling an unregistered function (`sqrt(record.amount)`) read `null` on every row with no log line anywhere. ADR-0032 says a call site must not silently swallow an expression fault. + + The engine now reports the fault through its logger at `warn`, once per (object, field) per engine instance, however many rows and reads hit it. The line names the object, the field and the evaluator's error (kind and first line; the full message is in the log metadata). It also says where the repair is: `os validate` or a re-save of the object refuses an expression-level fault with a located message, and a fault that depends on a record's values needs a guard on the operands it reads. + + Unchanged: the field still reads `null`, because what a read returns is protocol. `evaluateFormulaField`, the hook-side helper with no engine, still returns `null` without a log line. The built entry declarations gain three `private` member names on `ObjectQL`. +- c8d06a9: `skipAutomations` no longer skips ObjectQL's own audit stamps: a data import with "run automations & triggers" unchecked stamps `created_by` / `updated_by` from the session user again + + Clause-②: no + + `ExecutionContext.skipAutomations` suppresses the lifecycle hooks bound from metadata and, by its own + description, never bypasses audit. ObjectQL's builtin audit stamps (`sys_stamp_audit_insert` on + `beforeInsert`, `sys_stamp_audit_update` on `beforeUpdate`) were registered through the hook binder, + so they carried the metadata binding the opt-out keys on and were skipped together with the app's + hooks. Rows that a data import wrote with automations off landed with no `created_by` / `updated_by` + and no value in a declared plain `tenant_id` field. On a driver that does not stamp its own + timestamps, they also landed with no `created_at` / `updated_at`. The organization column was not + affected: the driver stamps it from the engine's driver options either way. + + The builtins are now registered in code (`registerHook`, no metadata binding), which is how the + contract describes audit. They run under `skipAutomations` exactly as they run without it, and they + keep the same wrapper, priority and `sys:audit` package id. Every hook bound from metadata is still + skipped under the flag. ⛔ Nothing you author changes: no key, option, export or type is added or + removed. +- 0b997ea: fix(objectql): a stack-declared position is registered under its package, so the metadata save door refuses a save over it like every other non-overridable security type + + Clause-②: no + + - **What was wrong.** `PUT /api/v1/meta/position/NAME` naming a position an installed package declares answered `200`, and the saved environment row then won the by-name read (`GET /api/v1/meta/position/NAME`). The type registry declares `position` `allowOrgOverride: false`, as it declares `permission` and `capability`, so the save door should refuse it. + - **Why.** The save door decides "a code package ships this item" from the engine's SchemaRegistry entry that a package registered. `ObjectQL.registerApp()` puts a stack collection into that registry under its package only for the collections it enumerates. That list carried `permissions` and `capabilities`, but still carried the retired `roles` instead of `positions`. So no package-declared position had an entry, and the save took the runtime-create path. + - **What changes.** `registerApp()` (and the nested-plugin seam) now registers a stack's `positions` under the owning package, with the same ADR-0010 provenance as its permission sets. A save over a package-declared position is refused `403 NOT_OVERRIDABLE` on every topology. A save that names the read-only package (`?package=`) is refused `403 ITEM_LOCKED`, which is how a save naming a read-only package is refused for any non-overridable type. The by-name read keeps serving the package's position. + - **What does not change.** A position no package declares still saves (`allowRuntimeCreate`). Permission sets and capabilities are refused as before. The declared-positions seeder in `@objectstack/plugin-security` now reads the stack's positions from the engine registry rather than the metadata service, as it does for permission sets. It seeds the same names, labels and descriptions. + - **To customize a packaged position,** create a position with a different name. +- e87070e: fix(objectql): a `find` / `findOne` whose `fields` names a formula field returns that projection, not every stored column + + Clause-②: no + + To evaluate a formula field, the engine widens the projection it hands the driver to every stored column of the object (CEL's `record.` reads whatever the formula needs off the full row). The rows were never cut back, so a projection that named a formula field returned every column: the tenant, owner, owning unit and audit columns, and every field the caller did not name. A flow's `get_record`, whose `config.fields` is declared as "only these fields are read", passed all of them on to its later nodes. + + Each row is now cut back to the caller's projection once the read is done: the columns the caller named, plus the formula's computed value. `id` is returned only when it is named. `driver-memory` and `driver-mongodb` add `id` to every projection at the driver layer, so on those two drivers a projection that names a formula field but not `id` no longer carries `id`, while the same projection without the formula still does: name `id` when you need it. The formula still sees the full row, and so do the `afterFind` hooks and the middlewares, as before. A key an `afterFind` hook derives is kept. + + Unchanged: a projection that names no formula field, and a read with no projection (every declared column, with the formulas computed). Field-level security is unchanged as well: a field the caller may not read was already masked off the widened row, and still is. +- c8c803c: Deleting a record no longer fails because of a record that the same delete removes. Before this fix, when a record's delete cascaded to two kinds of children, and one child held a required lookup to the other, the delete could answer `409 DELETE_RESTRICTED` naming the child that holds the lookup. Whether it did depended on which object was registered first. A measured example: an account cascades to its contacts and to its contracts, and each contract has a required lookup to a contact. With the contacts registered first, deleting the account was refused, naming a contract that the same delete was about to remove. With the contracts registered first, the same delete succeeded. + + Clause-②: no + + A by-id cascade delete now collects, before any refusal is judged, every record it will delete across its cascading relations, the root record included. A `restrict` raised by a record in that set against another record in the set is no longer a refusal. That covers an authored `deleteBehavior: 'restrict'` and a required lookup whose `set_null` cannot clear it. A `set_null` writes nothing to a record in the set, so that record's hooks and audit rows show the delete without an earlier update. A refusal from a record outside the set is unchanged. It still answers `409 DELETE_RESTRICTED` and names the dependent object, and on a single datasource the whole delete still rolls back. The refusal now counts only rows outside the set. + + The cascade also no longer re-enters a record it is already deleting. A cycle in the data, such as two rows that cascade to each other, now deletes both rows. Before, the walk re-entered the same rows without end. A row that two cascade paths reach is deleted once, instead of the second path answering `404`. + + The cost is one more read for each cascading relation of each record the cascade deletes: the set is collected by a read-only walk before the existing walk deletes. No key, export or error code changes. +- fdfdd7e: fix(service-automation, objectql): the `runAs: 'user'` refusals and the run-setup warning say what a principal-less operation meets on both kernels + + Clause-②: no + + Three author-facing texts told a flow or hook author that a `runAs: 'user'` data operation with no trigger user "would execute UNSCOPED (elevated, RLS-bypassing)" if it were not refused. That holds only on a kernel with no security plugin. Since ADR-0096 D5, a kernel with `@objectstack/plugin-security` refuses an operation that carries no principal (`403 PERMISSION_DENIED`). Each text now says both: without a user the operation would carry no principal, refused by the security plugin where one is composed, unscoped where none is. + + - `@objectstack/service-automation`: the `UnscopedRunDataAccessError` message, and the `[runAs]` warning logged at run setup for a flow whose trigger resolved no user. + - `@objectstack/objectql`: the `HookUnscopedDataAccessError` message. + + Only the wording changes. The refusals, their order, their codes (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`, `HOOK_UNSCOPED_DATA_ACCESS` with its `403` status) and their remedy are unchanged: declare `runAs: 'system'` to make the elevation explicit and intended, or arrange for the trigger to supply a user. +- 87cd458: A top-level object that no package body of a multi-package stack declares is now served by the data door, under the stack's `manifest.id`, the id the metadata door already lists it under + + Clause-②: no + + - **What was wrong.** A stack that carries `packages[]` registers each package body under that package's id. A top-level object that no body declares is the stack's residual. The metadata door registered it under the stack's `manifest.id` and the boot warned that every door would report that id as its owner. The engine's `manifest` service registered the bodies only. So `GET /api/v1/meta/object` listed the object while `GET /api/v1/data/` answered `404`, on the artifact boot (`os serve` of a built artifact) and on the config boot (`os serve objectstack.config.ts`) alike. + - **What changes.** After the bodies, the `manifest` service registers the residual's objects under the stack's `manifest.id`. It reads which objects are residual from `unclaimedTopLevel` (`@objectstack/metadata`), the same answer the metadata door and `os validate` read. No package record is created for that id: the residual is not a package. When the id names one of the bodies (a composed stack keeps one member's manifest), that body's record is unchanged. A residual object that arrives after boot, with a late artifact, is copied to the metadata service as a body's objects are. + - **Listed and not served, by name.** Registering two classes of residual object would refuse a boot that the stack passes today, so they are not registered. They stay listed by the metadata door and unserved by the data door, and the boot prints one warning naming each object and why: + - an object whose name another package already owns: the data door keeps serving that package's object; + - an object with a field that names a picklist no package body declares: the stack's top-level picklists are metadata only, so the boot's picklist check would fail. + + The remedy in the warning is the residual warning's own: declare the object inside the `packages[]` entry of the package that owns it, with every picklist its fields name. To add fields to an object another package owns, use `objectExtensions`. + - **Unchanged.** A stack without `packages[]`, and a normally composed one whose top level repeats its bodies, has no residual and registers exactly as before. +- b62260c: An app, object or other item registered through `ObjectQL.registerApp` is now served with its package's `_packageVersion`, as the artifact loader already serves it + + Clause-②: no + + `_packageVersion` ("Owning package version") is declared on the protection envelope, and the metadata plugin's artifact loader stamps it. The registry load path stamped only `_packageId`: `ObjectQL.registerApp(manifest)` called `SchemaRegistry.registerItem` and `registerObject` with the package id alone. So `GET /api/v1/meta/app` served such an app with no version, and a console waiting for an upgrade could not tell the new app from the old one that a stale kernel still serves. + + `registerApp` now stamps `manifest.version` on every item it registers: owned objects, object extensions, apps, every metadata collection, seed datasets, and the objects, app and collections of a nested plugin, which carry their owning package's version. A multi-package stack's residual top-level objects, which the engine registers under the stack's own id, now carry the stack's version too, as the metadata door stamps them. Each item is stamped before it reaches the registry, with the artifact loader's own call, `applyProtection(item, { packageId, packageVersion })`. The registry's stamp knows the id only, and it keeps a version that is already set. No signature changes: `SchemaRegistry.registerItem`, `registerObject` and `registerApp` keep theirs. + + A caller that passes only a package id still stamps the id alone. No version is looked up or invented. Those callers are the metadata-service re-sync and the facade, which pass along the stamp the artifact loader already put on the item; the stored-row hydration, whose rows are tenant-authored; and the security catalog's built-ins. +- 8532783: feat(service-datasource,metadata-core)!: the datasource-admin plugin reads the environment's stored `datasource` rows only; a legacy organization-scoped row is neither loaded at boot nor served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** Graded `minor` on the v18 prerelease line, as the read narrowing it completes. + + **What changes.** `DatasourceAdminServicePlugin` reads stored `datasource` rows in five places — the boot restore (`restoreRuntimeDatasources`), the by-name read behind cluster convergence and the `/meta` door's datasource projection, and the existence probes behind its own create, update and remove. Each now reads the environment's rows only, judged by the same row-value test the metadata reads apply. A row stored organization-scoped by an earlier release: + + - is no longer registered at boot, so the datasource-admin door and the metadata read door no longer serve it, and no live pool is opened from it; + - is never updated or deleted by an admin create, update or remove under its name — those write and remove an environment row of their own; + - stays at rest, and the boot report `[metadata_org_scoped_unserved]` keeps naming it. Nothing is deleted or rewritten; the v18 migration ceremony (`os migrate`, ADR-0131 D10) carries it. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | a legacy organization-scoped runtime datasource a deployment relied on | re-create it through the datasource-admin door or save it through the `/meta` door to make it environment-wide now, or wait for the v18 migration ceremony | + + `@objectstack/metadata-core` exports `isLegacyOrganizationRow`, the one row-value test for such a row; `@objectstack/objectql`'s authored hook and action binders now read it from there, with no change in what they bind. +- eb20c33: fix(cli): the nine rule ids the CLI produces print one verdict sentence each, `os explain ` carries their reasoning, and the undeclared-handlers boot line is one line + + Clause-②: no + + - **One verdict sentence of at most 200 characters.** Measured on every finding the CLI's suites fire (message length; before → after): + - `os lint`'s hook-body rules: `hook-body/not-lowerable` 565–661 → 165–178, `hook-body/bundled-fallback` 324–947 → 109–171, `hook-body/unparseable` 469 → 157, `hook-body/extraction-failed` 331 → 140. Over every refusal the extractor can throw (14 forbidden constructs and 7 parameter refusals), `hook-body/bundled-fallback` runs 296–1,011 → 113–199: the verdict quotes the extractor's own verdict clause, cut to fit and marked `…` when cut. + - package docs (`os build`, `os validate`, `os lint`): `docs/uncollected-directory` 433–558 → 124–183, `docs/nav-target` 263–299 → 147–184, `docs/frontmatter-tags` 233–285 → 135–195, `docs/duplicate-name` 330 → 167 (its within-package arm is unchanged at 101). A doc finding no longer repeats its path, which every printer already leads with; lists of files, packages and directories name the first three and count the rest. + - `picklist-reference-unverified` (the `os validate` / `os build` notice): 240–245 → 173–175, naming at most two outside dependencies and counting the rest. + - **A `fix:` line for the hook-body rules.** Their remedy moves out of the message into the finding's `fix`, at most 200 characters: `os lint` prints it as `→ fix:` under `--fix`, and `os lint --json` now carries `fix` on these four ids' issues. The remedy is still chosen per case: inline a module-scope name, or keep a check on a host-only global such as `Intl` in a string handler ref or a validation rule. A doc finding carries no separate fix, so its sentence ends with the remedy; the picklist notice's `hint` is unchanged. + - **`os explain ` carries the rest.** Each of the nine ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, the one table `os explain` reads, and `os lint`'s `rule:` line now points at it. They hold what the messages stopped saying: why a bundled handler is a change of deployment shape and how to bundle on purpose, what the sandbox cannot run and where `os build` prints the full refusal, why an instrument limit is not a verdict, where package docs are read from and why the collector never guesses, why a doc name may appear once, what a doc navigation item can open, how doc tags are read, and why a reference into a dependency outside the stack is not judged. + - **The `[action-governance]` boot line for registered handlers with no declaration** (`os dev`, `os serve`, and any host that boots ObjectQL) is one line of 192 characters, where it was 628: it names the three sources it read, states the 404 as expected rather than measured, and says to declare each with `defineAction`. Its structured `count` / `handlers` tail is unchanged, and the dead-button line beside it is unchanged. + - **Unchanged:** every rule id, severity and `path`, the boot lines' level and payload, and what each check accepts or refuses. A tool that matched on this message text should match on `rule` and `path` instead. +- 6373952: Work a `ctx.api.transaction(fn)` unit starts and does not await no longer keeps the unit's finished transaction handle + + Clause-②: no + + `ctx.api.transaction(fn)` is `ScopedContext.transaction()`, the transaction a hook or action handler opens. When the unit it opened finished, work the callback had started and not awaited still held its handle. The typical case is an `async: true` hook on a record the callback wrote. That work then failed with "Transaction query already complete" and its write was lost. Only the hook's log showed it. Measured on the in-process SQLite stack: the record was stored and the hook's later note was not. + + The handle reached that work two ways, and both now close when the callback settles, before the commit: + + - **Ambient.** `ScopedContext.transaction()` published its unit into the engine's ambient store and never closed the entry. It now runs its callback through the same helper `transaction()` uses, which closes the entry. + - **Threaded.** The unit's handle is also threaded into the callback's context (`tx.object(…)`), and from there into the triggered hook's `ctx.api`. A handle whose unit has closed is now treated as absent, so that work runs on its own connection, as it would outside any unit. That includes the write door's own unit for an aborting blocking `after*` hook: a late write opens one exactly when the same write outside any unit would, so that hook's abort rolls the write back. This applies on both surfaces: it also covers a caller who opens `transaction()` and passes its context to a write (`{ context: trx }`). + + **Unchanged:** work the callback awaits still runs inside the unit and commits or rolls back with it. A nested `transaction()` still joins the open unit, and its handle stays live until the outermost callback settles. Writes with no transaction are unaffected. A handle opened through the discrete `beginTransaction` / `commitTransaction` / `rollbackTransaction` trio (the sandboxed hook body's path) is not covered and behaves as before. +- 4663801: fix(objectql): a `tree` field's `deleteBehavior` now takes effect when its parent record is deleted, and the default clears the reference + + Clause-②: no + + The spec gives a `tree` field (a record's parent in a hierarchy on its own object) a `deleteBehavior`, like a `lookup`. When the field declares none, the parse fills in `set_null`. The engine's referential delete pass only followed `master_detail` and `lookup` fields. So deleting a parent node returned success and left every child pointing at the deleted id. An authored `cascade` or `restrict` on a `tree` did nothing either. + + A by-id delete now applies a `tree` field's behaviour the way it applies a `lookup`'s: + + - `set_null` (the default): the children stay, and their parent field is cleared. + - `cascade`: the whole subtree is deleted, at any depth, and nothing outside it. A cycle in the data (a row whose parent is its own descendant, or itself) ends: each row is deleted once. + - `restrict`: deleting a node that still has children is refused with `DELETE_RESTRICTED` (409), and the dependent named is the tree's own object. A leaf node still deletes. + - On a required `tree` field, `set_null` cannot clear the parent, so deleting a node that has children is refused with `DELETE_RESTRICTED`, as it is for a required `lookup`. + + Deleting a record of an object that has a `tree` field now runs as one transaction when the object is on the default datasource, as every delete that can cascade already does. If a node deep in the subtree refuses its delete (a `beforeDelete` hook, say), the nodes already deleted are restored and the call is refused. `lookup` and `master_detail` behave exactly as before. +- d294951: Boot-time system-table provisioning honours `OS_SKIP_SCHEMA_SYNC` / `skipSchemaSync` + + Clause-②: no + + `OS_SKIP_SCHEMA_SYNC=1`, or `ObjectQLPlugin`'s `skipSchemaSync` option, declares that a deployment's DDL is managed out-of-band ("Use after running migrations manually"). The engine's boot honoured it, but `AuditPlugin` and `MessagingServicePlugin` still created their system tables at `kernel:ready`. A deployment that set the flag got tables created behind its back while its boot log said no DDL ran, and a database role without DDL rights got a DDL failure it never asked for. + + - **What changes.** With the flag set, `AuditPlugin` (`sys_audit_log`, `sys_activity`, `sys_comment`, `sys_comment_reaction`) and `MessagingServicePlugin` (`sys_notification`, `sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_subscription`, `sys_notification_template`, `sys_http_delivery`) issue no DDL and no existence probe at boot. Each logs one `info` line naming the objects it did not provision and the flag, and pointing at `os migrate apply`, which creates any that are missing (`os migrate plan` lists them as `create_table`). That path was measured to create all twelve tables, on one database, with the telemetry sibling database, and with the flag set in the migrate run's own environment. + - **Which value is read.** The one `ObjectQLPlugin` resolved: the option, else the environment variable. An explicit `skipSchemaSync: false` therefore still provisions under `OS_SKIP_SCHEMA_SYNC=1`. `ObjectQLPlugin` publishes that value on the engine instance it registers. It is a property of the instance, not a declared member, so no type or export changes. + - **What is unchanged.** With the flag unset, provisioning is exactly as before. `syncObjectSchema` is not gated, so on a flag deployment a runtime publish still gives a new object its table. +- 8464380: fix(spec, objectql): a `tree` field with no `reference` points at its own object, so its `deleteBehavior` takes effect when a parent is deleted + + Clause-②: yes (widening) + + A `tree` field is a record's parent in a hierarchy on its own object. `reference` is optional on it, and when you write one it must be the object's own name — the object schema refuses any other target and tells you to drop the key. But a `tree` with no `reference` behaved as if it pointed at nothing. Deleting a parent answered success and left every child holding the deleted id, whatever `deleteBehavior` said (`set_null`, `restrict` or `cascade`). The same field also could not be expanded (`expand=parent` answered "declares no target object"), could not be filtered through (`{ parent: { title: … } }` was refused), and accepted a parent id that does not exist. + + Now a `tree` with no `reference` is a reference to the object that declares it, exactly as if you had written the object's name: + + - **`@objectstack/spec`.** `ObjectSchema` and `ObjectExtensionSchema` write the object's own name (an extension's `extend`) into such a field's `reference` when they parse it. `defineStack` and `ObjectSchema.create()` output, and built artifacts, now carry the object's name in `reference` on those fields, byte-identical to a field that wrote it. Nothing you could write before is refused now. A new export, `materializeTreeSelfReferences(objectName, fields)`, is the one helper both the parse and the engine use. + - **`@objectstack/objectql`.** The object registry writes the same self-reference when an object is registered. That covers an object saved through the metadata API (`PUT /meta/object/:name` stores the body as written) and a direct `registerObject` call, neither of which goes through the parse. + + What changes on a parent delete: + + - `set_null` (the default) clears the children's parent field and keeps the children. + - `restrict` refuses the delete with `DELETE_RESTRICTED` (409), naming the tree's own object as the dependent. + - `cascade` deletes the whole subtree. + + `expand`, nested relation filters, the write-time check that a parent id exists, the dangling-reference audit and `os verify --delete-behaviors` now treat the field as a self-reference too. A `tree` that already wrote its own name, and every `lookup` and `master_detail`, behave exactly as before. +- abf8655: A language the environment added to an email template, which the package does not ship, is no longer treated as packaged code + + Clause-②: no + + An `email_template` is identified by its name and its `locale`, so the languages of one template form a bundle. When an administrator customizes a template in a language its package does not ship (for example a `zh-CN` version of a template the package ships in `en-US` only), that version is the environment's own item. Until now it was registered and served wearing the package's protection envelope, taken from the shipped `en-US` version: + + - the registry's artifact lookup answered the environment's `zh-CN` version as the package's own `zh-CN` artifact; + - `GET /meta/email_template/:name?member=zh-CN` and the `/meta/email_template` list served it with `provenance: 'package'`, the package's id and its version; + - the registry heal that runs after `DELETE ?member=zh-CN` refused to retire it as packaged code. + + - `@objectstack/metadata-protocol`: the artifact envelope (`_packageId`, `_packageVersion`, `_provenance`, and an artifact lock) is copied only onto a version of the same language as the shipped artifact. Each place that copies it looks up the artifact of the body's own language first. A language the package does not ship is registered and served as the environment's item. A language the package does ship keeps its envelope, its lock and its reset exactly as before. Types without languages are unchanged. + - `@objectstack/objectql`: `SchemaRegistry.removeRuntimeShadow(type, name, member)` lifts a named language's shadow only when the package ships that language. For any other language it declines, and the heal's last step (`removeOverlayEntry`) retires the entry. Naming no language still sweeps the whole bundle, as before. + + A lock still binds the template name, at the read and at the write gate alike: a template the package locks stays locked in every language. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [35afb15] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [440bed6] +- Updated dependencies [3ca71b6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [aa5bfdf] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/metadata-protocol@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/metadata@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index d27e7e2baa6..ba334a92370 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Isomorphic ObjectQL Engine for ObjectStack", "main": "dist/index.js", diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md index 879f1f31ccd..14e657d4ad4 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,177 @@ # @objectstack/observability +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index c1a2431240c..86916ff879c 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Observability contracts and exporters for ObjectStack — MetricsRegistry, ErrorReporter, Logger plus noop/console/OTLP-HTTP exporters. Deployment-target neutral; runtime and services depend on this so the same instrumentation works on Cloudflare Workers, Node, and self-hosted Kubernetes.", "type": "module", diff --git a/packages/platform-objects/CHANGELOG.md b/packages/platform-objects/CHANGELOG.md index cc1017c1af9..d7cb2093cc7 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,592 @@ # @objectstack/platform-objects +## 18.0.0-next.0 + +### Minor Changes + +- 67b669e: The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. + - **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. + - **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. + - **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- 1920cf3: feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`. + + With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces. + + **What moves for consumers.** + + - **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables. + - **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`. + - **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables. + + **Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one. + + **Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names. +- c52bfb4: feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. + + - **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. + - **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. + - **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. + - **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. + + **What moves for consumers.** + + - **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. + - **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. + - **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. + - **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. +- 8311650: The user page's "Permission Sets" list shows each assignment's validity window, `valid_from` and `valid_until` (ADR-0091 D1/D2) + + The resolver drops a `sys_user_permission_set` assignment outside its half-open `[valid_from, valid_until)` window at every evaluation, so a list that showed only the set presented an expired or not-yet-active assignment as a live grant. The two bounds now sit beside the set. The list's Add affordance still writes the user and the set only; the window is set by editing the assignment row. + +### Patch Changes + +- 0253192: Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4) + + - `@objectstack/plugin-auth`: the self-registration grant names the declared set alone. Admission asks the security catalog and the activation ledger whether the set grants, and the settlement writes `{ user_id, permission_set }` with no row read, so a deployment with no `sys_permission_set` row for the set still grants it. The last-administrator guard reads which set a grant holds from its name only; writing `permission_set_id` moves no standing. + - `@objectstack/verify`: the RLS probe persona's grant names the registered probe set alone; it no longer needs, or refuses for want of, a boot-seeded `sys_permission_set` row. + - `@objectstack/platform-objects`: Setup's user page grants a permission set by name: the Add picker stores the set's machine name (`valueField: 'name'`, `linkField: 'permission_set'`), and the list shows the name column. The picker still lists `sys_permission_set` rows; listing the security catalog itself is a console change. +- 48cca4a: Setup's user page grants a position or a permission set by picking its name from the security catalog (ADR-0131 D4) + + Clause-②: no + + - `@objectstack/plugin-security`: `sys_user_position.position` declares `widget: 'position-ref'` and `sys_user_permission_set.permission_set` declares `widget: 'permission-ref'`, so the grant form offers a picker over the positions and permission sets the catalog serves, and stores the chosen name. A console without those widgets keeps the text input. `sys_user_permission_set.permission_set_id`, the legacy id that grants nothing, is now `hidden` and no longer appears on the grant form; the column stays until a later release drops its data. + - `@objectstack/platform-objects`: the user page's Positions and Permission Sets lists no longer carry the "Assign position" / "Grant permission set" buttons. Those buttons listed `sys_position` / `sys_permission_set` rows, which a deployment need not hold. Grant with the list's **New**, which opens the grant form; removing a grant is unchanged. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate`), which applies `convertPositionBindingRows`. A name that organizations bound differently is reported `conflicting` and is never merged. +- 8c5aa50: feat(plugin-email,plugin-auth)!: an organization can no longer create or edit an email template row; the template provenance stamp and the auth SMS template seed retire + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing and three retired exports), shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 and the maintainer's ruling C on ADR-0131 §6 Q1: email templates are not overridden per organization. + + **What stops being accepted.** + + - **The `sys_email_template` organization door is closed.** An organization's create and update of a template row are refused with `403 PERMISSION_DENIED`, and the message names the closed door: every engine `insert` / `update` whose context names a caller and is not system-elevated. That covers `POST` / `PATCH /api/v1/data/sys_email_template`, the Studio record editor, batch and import routes, and scripts and flows that run as a user or a service principal. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio `email_template` save into its row, and the v18 migration ceremony's promotion. Delete is not part of this door. + - **The template provenance stamp is retired.** It marked a package- or platform-seeded row `customized: true` when a non-system caller updated it. With the door closed no such update reaches the engine's write, so nothing marks a row any more. Rows already marked keep their mark and are still never overwritten by the boot seeders; they are the population the v18 migration ceremony promotes to environment-level Studio templates. + - **Retired exports of `@objectstack/plugin-email`:** `bindEmailTemplateProvenanceStamp`, `unbindEmailTemplateProvenanceStamp` and `EMAIL_TEMPLATE_PROVENANCE_PACKAGE`. They have no replacement. `EmailServicePlugin` closes the door itself, and there is no stamp left to bind, so a direct call is deleted, not rewritten. + - **The auth SMS template seed is retired.** A boot with phone sign-in on no longer writes the built-in OTP and invitation texts into `sys_notification_template` as rows. It was internal to `@objectstack/plugin-auth` and exported nothing. + + **What renders unchanged.** + + - Every email template renders as before: the template loader still reads `sys_email_template`, and a Studio edit of an `email_template` still reaches the mail at once and survives a restart. + - Every auth SMS text renders byte for byte as the seeded store rendered it, for every built-in text and every recipient locale. Each locale rung renders an operator's active row when one exists, and the built-in text where no row exists at that locale. A deactivated or blank row passes its rung on, as it did before. + - A `sys_notification_template` row an operator already has still wins, and no existing row is touched. + + **What changes for you.** To change what an email template sends, edit the `email_template` in Studio. A script or integration that wrote `sys_email_template` rows through the data API now receives `403 PERMISSION_DENIED`. `@objectstack/platform-objects` corrects the `is_system` and `customized` field help on `sys_email_template`, which said an organization may edit a row. +- 77a94d8: "Add Member" is offered only to a platform administrator, the one standing its endpoint admits. + + Clause-②: no + + - `sys_member`'s `add_member` toolbar action now declares `visible: 'current_user.isPlatformAdmin == true'`. `requiresFeature: 'organization'` composes onto it at parse time, so the served predicate reads `(current_user.isPlatformAdmin == true) && features.organization != false`. + - Its endpoint, `POST /api/v1/auth/organization/add-member`, has always admitted a platform administrator alone (ADR-0068) and answered every other caller, org owners and admins included, with 403 `PERMISSION_DENIED`. Before this change the button was still shown to every member of the organization. + - ⛔ Nothing you author changes. The endpoint and the callers it admits are unchanged, and no key, export or parameter is added. The action's label is unchanged. +- 879bd38: The user, OAuth-application and SSO-provider actions whose endpoint admits only a platform administrator are now offered only to a platform administrator. + + Clause-②: no + + - These thirteen actions now declare `visible: 'current_user.isPlatformAdmin == true'`, composed with their existing terms: + - `sys_user`: `ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user` and `set_user_manager`; + - `sys_oauth_application`: `disable_oauth_application` and `enable_oauth_application`; + - `sys_sso_provider`: `register_sso_provider`, `register_saml_provider`, `request_domain_verification` and `verify_domain`. + - Where an action also carries `requiresFeature`, the feature gate composes onto it at parse time. For example, `ban_user` now serves `(current_user.isPlatformAdmin == true) && features.admin == true`. + - Each endpoint (`/api/v1/auth/admin/*`) has always admitted a platform administrator alone (ADR-0068) and answered every other caller, org owners and admins included, with 403 `PERMISSION_DENIED`. Before this change the buttons were still shown to those callers. + - `create_oauth_application`, `rotate_client_secret`, `delete_oauth_application` and `delete_sso_provider` are unchanged: their endpoints authorize the signed-in user or the record's owner, not the platform administrator. + - ⛔ Nothing you author changes. The endpoints and the callers they admit are unchanged, and no key, export or parameter is added. The actions' labels are unchanged. +- 1c563af: Setup's identity pages open on the tenant-wide list, not on the administrator's own rows. Before this, Setup → API Keys, Sessions, OAuth Applications, Identity Links and User Preferences opened each object's first declared list view, which was the caller-scoped "My …" view (`user_id = {current_user_id}`), so an administrator saw only their own keys, sessions, applications, links and preferences. + + Clause-②: no + + - On `sys_api_key`, `sys_session`, `sys_oauth_application`, `sys_account`, `sys_user_preference` and `sys_user`, the unscoped "All" view (`all_keys`, `all_sessions`, `all_apps`, `all_links`, `all_preferences`, `all_users`) is now declared first, and the caller-scoped view (`mine`, `me`) second. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens the "All" view. No view is added, removed or changed. + - The Setup entries `nav_api_keys`, `nav_sessions`, `nav_oauth_apps`, `nav_accounts` and `nav_user_preferences` now name that view with `viewName`, as `nav_users` already did. The Account app's Linked Accounts entry (`nav_account_linked`) now names `mine`, like the other Account entries, so neither app depends on the declared order. + - The "My …" views are still tabs on each page. The declared order decides which view opens, not which rows a caller may read: row-level security still scopes a member's rows. + - The generated translation bundles follow the new view order. No translated text changed. + - ⛔ No schema, parse, export or accept-set change. +- 51290bc: Form help and refusals an author reads no longer carry service-interface names, ruling dates or another product's ids + + Clause-②: no + + Wording only: no schema, key, type, export or error-code change. + + - The notify node's Template help (the `NotifyConfigSchema.template` describe and the Studio + inspector's copy in `@objectstack/service-automation`) names the deployment's default locale in + product words instead of `II18nService.getDefaultLocale()`, and drops its ruling date. + - `MANIFEST_ID_EXAMPLES` is now `com.acme.crm` and `org.example.help-desk` (was `com.steedos.crm` + and `org.apache.superset`). The package-id refusal opens with the headline + "Invalid package id 'VALUE'." and names the key in the sentence after it, so the headline alone + carries no JSON path; the rule, the examples and the suggestion follow unchanged in substance. A + caller that matched the old "on KEY. Expected reverse-domain notation" wording matches the + headline, or compares against `manifestIdRefusal()` by reference, instead. + - Ruling dates leave the describes Studio renders as form help: field `required` and `multiple`, + form-view field and section `visibleWhen`, section `collapsible` / `collapsed`, the redirect + arm's `submitBehavior.url`, and page `kind` / `source` (the ADR citations stay). They also + leave the refusals for padded grouping field names, `submitBehavior.url`, `features.*` in a + form-view predicate, and the four filter comparand refusals (null ordering comparand, + `{ $field }` in a list position, null list member, blank `$between` bound). Each sentence still + states the rule, why it exists and the repair. + - The email-template form's Identity section help says how senders address a template instead of + naming `IEmailService.sendTemplate`, in all four shipped locales. + - The action `description` help no longer ends in a dangling dash left behind by an earlier + strip: "(one dialog, not two —)" now reads "(one dialog, not two)". +- 4e4111c: The `sys_email` field help for Headers (JSON) and Status no longer names the `IEmailService.send` service interface (#22093) + + Clause-②: no + + Studio shows these descriptions as field help in the object forms. They now say what the field holds and when it is set, in product words: Headers (JSON) holds the custom headers supplied with the message when it was sent, and Status is `queued` when the message is submitted for sending, before the first delivery attempt. Every other fact the help states is unchanged. The `en` bundle is regenerated from the source, and the `zh-CN`, `ja-JP` and `es-ES` translations of the two leaves are re-translated in place. Wording only: no field, option, key or type changes. +- 0b7ba4d: fix(platform-objects): Invite User explains itself in its dialog and names the invitee in its success message + + Clause-②: no + + - **The parameter dialog.** `invite_user` on `sys_user`, `sys_invitation` and `sys_member` declares a `description`: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation." The console shows it as the dialog's subtitle, where a generic "Please provide the required information to continue." showed before. + - **The success toast.** `successMessage` is now `Invitation sent to ${result.email}`. `POST /api/v1/auth/organization/invite-member` answers the invitation row, and its top-level `email` is the invitee's address as stored (lowercased), so the toast reads "Invitation sent to ada@example.com" where it read "Invitation sent". + - **Translations.** The zh-CN, ja-JP and es-ES bundles carry the new description and a success message that keeps the `${result.email}` token, so every locale names the invitee. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- a00cf99: Studio's object form now offers the `approvalsVisibleToReaders` row in its capabilities panel, with its label and help text in the metadata-form translation catalogs for `en`, `zh-CN`, `ja-JP` and `es-ES`. + + Clause-②: no +- e223152: feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The slot map read as seven independent slots, and two of them are not. On a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored. A page authoring both passed `PageSchema.parse`, `objectstack validate` and the metadata save door, and its details body (its `sections`, its `hideFields`) silently never applied. The platform's own `sys_user` record page was one: its Identity and Audit sections never showed, and the ban columns it hides were never hidden by it. + + **What is refused.** A page whose `slots` map carries both a `details` and a `tabs` key, at `slots.details`, with a `custom` issue that names both slots and the fix. Either slot may be one component or an array; an empty `details: []` is refused like a full one, and the page's `kind` does not matter. That covers `PageSchema`, `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate` and the metadata save door (`422 INVALID_METADATA`). The check is exported as `checkPageSlotPair`, for a mirror built from `PageSchema.shape` to re-attach. + + **What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` | + | both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot | + + **The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair. + + **Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is replayed unchanged at load, so it renders as before (the authored tabs, without the details body); its read diagnostics name the pair, and saving it again is refused until the details body moves. + + ### The kit + + - **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code. + - **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item. + - **The ledger.** The D3 semantic entry `page-slots-details-beside-tabs-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which tab item carries the details body, and under which label, is the author's decision. +- e5899a6: `fileAccessDelegate` and the refused file marker now describe what the delegate decides on a record read, beside the download + + Clause-②: no + + Wording only: no key, type, schema shape or runtime behaviour changes. A record read now follows the download door's field-owned verdict for a file the record owns, so the texts an author reads understated what a declared delegate decides. + + - **`fileAccessDelegate`.** This covers the object schema's description and TSDoc, the object form's help text in `en`, `zh-CN`, `ja-JP` and `es-ES`, and the generated reference pages. The named service authorizes downloads of files owned by the object's media fields. It also decides whether a reader who may not read `sys_file` sees those files' name, size and type in a record read of the object. It is asked once per owning record on each such read, so an implementation runs on reads, not only on downloads. A reader who may read `sys_file` never reaches it on a record read. It fails closed on both paths. + - **`FileRefusedValueSchema` (`{ id, metadataRefused: true }`).** The TSDoc and the `metadataRefused` description now give two cases for a reader who may not read `sys_file`: the record being read does not own the file, or the download verdict on the owning record did not allow it. A file the record owns and the verdict allows is served as the full file object. + - **`IFileAccessDelegate`.** The interface's TSDoc and its `authorizeFileRead` doc now name both questions one verdict answers: the download, and whether a reader refused `sys_file` sees the file's name, size and type in a record read (asked once per owning record per such read). The warning now covers metadata too: a permissive implementation leaks the metadata as well as the bytes. + + **For authors.** Nothing to change in metadata. An existing delegate now also decides a refused reader's file metadata on every read. It should keep applying the same rule its service uses to read the record. +- e865300: A formula field can declare a currency result: `returnType: 'currency'`, with its currency in its own `currencyConfig` + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec`.** `FieldSchema.returnType` accepts `'currency'` beside `'number'`, `'text'`, `'boolean'` and `'date'`. A `currency` result is an amount of money. Its value is a bare number, as a currency field's is. Its currency is the formula field's own `currencyConfig`, the same `CurrencyConfigSchema` a currency field carries, with the same defaults and the same refusals: no `currencyConfig` means dynamic mode (the tenant default currency), and `{ currencyMode: 'fixed', defaultCurrency: 'USD' }` fixes it. Nothing is read at display time from the fields the expression references: the currency is a declaration on the formula. + - **Readers.** The filter doors judge a formula returning `currency` as the currency field it reads like: a text operator on it is refused (`INVALID_FILTER`), and the number door judges its comparands. A formula is still not title-eligible unless it returns `text`, and is still refused for `sum` / `avg` / `min` / `max` (it has no stored column). + - **Designer forms.** The field designer and the object designer's field grid both offer Currency as a return type. The field designer shows its Currency Config row for a formula whose return type is Currency, as it does for a currency field. + - **What is new in `@objectstack/formula`.** `inferFormulaReturn(expression, fields)` returns the declaration authoring stamps onto a formula field, judged over the host object's declared field map: `{ returnType: 'currency' }` when the expression is provably an amount of money, plus `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when its source amounts are in one fixed currency. "Provably money" is unit checking over the expression: amounts in one currency added, subtracted, scaled by a number or a percent, divided by a number, rounded, or picked by `min` / `max` / `coalesce` / a ternary. Two different currencies, an amount multiplied by an amount, or an amount plus a plain quantity are not money. For everything it cannot prove to be money it answers exactly what `inferExpressionType` answers, so a numeric formula stays `number`. Its types `FormulaReturnDeclaration` and `FormulaSourceField` are exported with it. `inferExpressionType` is unchanged. + - **`@objectstack/platform-objects`.** The designer help text for the return type and the currency config rows names the currency result, in all four locales. + - **Nothing to migrate.** Every value accepted before is accepted now. To declare a formula over money as money, declare `returnType: 'currency'` and copy the source currency field's `currencyConfig` onto the formula, or run `inferFormulaReturn` and write what it returns. +- 31b5a5f: The Setup "Create User" and "Set Password" dialogs no longer offer a "Generate Temporary Password" box the server ignores once a password is typed + + Clause-②: no + + The `sys_user` actions `create_user` and `set_user_password` showed two controls for one decision: a "Generate Temporary Password" box (ticked by default on Create User) and a password field labelled "leave empty to generate". With a password typed, the box did nothing, yet it was sent ticked, so its state was false whenever the field was filled. + + The password field's label is now the only rule. `generatePassword` is no longer a dialog param on either action. It is sent as a constant `true` in each action's `bodyExtra`, and the endpoint's existing rule gives the label's behaviour: + + - **A typed password** is the one applied, and nothing is generated. + - **An empty password** generates a temporary password, shown once in the result dialog. On Set Password this is new: the box was unticked by default there, so an empty field answered `400` unless the admin ticked it. + + The endpoints `POST /api/v1/auth/admin/create-user` and `POST /api/v1/auth/admin/set-user-password` are unchanged. A caller that sends neither a password nor `generatePassword: true` still gets `400`. +- e5687cb: An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` + + Clause-②: yes (widening) + + - **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. + - **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. + - **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. + - **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. + - **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. + - **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES. +- ec7f7f8: feat(spec)!: retire an email template's `category` and `isSystem` — the grouping and the built-in flag nothing read (#22876) + + **BREAKING** — `category` and `isSystem` on an email template (`EmailTemplateDefinitionSchema`, the `email_template` metadata type) are now refused at parse. Nothing read either key once templates stopped being copied into `sys_email_template` rows: the Studio listing never offered the filter facet `category` promised, and no door refused deleting a template that set `isSystem`. Delete both keys. A template is found by its `name`, so group templates with a dotted namespace prefix (`auth.password_reset`, `crm.welcome`). A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. + + Clause-②: no (narrowing) + + Measured before removal: no reader of either key in `@objectstack/plugin-email`, `@objectstack/service-messaging`, `@objectstack/plugin-auth`, the metadata protocol, ObjectQL, the runtime or the REST layer, and none in the Studio console at its pinned commit beyond a fallback form input, which the console's own companion change removes. Both keys carried a schema default (`category: 'custom'`, `isSystem: false`), so every parsed template carried them and the liveness advisory fired on every template, with no way for an author to clear it. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `category: 'auth'` (or `'notification'`, `'workflow'`, `'marketing'`, `'custom'`) | nothing — delete the key; group by the `name` prefix | + | `isSystem: true` | nothing — delete the key | + | `isSystem: false` | nothing — delete the key | + | `import { EmailTemplateDefinitionCategorySchema } from '@objectstack/spec/system'` | nothing — the enum is removed with its key | + | `type EmailTemplateDefinitionCategory` | nothing — the type is removed with its key | + + **The one-line fix:** delete `category` and `isSystem` from every email template. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: + + > `email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). 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. + + > `email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. 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. + + ## The retirement kit + + - **Two `retiredKey()` tombstones** on `EmailTemplateDefinitionSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `system/EmailTemplateDefinition:category`, `system/EmailTemplateDefinition:isSystem`. The `category` enum, `EmailTemplateDefinitionCategorySchema`, had no other consumer and leaves with it: `RETIRED_DEFS_BY_MAJOR[18]`: `system/EmailTemplateDefinitionCategory`. + - **The D2 conversion `email-template-category-is-system-removed`** (protocol 18, retired from the load path) deletes both keys from every `emailTemplates[]` entry, one notice per key, as a lossless delete. Because both keys carried a default, every built artifact and every stored `email_template` row written under 17.x carries them; both load through the rehydration seams, which replay this conversion. The retired-default residue stage is not adopted: the replayed conversion heals those populations, and an authored source that still writes either key meets the prescription. + - **The D3 entry `email-template-category-is-system-retired`** asks the author whether anything they built read either key — a report or integration that filtered templates by `category`, or one that treated `isSystem: true` as protection against deletion. Nothing ever enforced that. + - **Studio:** the email-template form drops the `category` select and the `isSystem` checkbox. + - **Ledger:** both rows stay `dead`, re-noted as tombstones (a tombstone keeps the key in the walked shape). + - **No deprecation window**, per the project's startup-stage posture. + + **`@objectstack/plugin-email`:** the six built-in auth templates (in every locale) no longer write `category: 'auth'` or `isSystem: true`. They render and send exactly as before. + + **`@objectstack/platform-objects`:** the metadata-form translation bundles drop the two retired form inputs' labels (`email_template.fields.category`, `email_template.fields.isSystem`). The `sys_email_template` object and its `category` / `is_system` columns are unchanged. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 3071d3e: A host's translation bundle for a platform key now overrides the platform's text, whichever loads first + + Clause-②: no + + `PlatformObjectsPlugin` loaded its translation bundles (the Setup App, the `sys_*` objects, `metadataForms.*`) on `kernel:ready`, and the i18n service merges each load over the last. A bundle a host loaded earlier, during its own `init()` or `start()` (every `defineStack({ translations })` app is one), was overwritten for every key the platform also ships. Measured on both i18n providers: a host zh-CN bundle that set `objects.sys_organization.label` to 「工作区」 still served 「组织」. The docs already said such a bundle "would override the platform's". + + The platform bundles now load UNDER what the service already holds for each locale. + + - **A host's key wins in either order.** A bundle loaded before the platform's is laid back on top of it, and a bundle loaded after it wins as before. + - **Everything the host does not ship still serves the platform's text.** That covers sibling keys of a host key (a host field label leaves the platform's `help` for that field in place), and every locale the host does not ship. + - **Studio-authored translations are unaffected.** The bundles now load in `start()`, as soon as an i18n service exists, which is before the authored overlay is synced at `kernel:ready`. Deleting an authored override of a platform key brings back the platform text, as before. A kernel whose i18n service only appears later in Phase 2 (an `AppPlugin` registering the in-memory fallback on a lean kernel) gets the bundles on `kernel:ready` instead. + - **A provider without `getTranslations`** still gets the platform bundles, loaded over what is there as before, and the boot warns once that host bundles loaded earlier do not override it. + + No platform text changes: zh-CN `sys_organization` is still 「组织」. A host that wants different text ships it in its own bundle. The other platform packages' bundles share no key with this package's, and no example app's bundle repeats a platform key, so nothing else changes what it serves. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 6c379f03d34..bc916e45d82 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Core platform object schemas for ObjectStack — identity, security, audit, tenant, and metadata objects", "main": "dist/index.js", diff --git a/packages/plugins/embedder-openai/CHANGELOG.md b/packages/plugins/embedder-openai/CHANGELOG.md index ba9ab3d8546..1f7586a5907 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,177 @@ # @objectstack/embedder-openai +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index f8a4f8ff8bf..ea8acb7dd40 100644 --- a/packages/plugins/embedder-openai/package.json +++ b/packages/plugins/embedder-openai/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/embedder-openai", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "OpenAI-compatible embedder for ObjectStack — works against OpenAI, 阿里通义 DashScope, 智谱 BigModel, 硅基流动 SiliconFlow, 火山引擎 Doubao, MiniMax, Ollama, and any drop-in OpenAI-shape endpoint.", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-memory/CHANGELOG.md b/packages/plugins/knowledge-memory/CHANGELOG.md index ab27a799670..c5f5bbb71e1 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,200 @@ # @objectstack/knowledge-memory +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/service-knowledge@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 369798c7ae6..07ba4c3175e 100644 --- a/packages/plugins/knowledge-memory/package.json +++ b/packages/plugins/knowledge-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-memory", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "In-memory knowledge adapter for ObjectStack (dev / test reference implementation).", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-ragflow/CHANGELOG.md b/packages/plugins/knowledge-ragflow/CHANGELOG.md index 9bac1aef77a..61420550137 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,200 @@ # @objectstack/knowledge-ragflow +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/service-knowledge@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 99084b73e77..b80e2225016 100644 --- a/packages/plugins/knowledge-ragflow/package.json +++ b/packages/plugins/knowledge-ragflow/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-ragflow", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "RAGFlow knowledge adapter for ObjectStack — production-grade RAG via the Apache 2.0 RAGFlow REST API.", "main": "dist/index.js", diff --git a/packages/plugins/organizations/CHANGELOG.md b/packages/plugins/organizations/CHANGELOG.md index ead49479435..429519d50ec 100644 --- a/packages/plugins/organizations/CHANGELOG.md +++ b/packages/plugins/organizations/CHANGELOG.md @@ -1,5 +1,267 @@ # @objectstack/organizations +## 18.0.0-next.0 + +### Patch Changes + +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- e49aaec: The organization seed ownership claim no longer fires app hooks or record-change flows + + Clause-②: no + + When an organization's owner is established (for example, `ensureDefaultOrganization` binding the platform admin as the default organization's owner), `claimOrgSeedOwnership` hands that organization's owner-less seeded rows to the owner. That write ran under a bare system context, so every claimed row went through the full write pipeline: hooks bound from app metadata fired, record-change flows ran, and the approvals and notifications those flows start went out for a bookkeeping write. The single-tenant claim, `claimSeedOwnership` in `@objectstack/plugin-security`, already wrote without automation; the organization claim did not. + + The claim's write now runs with `{ isSystem: true, skipAutomations: true }`, the same context the single-tenant claim uses. + + - **App hooks no longer fire for the organization seed ownership claim.** No hook bound from metadata runs on the claim's owner change, and no record-change flow is dispatched for it, so the claim opens no approval and sends no notification. + - **Still runs.** Hooks that plugins register in code still run on every claimed row: the audit writer, capability gates and the sharing projection. Every claimed row's `owner_id` is the organization owner, as before. + - **The read is unchanged.** The claim's per-object read is still a plain system read. + - **If an app relied on it.** An app hook or flow that reacted to the claim's owner change no longer sees it, just as it never saw the seed's own writes. +- 275ad42: The organization seed ownership claim now owns every owner-less row, not only the first 10,000 of each object + + Clause-②: no + + `claimOrgSeedOwnership` hands an organization's owner-less seeded rows to the organization's owner. It read at most 10,000 owner-less ids per object and updated them one at a time, and nothing read again. The default-organization bootstrap runs the claim once (the ADR-0093 D7 once-gate), so on an object with more than 10,000 owner-less rows in one organization the rest stayed owner-less for good. Measured on a real engine over the SQLite WASM driver: 10,500 owner-less rows, one pass, 10,000 owned and 500 left. + + The claim now re-owns each object's owner-less rows with one predicate write (`owner_id = owner` where `organization_id = org` and `owner_id` is null), the shape the single-tenant claim `claimSeedOwnership` in `@objectstack/plugin-security` already uses. + + - **Every owner-less row is owned after one pass.** When an object has more rows than one write may fire per-row hooks over (the engine's per-row hook ceiling, 10,000), the engine refuses the write and nothing is written. The claim then re-owns one page of 5,000 rows and tries the whole remainder again, until no owner-less row is left. + - **The returned count is every row claimed.** `count` in each `{ object, count }` entry is the sum of the affected-row counts of every write the claim made for that object. + - **Same scope and the same write context.** Only this organization's rows with no owner are written, a page write included. Every write still runs with `{ isSystem: true, skipAutomations: true }`: no hook bound from app metadata and no record-change flow runs, and hooks that plugins register in code (audit, capability gates, the sharing projection) still run once per claimed row. + - **The claim reads only when it pages.** Below the ceiling it issues no read: the write's own predicate selects the rows. The page read, when there is one, is a plain system read. + - **A failure now costs the write, not one row.** A hook or validation rule that refuses a predicate write refuses all of it, so that object's rows (or that page) stay owner-less and the claim logs a warning naming the object and how many rows it had already claimed. Other objects are still claimed. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [8a995b8] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [f9ebb7d] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/plugin-auth@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json index 457c19d56e1..d3998189ecb 100644 --- a/packages/plugins/organizations/package.json +++ b/packages/plugins/organizations/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/organizations", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Multi-organization runtime for ObjectStack — registers the `org-scoping` service that turns single-database row-level Organization isolation on: `organization_id` auto-stamp on insert, per-org seed replay, default-organization bootstrap, and the walled-posture membership-policy gate.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md index f39d76519f9..b349778c40f 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,405 @@ # @objectstack/plugin-approvals +## 18.0.0-next.0 + +### Minor Changes + +- 5495331: feat(plugin-approvals): serve the approval request's record page, and declare the thread reply as `approval_comment` + + Clause-②: no + + **The request's record page is now metadata this plugin serves.** `ApprovalsServicePlugin` registers `sys_approval_request_detail` through its manifest `pages`: a slotted record page, default for every `sys_approval_request` record, that the metadata API serves like any other page. Its slots: + + - `actions`: one `record:approval_decision` node (the decision panel: the request's decision progress and its declared decision actions); + - `highlights`: the object's `highlightFields` without `record_id`, so `record_id` stays in the details grid, where the console draws the target record of the `record_id` / `object_name` pointer pair. The object's own `highlightFields` is unchanged; + - `tabs`: Details (`record:details`) and Timeline (`sys_approval_action` as a related list on `request_id`, newest first); + - `discussion: []`: no generic discussion thread. The request's thread is its timeline. + + The page label is translated in the plugin's bundle as `pages.sys_approval_request_detail.label` (en, zh-CN, ja-JP, es-ES). The page stays module-internal: nothing new is exported from the package entry. + + **`approval_comment` declares the thread reply.** `sys_approval_request` now declares a ninth action, `approval_comment` ("Reply"). It posts `comment` (a required textarea) and `attachments` (optional, several files) to `POST /api/v1/approvals/requests/{id}/comment`, the route the console drawer posted to by hand. The service stores the reply as a `sys_approval_action` row with action `comment`, which the Timeline lists. Its `visible` gate is the route's own admission: a pending approver (`record.viewer.can_act`), or the submitter while the request is `pending`. It has no admin-override arm, because the comment route admits none. +- a00cf99: feat(plugin-approvals): the record-reader approval tier honours an object's own `enable.approvalsVisibleToReaders` declaration + + Clause-②: yes (widening) + + - **Before this,** the read-only record-reader tier could be switched on only through the `recordReaderVisibleObjects` constructor option. A config-driven app, whose host builds the plugin with no options, had no way to reach it. + - **Now** the tier is on for an object when either source turns it on: the host's `recordReaderVisibleObjects`, or the object's own `enable.approvalsVisibleToReaders: true`. With neither, visibility is exactly as before. + - **Read where it is used.** The service reads the declaration from the object's live registered definition on each read that names a record, not once at start. An object registered after boot (an installed package, a Studio edit, a dev reload) takes effect at once. Removing the flag turns the tier off at once, with no restart. + - **Fails closed.** An engine that cannot answer an object definition, an unknown object, or any value other than `true` leaves the tier off. Default OFF costs one in-memory registry lookup on a read that names a record. The business record is never probed for an object that declares nothing. + - **Unchanged.** The rule itself: who counts as a reader, the read-only boundary, the request tables it covers on both doors, and the constructor option. +- 3d0eeef: feat(plugin-approvals): `ApprovalService.handleActionPage` serves the ADR-0043 action page from a web-standard `Request` (#22578) + + Clause-②: yes + + - **What is new.** `ApprovalService` implements the optional `IApprovalService.handleActionPage(request)` member: the session-less page an approver reaches from the approve or reject link in an e-mail or IM message, from a `Request` to a `Response`, with no raw app, no Hono context and no `http.server`. `GET` renders the confirm page from the `token` query parameter and never decides. `POST` redeems the `token` field of the form body as the approver the token binds, and answers the result page. A dead link (unknown, expired, consumed, decided or reassigned) is told on the page with `200`. Any other method answers `405` with `Allow: GET, POST`. + - **The same pages, the same checks.** The member answers what the plugin's self-hosted pages answer for the same token and method: the same status, `Content-Type: text/html; charset=utf-8` and bytes. It goes through the same `peekActionToken` / `redeemActionToken` and the same page renderers, and both doors share one token store, so a link used at one is dead at the other. The token is the only credential: no session, cookie or `Authorization` header is read. + - **Its one caller** is the runtime HTTP dispatcher's `/approvals/act` domain (#22576), which reaches a hosted tenant kernel that owns no socket. + - **The self-hosted mount is unchanged.** Where `http.server` exposes a raw app, the plugin mounts `GET` and `POST /api/v1/approvals/act` exactly as before, and the path is never mounted twice. Where there is none, the plugin now logs one `info` line at boot saying the pages are served through the dispatcher's `/approvals/act` domain; it was silent. +- e8c6666: feat(plugin-approvals): opening an approval step tells each resolved approver (`approval.requested`) + + Clause-②: yes (widening) + + - **What is new.** When an approval node opens a request, every concrete approver on the slate it opened on gets one notification on the new topic `approval.requested`: title "Approval requested", body "A decision on OBJECT/RECORD is waiting on you.", linked to that request in the Approvals Inbox, with the person whose run opened the step as its actor. It goes through the installed `messaging` service exactly as `approval.reminder`, `approval.reassigned`, `approval.escalated` and `approval.returned` do, so the same channels and delivery preferences apply. Before this, the opening was the one lifecycle step that published nothing: an approver learned that a step was waiting only by opening the inbox. + - **Who is told.** A named user, every holder of a staffed position or member of a team, and, when `onEmptyApprovers: 'fallback'` replaced an empty slate, the declared `fallbackApprovers`. Each person is told once, even when the slate lists them under two groups. + - **Who is not.** A `type:value` slot no one holds (`position:NAME` with no holders) names no person, so a step that resolves to nobody notifies no one, and `onEmptyApprovers` decides that case: `admin_rescue` opens on the literal slot and tells nobody, `auto_approve` and `fail` open no request. An out-of-office delegate gets one message, the existing `approval.ooo_substituted`, not a second one for the opening. Without a `messaging` service nothing is delivered, as before. + - **Not included.** The opening carries no one-tap Approve / Reject links. Those links are still minted only by a reminder. + - **If your flow already tells the approvers itself, remove that.** The approvals guide used to say that opening a request notifies nobody, and suggested a `notify` node next to the approval node as the workaround. A flow that followed that advice now tells the same approvers twice. Remove the `notify` node, or narrow its recipients to people the approval does not address. Keep `notify` nodes that tell the submitter or a watcher, or that report the decision. No flow in this repository's examples, `objectstack-ai/hotcrm` or `objectstack-ai/hotclm` has that workaround: every `notify` node in their approval flows runs after the decision. +- 86da194: fix(plugin-approvals)!: `sys_approval_token`, the action-link tokens, is no longer exposed through the automatic API + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing on a runtime door), shipped as `minor` under the launch-window convention for breaking changes. + + The object's own declaration already said its tokens are minted and consumed by the approval engine, never through the data API. It now says so in the form every door enforces, so the generic doors serve token rows to no caller, as the approvals door (`/api/v1/approvals/*`) serves them to nobody. + + **What narrows, from → to.** + + - The declaration: `enable.apiMethods: ['get', 'list']` becomes `enable.apiEnabled: false` with `enable.apiMethods: []`. `apiEnabled: false` is the automatic API's off switch, judged first for every operation; `apiMethods: []` is the declared fully-closed whitelist, so the whitelist no longer advertises the read methods either (an absent whitelist would read as fully open). + - The REST data routes (list, query, by id, export, and every write): a caller granted read on the object was served its rows, which name the request, the identity a link is bound to, the action, the expiry and the consumption state, and so was an administrator. Every caller, an administrator included, now receives `404 OBJECT_API_DISABLED`. The dispatcher and the MCP data tools judge the same switch first. + - Without a grant, the answer moves from `403 PERMISSION_DENIED` to `404 OBJECT_API_DISABLED`: the exposure gate is judged before the permission check. + - The cross-object search (`GET /api/v1/search`) no longer sweeps the object, for any caller. + + **Unchanged.** The approval engine's own token path, which runs as the system: a reminder still mints one approve and one reject link per concrete pending approver, the link's confirm page still validates without consuming, one POST still decides as the bound approver, and a replay is still refused as already used. The digest is still stored, indexed and withheld from every generic exit (`internal: true`). + + **What changes for you.** Nothing in the platform, the console or Setup reads this object through a generic door, so no shipped surface changes. A script or integration that read token rows through the data API now receives `404 OBJECT_API_DISABLED`. There is no replacement read, by design: a link's state is what its confirm page answers, and a decision taken through a link is recorded in the request's action history on the approvals door. +- 896a434: fix(plugin-approvals,plugin-audit)!: a lookup target's title is read only when the TARGET object's declared exposure serves `get` — the approvals inbox's `payload_display` and the activity summary (#22738) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The data routes judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`), and the data door's `$expand` and the dataset door's labels already ask it of a lookup's TARGET. Two more reads follow a lookup to its target's title under a system context and never asked it. + + **FROM.** For an administrator and a member alike: + + - **Approvals inbox** (`@objectstack/plugin-approvals`): `GET /api/v1/approvals/requests` and `GET /api/v1/approvals/requests/:id` carried, in `payload_display`, the title of a snapshot lookup's target whose declaration refuses `get`. + - **Activity summary** (`@objectstack/plugin-audit`): an update's tracked-change summary, and a fired milestone's summary, named such a target's records by title in the `sys_activity` row served by `GET /api/v1/data/sys_activity`. + + **TO.** Each title read first asks the decision of the TARGET object, for `get` (turning an id into the record it names, the read `GET /api/v1/data/:target/:id` performs): + + - A refused target is not read. The inbox carries no `payload_display` entry for that key, so the snapshot's stored id stands, which is what a deleted target already answers. The activity summary names the record by its stored id, which is what an unresolvable reference already answers. + - A declaration that cannot be read withholds the title too (fail-closed, at `warn`). + - Only activity rows written from now on change. A written row is a snapshot and is never rewritten, and no other activity column changes. + + A target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']`) is withheld; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. The decision takes no caller. + + **Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (control: 88 lookups into `sys_user`). So no shipped object's served title changes. Deployed and cloud-held object definitions were NOT MEASURED. +- 69d4218: feat(plugin-approvals): an approval step whose approvers resolve to no one opens on the organization's administrators under the default `onEmptyApprovers: 'admin_rescue'`, as ordinary approvers in their inbox + + Clause-②: yes + + **FROM → TO: who receives an `admin_rescue` request.** A node whose approvers resolve to no concrete person (an unstaffed position, an empty team or department, an unset manager, an expression that yields no one) under the default `onEmptyApprovers: 'admin_rescue'`: + + - **FROM:** the request opened on a slate that named no one, such as the literal `position:sales_manager`. It was in no one's "My Pending", the bell counted 0, no one was told, and a `lockRecord: true` record stayed locked until a platform or tenant admin found it under **All** and decided it through the privileged override. + - **TO:** the request opens on the request organization's administrators: the people who hold the organization-admin capability (`organization_admin` or `organization_admin_no_bypass`, ADR-0095 D3) in that organization. That is the same authority that lets a tenant admin override a request, resolved to the people who hold it, through the same grant resolution every request runs. An organization's owners and admins receive the capability automatically. + + They are ordinary approvers. The request is listed and counted for each of them under their own `approverId`, each is told once by `approval.requested`, out-of-office delegation applies to them, and a decision by one of them is an ordinary approval or rejection, recorded with `via_override: false`. The node's `behavior` reads them as one group. A platform administrator is not added for being one; one who also holds the organization's capability, such as its owner, is on the slate for that. + + The request says why. Its timeline carries a note, for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder.", and its node-config snapshot keeps the abandoned slate and the reason under `__resolvedFrom` (`admin_rescue:abandoned`, `admin_rescue:reason`). The abandoned slate is replaced, not kept beside the administrators, so a holder staffed into the position after the request opened does not receive it; an administrator can reassign it to them. + + **Unchanged.** A slate that names a person; `fallback`, `auto_approve` and `fail`; a `fallback` whose own list resolves to no one still degrades to `admin_rescue`, which now opens on the administrators. The privileged override stays for what the administrators' slate cannot reach: a request whose named approvers have all left after it opened, and an organization with no administrator (or a request with no organization), where the request still opens on the slate that names no one and the server logs a warning. + + **To name the person who should decide instead of the administrators,** declare `onEmptyApprovers: 'fallback'` with a `fallbackApprovers` list. + + **`@objectstack/spec`:** the `onEmptyApprovers` description says that `admin_rescue` opens on the organization's administrators and that `fallback` names who decides instead. + + **`@objectstack/lint`:** `approval-approvers-may-resolve-empty` no longer says the request "waits forever": it says the request opens on the organization's administrators, its remedy names `onEmptyApprovers: 'fallback'` as the way to name who decides, and its `os explain` text says the same. + +### Patch Changes + +- f0022c4: Setup → Approvals → Requests opens on every approval request, not on the requests pending on the administrator. Before this, the entry named no view, and `sys_approval_request` declared the caller-scoped "My Pending" view (`pending_approvers contains {current_user_id}`) first, so the console opened it. + + Clause-②: no + + - `sys_approval_request` now declares its unscoped "All" view (`all_requests`) first. "My Pending", "I Submitted" and "Completed" follow it in their previous order, still as tabs. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens "All". No view is added, removed or changed. + - The Setup entry `nav_approval_requests` now names `all_requests` with `viewName`, so it does not depend on the declared order. The Account app's Approvals entry opens the Approvals Inbox component and reads neither. + - The declared order decides which view opens, not which rows a caller may read. + - The generated translation bundles follow the new view order. No translated text changed. + - ⛔ No schema, parse, export or accept-set change. +- 0b7ba4d: fix(plugin-approvals): Approve asks its decision question in the dialog, as Reject does + + Clause-②: no + + `approval_approve` on `sys_approval_request` declares a `description`: "Approve this request? Your approval is recorded, and the request moves on once this step has the approvals it requires." The console shows it as the parameter dialog's subtitle, where a generic "Please provide the required information to continue." showed before; `approval_reject` beside it already declared its question this way. The zh-CN, ja-JP and es-ES bundles carry a translation of it. +- 3403be8: fix(plugin-approvals): `sys_approval_request` declares its per-caller `viewer` block under `attachedOnRead`, so the shared validator accepts the object's own action predicates (#22211, #22387) + + `sys_approval_request` ships eight action `visible` predicates that read `record.viewer.can_act`, `record.viewer.can_override` or `record.viewer.is_submitter`. `viewer` is the block the approvals service attaches to every request it serves through `listRequests` and `getRequest`, computed from the caller. The object never declared it, so `os build` / `os validate` refused all eight with ``unknown field `viewer` on `sys_approval_request` ``, and the object save door's authoring gate refused a save of the shipped body the same way. + + The object now declares `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. The eight predicates pass the build and the save door's gate. A misspelt leaf (`record.viewer.can_actt`) is still refused, and the refusal names the three declared leaves. + + Unchanged: who sees which decision button. `viewer` is computed as before and served on the same two reads only; the generic data door, a record-change flow's `record` and a flow action's subject row do not carry it. The served object definition gains the `attachedOnRead` key. The exported `SysApprovalRequest` type resolves to the same type. +- b98e984: fix(plugin-approvals): a `manager` approver whose record owner has no manager adds no slot to the request, instead of the literal `manager:undefined` + + Clause-②: no + + An approval request whose user-resolving approver resolves to nobody now opens on an empty `pending_approvers`, and the node's `onEmptyApprovers` policy applies, as `content/docs/automation/approvals.mdx` documents ("An entry that resolves to nobody is not an error: the request opens with an empty `pending_approvers`"). This covers `{ type: 'manager' }` when the record's owner has no `sys_user.manager_id`, or the manager is outside the request's organization, and `{ type: 'field' }` when there is no record to read. + + Before this fix the expansion stored a literal `manager:` slot: `manager:undefined` when the approver authors no `value`, which is the usual case. Both read doors (`GET /api/v1/approvals/requests` and `GET /api/v1/data/sys_approval_request`) served that slot. Nobody can ever act under it: a caller acts only under their user id, their own account's email, or the `position:` address of a position they hold. + + What changes, and for whom: + + - **A slate made only of such rungs** opens with `pending_approvers: []` under the default `onEmptyApprovers: 'admin_rescue'`, where it held `["manager:undefined"]` before. `fail`, `auto_approve` and `fallback` behave as before, because those policies already treated a slate of literals as empty. + - **A slate with other approvers** (for example `[{ type: 'manager' }, { type: 'user', value: 'u9' }]`) is carried by the others. Under `unanimous`, `quorum` or `per_group`, the dead literal used to be an unapprovable slot: it held the request open after every real approver had approved, until an admin overrode it. Now the real approvers' approvals finish the step. This is the "second approver entry that cannot resolve empty" escape that the docs name for an unset manager. + - **Group approvers are unchanged.** An unstaffed `position` still opens on its `position:` literal, which a holder staffed into the position later decides. `team`, `department` and `org_membership_level` still fall back to their `type:value` literal. + - The `manager` rung still logs a warning when it resolves to nobody. The warning now says that the rung adds no slot, and names the subject user. + + Stored requests are not rewritten. A pending request that already holds a `manager:…` or `field:…` slot keeps it, and it stays recoverable as before: a platform or tenant admin can approve, reject, reassign or recall it. To list such requests, filter `sys_approval_request` on `status = pending` and on `pending_approvers` containing `manager:` or `field:`. +- 76bc1e0: fix(plugin-approvals): the generic data door serves `sys_approval_request` rows by the approvals door's visibility + + Clause-②: no + + A read of `sys_approval_request` through the generic data door (`find`, `findOne`, `count` and `aggregate`, so the REST data routes, the export route, the MCP data tool and every other engine read) now returns only the requests the approvals door (`/api/v1/approvals/*`) serves the same caller: + + - the requests the caller submitted, is a current approver of, or has acted on; + - every request, for an administrator holding the approval override; + - read-only, on an object named in `recordReaderVisibleObjects`, the requests of a record the caller can read, on a read that names that record (`object_name` and `record_id`, or the request's id). + + Before this, a deployment that granted read on the object served every request of the organization on that door, including the payload snapshots of records the caller cannot open. + + - **One rule for both doors.** The gate asks the approvals service for its own visibility (the same rule `listRequests` and `getRequest` apply), through an in-package handle: the published `ApprovalService` type is unchanged. It is not the activity stream's parent-record gate, which admits every reader of the record: the record-reader tier stays off unless the deployment names the object. + - **Absence, not refusal.** A request the caller may not see answers a by-id read exactly as a missing one does, `404 RECORD_NOT_FOUND`. A list's `total` and a grouped count are narrowed like its rows. A failed visibility answer denies the read and logs a warning. + - **Unchanged.** The approvals door, an administrator's view (Setup → Approvals → Requests), and the approval engine's own reads. A deployment that grants no read on the object still answers `403`. + - The record-reader tier no longer anchors on `sys_approval_request` itself, even when `recordReaderVisibleObjects` names it. +- 83b8b80: fix(plugin-approvals): the generic data door serves `sys_approval_action` and `sys_approval_approver` rows by their request's visibility + + Clause-②: no + + A read of a request's two child tables through the generic data door (`find`, `findOne`, `count` and `aggregate`, so the REST data routes, the export route, the MCP data tool and every other engine read) now returns only the rows whose request the approvals door (`/api/v1/approvals/*`) serves the same caller: + + - `sys_approval_action`, the decision log (actor, decision, comment text); + - `sys_approval_approver`, the pending-approver index. + + Before this, a deployment that granted read on either object served every row of the organization on that door, including the decision log and comment text of requests the caller does not take part in. + + - **One rule for both doors, and for all three tables.** The gates ask the approvals service the same question the `sys_approval_request` gate asks, keyed by the row's `request_id`: the request's submitter, its current approvers and those who acted on it see its rows; an administrator holding the approval override sees every row; and, read-only, on an object named in `recordReaderVisibleObjects`, a reader of the record sees its request's rows on a read that names the request (`request_id`) or the row (its id). The published `ApprovalService` type is unchanged. + - **The record page's Timeline keeps its readers.** A request's Timeline (a list of `sys_approval_action` keyed on `request_id`) serves the rows `listActions` serves the same caller; a row read by id is served exactly when a decision attachment on it would be (`authorizeFileRead`, which now shares the gate's read of the row's request). + - **Absence, not refusal.** A row the caller may not see answers a by-id read exactly as a missing one does, `404 RECORD_NOT_FOUND`. A list's `total` and a grouped count are narrowed like its rows. A failed visibility answer denies the read and logs a warning. + - **Unchanged.** The approvals door, an administrator's view, and the approval engine's own reads. A deployment that grants no read on either object still answers `403`. + - The record-reader tier no longer anchors on a row of either table, even when `recordReaderVisibleObjects` names it, as it already never anchored on `sys_approval_request`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index 3493ed683c0..f5a1a183b71 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-approvals", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-audit/CHANGELOG.md b/packages/plugins/plugin-audit/CHANGELOG.md index 90a20b8ffa6..48e7b704763 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,515 @@ # @objectstack/plugin-audit +## 18.0.0-next.0 + +### Minor Changes + +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- 3599fef: `AuditPluginOptions` gains `getLocale`, an optional host locale resolver asked before the settings-derived locale, so a multi-organization host can write each organization's activity summaries in its language + + Clause-②: yes (widening) + + - **The option.** `new AuditPlugin({ getLocale })`, where `getLocale` is `(tenantId?: string, userId?: string) => string | undefined | Promise`. It picks the language of `sys_activity.summary` and of the @mention notification title the plugin emits, and is called with the write's organization and user (for an @mention title, the mentioned recipient). + - **Precedence: host first, settings second.** An answer that is a well-formed BCP-47 tag is used, in its canonical form (`zh-cn` → `zh-CN`). `undefined`, `null` or a blank string falls back to the settings-derived `localization.locale` (ADR-0053) the plugin used before this option. So do a malformed answer (`zh_CN`, a non-string) and a throw or rejected promise; each of those two faults is logged once at `warn`, and neither fails the audited write. There is no other locale source. + - **Without the option, nothing changes.** The settings-derived locale stays the only source, as before. + - The locale only picks the message catalog, and a summary is rendered once, when its row is written. Rows already written keep their text. +- ce3d0ad: fix(service-storage,plugin-audit)!: the `sys_attachment` and `sys_comment` parent gates judge a `controlled_by_parent` parent through its master, so a member can no longer attach files to, or delete other people's files or comments from, a child record they may not edit + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. It follows ADR-0055. + + **What stops being accepted.** + + - A `controlled_by_parent` record takes its access from its MASTER. Record sharing does not enforce on it, so the sharing service's edit verdict abstains on every such record, and both gates used to take that abstention as permission. A member holding the `sys_attachment` create or delete bit, or the `sys_comment` delete bit, could therefore attach files to, and delete other people's files and comments from, every master-detail child record of the organization. That included records whose own `PATCH` refuses the member, and records the member cannot read. + - Both gates now ask the sharing service's tri-state `checkEdit`. `allow` admits and `deny` refuses, as before. On an abstention they ask the security service's master-detail write check (`ISecurityService.checkControlledByParentWrite`), which is the answer a by-id update of the parent gets. Its `allow` and `not_applicable` admit. Its `deny` refuses, and so does an `unresolvable` outcome: a broken master-detail declaration, a record that does not exist, or an empty master reference. A datasource fault propagates with its `503`. + - The refusals keep today's envelopes. An attach or a re-point answers `403 ATTACHMENT_PARENT_ACCESS`. A delete of another user's file answers `403 ATTACHMENT_DELETE_DENIED`, and an update of it answers `403 RECORD_NOT_ACCESSIBLE`. A delete or update of another user's comment answers `403 RECORD_NOT_ACCESSIBLE`. A caller who cannot read the parent still gets the not-visible `403 PERMISSION_DENIED`. + + **What is unchanged.** An uploader still deletes their own file, and an author still deletes their own comment. Posting a comment still needs only read access to the record. A parent that is not `controlled_by_parent` is judged exactly as before, and a `public_read_write` parent still admits every member. A kernel whose security service does not serve the master-detail write check composes no master check, and there the gates admit on the abstention as before, which is also what the parent's own update gets. + + **What changes for you.** A member who was attaching or moderating on a child record through this gap now gets the refusal above. To give them that access, grant them edit access on the MASTER record: ownership of it, an `edit`-level record share, the `modifyAllRecords` bypass on the master object, or an app-authored `update` row-level security policy on the master that admits the row. Then their `PATCH` of the child and their attachment and moderation rights on it agree. The port types `AttachmentSharingLike` and `CommentSharingLike` now pick `checkEdit` rather than `canEdit`. The registered sharing service serves both, so a caller passing it is unaffected. `installAttachmentAccessHooks` and `installCommentAccessHooks` take an optional trailing resolver for the security service (new port types `AttachmentSecurityLike` and `CommentSecurityLike`). The two plugins pass it themselves. +- 29a163f: `sys_comment_reaction`: an emoji reaction to a comment is now the reacting user's own record, one row per comment, emoji and user + + Clause-②: yes (widening) + + - **The object.** `@objectstack/plugin-audit` registers `sys_comment_reaction` beside `sys_comment`. A row holds `comment_id` (the reacted comment's id), `emoji` (the character sequence, at most 64 characters) and `user_id` (the user who reacted). The data API offers `get`, `list`, `create`, `delete` and `bulk` on it, so `createMany`, `deleteMany` and `/batch` creates and deletes work too. It offers no `update`, so `updateMany` stays refused: a reaction is removed and made again, not edited. Every row of a batch meets the same rules as a single write. + - **Who may read it.** A reaction is readable exactly when its comment is. Every read of the object (`find`, `findOne`, `count`, `aggregate`, and so the list total) is narrowed to the rows whose comment the caller can read, decided by a read of those comments under the caller's own context. A reaction whose comment was deleted is read by nobody. + - **Who may write it.** A caller reacts only to a comment it can read; on any other comment, including one that does not exist, the create is refused with `403 RECORD_NOT_ACCESSIBLE`. `user_id` is set from the signed-in user, and a value the client sends is replaced; a session with no user is refused. A member deletes their own reaction, and not another member's, by the platform's own-record rule (`owner_only_deletes`). The author-only edit rule on `sys_comment` is unchanged: reacting no longer writes the comment's row. + - **One row per reaction.** `(comment_id, emoji, user_id)` is unique within an organization. Two members reacting at once write two rows. The same reaction a second time is refused, and the data API answers `409 UNIQUE_VIOLATION`. + - **Reading reactions for a feed.** Read the reactions of the comments on screen in one request, `comment_id` `$in` those ids, and group the rows by comment and emoji. Nothing is stored or computed on the comment row. + - **Granting it.** The object's create, read and delete bits come from a permission set, as `sys_comment`'s do. The `everyone`-bound baseline (`member_default`) grants nothing on it. + - **`@objectstack/spec`: the platform object registry lists it.** `PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']` now includes `sys_comment_reaction`, and so do `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName`. A stack that references the object is no longer reported as naming an unknown platform object. This is the only change in the package. + - **`sys_comment.reactions` is unchanged in this release.** The column is still declared and still written by clients that write it. It is retired separately, and reactions stored in it are not converted into records. +- 9963322: feat(plugin-audit)!: `sys_comment.reactions` retires; an emoji reaction lives only in `sys_comment_reaction`, and reactions stored in the old column are not migrated + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + **Existing emoji reactions stored in `sys_comment.reactions` are gone after the upgrade.** They are not converted into `sys_comment_reaction` records, and no screen or API returns them again. Reactions now live in `sys_comment_reaction`, one row per comment, emoji and user, the reacting user's own record. Where the entry that introduces `sys_comment_reaction` says the column is unchanged, this entry supersedes it: the column retires. + + - **`sys_comment`** (`@objectstack/plugin-audit`) no longer declares `reactions`. Nothing takes its place on the comment row: no stored aggregate, and no block computed on read. Its label leaves the four generated translation bundles. + - **The data door refuses the field.** A create or an update of a comment that still carries `reactions`, and a list filter on it, answer `400 INVALID_FIELD` naming the field, and nothing is written. The rest of the payload is not applied either. A read of a comment no longer returns `reactions`. + - **`@objectstack/spec`**: the protocol-18 step of the ADR-0087 migration chain gains the semantic entry `sys-comment-reactions-column-retired`, so `os migrate meta` and the upgrade guide carry the prescription below. + + **What moves for consumers.** + + | You wrote | Write instead | + |:---|:---| + | `sys_comment.reactions` read as a map of emoji to user ids | one read of `sys_comment_reaction` for the comments on screen (`comment_id` `$in` their ids), grouped by comment and emoji | + | an update of `sys_comment` that rewrites `reactions` to add or remove a user | create the user's own `sys_comment_reaction` row (`comment_id`, `emoji`) to react; delete that row to un-react | + | `reactions` in a seed row, filter or list-view column on `sys_comment` | delete it | + + An application that shows reactions grants `sys_comment_reaction` read, create and delete beside its `sys_comment` grant; without it a member cannot react. The console's record chatter already reads and writes `sys_comment_reaction` rows wherever the deployment declares that object, as this framework does. + + **Existing databases — nothing moves automatically.** Schema sync is additive: the physical `reactions` column stays with the values it holds, the boot drift report names it orphaned (`sys_comment.reactions: column exists in the database but not in metadata`), and the boot is otherwise clean. The data door neither returns nor accepts the column. To drop it, run `os migrate apply --allow-destructive` with the audit plugin composed (an auth secret resolves); the stored reactions go with it. +- 896a434: fix(plugin-approvals,plugin-audit)!: a lookup target's title is read only when the TARGET object's declared exposure serves `get` — the approvals inbox's `payload_display` and the activity summary (#22738) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The data routes judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`), and the data door's `$expand` and the dataset door's labels already ask it of a lookup's TARGET. Two more reads follow a lookup to its target's title under a system context and never asked it. + + **FROM.** For an administrator and a member alike: + + - **Approvals inbox** (`@objectstack/plugin-approvals`): `GET /api/v1/approvals/requests` and `GET /api/v1/approvals/requests/:id` carried, in `payload_display`, the title of a snapshot lookup's target whose declaration refuses `get`. + - **Activity summary** (`@objectstack/plugin-audit`): an update's tracked-change summary, and a fired milestone's summary, named such a target's records by title in the `sys_activity` row served by `GET /api/v1/data/sys_activity`. + + **TO.** Each title read first asks the decision of the TARGET object, for `get` (turning an id into the record it names, the read `GET /api/v1/data/:target/:id` performs): + + - A refused target is not read. The inbox carries no `payload_display` entry for that key, so the snapshot's stored id stands, which is what a deleted target already answers. The activity summary names the record by its stored id, which is what an unresolvable reference already answers. + - A declaration that cannot be read withholds the title too (fail-closed, at `warn`). + - Only activity rows written from now on change. A written row is a snapshot and is never rewritten, and no other activity column changes. + + A target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']`) is withheld; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. The decision takes no caller. + + **Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (control: 88 lookups into `sys_user`). So no shipped object's served title changes. Deployed and cloud-held object definitions were NOT MEASURED. +- dc93781: `sys_comment` edit marks are platform-written: an update that changes a comment's `body` stamps `is_edited: true` and `edited_at` from the server clock, and a client can no longer set or clear either field. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - **Stamped by the platform.** The comment's `beforeUpdate` sets both fields when the update's `body` differs from the stored body, for every caller, the way the engine stamps `updated_at`. An update that does not name `body`, or sends the stored text again beside new `mentions`, is not an edit and stamps nothing. A create is not an edit either. + - **Read-only at the data door.** `is_edited` and `edited_at` now declare `readonly: true`. On an update that edits the body, the stamp replaces whatever the client sent; on any other update a non-system caller's value is dropped and reported in the response's `droppedFields`; on a create the platform sets both to "not edited" over a client value. Nothing is refused for carrying the fields — a client that still sends them on every edit, as the console released before this change does, keeps working and its values are ignored. Read both fields back from the row instead of writing them. + - **Predicate updates.** A `multi: true` update is stamped per matched row. One whose matched rows disagree — some bodies change, some already hold the new text — is now refused whole with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (400) and writes nothing, because one SET clause cannot mark some rows and not others. Narrow the predicate to the rows whose text changes, or update by id. + - A `preserveAudit` (historical import) update keeps a mark it supplies, as it keeps `updated_at`. + +### Patch Changes + +- d1d42ec: `sys_activity.actor_name` is written: a user's create, update or delete records that user's display name on its activity row, so the record History tab names who acted instead of "Unknown user" + + Clause-②: no + + `sys_activity` declares `actor_name` and lists it among its highlight fields, because activity + entries are denormalized snapshots read in time order. The audit writer filled `actor_id` and never + `actor_name`, so every row carried the user's id and no name. A renderer that reads the name, such + as the record History tab, showed "Unknown user" for every entry, for every user. + + The writer now records the name when it writes the row. The name is the title of the user's + `sys_user` row (its `name` column), read once per user and kept for 30 seconds. A predicate write + over many rows costs one `sys_user` read for its actor, not one per row. + + - **A user write.** `actor_name` is the user's display name. `actor_id` is unchanged, and the name + is read for that same id. This includes a write the system authorized on behalf of a credited + user (`attributedUserId`). + - **A user whose name cannot be read** (no row, or a blank name). `actor_name` stays empty. The + writer never puts the id in it. + - **A write with no user** (boot, migration, a scheduled job, a flow running as the system). + `actor_name` stays empty, as before. Per ADR-0118 D1 the system actor is `null`, and the UI + renders the "System" label for it. + - **An object with `enable.activities: false`.** No activity row is written and no name is read, + as before. + + Rows written before this change keep `actor_id` and no name. This change does not backfill them. + Nothing you author changes: no key, option, export or type is added or removed. +- e194ab4: `sys_activity.actor_avatar_url` is written: a user's create, update or delete records that user's profile image on its activity row, so the record History tab and the activity feed show the user's avatar instead of the generic one + + Clause-②: no + + `sys_activity` declares `actor_avatar_url` as one of its denormalized actor columns, and the record + History tab and the record activity feed both read it as an image source. The audit writer never + filled it, so every row carried a null avatar, even for a user with a profile image. + + The writer now copies the acting user's `sys_user.image` (the profile-image URL that + `POST /api/v1/auth/update-user` and the console's avatar uploader write) into `actor_avatar_url` + when it writes the row. The value is copied as stored. It comes from the same `sys_user` read that + already supplies `actor_name`, read once per user and kept for 30 seconds, so the avatar adds no + read: a predicate write over many rows still costs one `sys_user` read for its actor. + + - **A user with a profile image.** `actor_avatar_url` is that image's URL. + - **A user with no profile image.** `actor_avatar_url` stays empty, as before. + - **A write with no user** (boot, migration, a scheduled job, a flow running as the system). + `actor_avatar_url` stays empty, as before. Per ADR-0118 D1 the system actor is `null`, and how + the system is shown is the renderer's choice. + + Rows written before this change keep their empty avatar; a later change to a user's image shows on + rows written after it, as with the name. +- 8bd0fcd: A `sys_activity` row that an `activityMilestones` entry fired carries `metadata.kind: 'milestone'`, so a reader can tell a milestone from an ordinary update or a finished task + + Clause-②: no + + ADR-0052 §5 keeps `sys_activity.type` domain-neutral and puts a row's domain kind in + `metadata.kind`. A fired milestone keeps the `type` its declaration names: `completed`, or + `updated` when it names none. Nothing on the served row said it was a milestone. So a "Case + resolved" milestone declared with `type: 'completed'` read as a finished task, and a feed that + hides completed tasks hid it too. + + The CRUD mirror now writes `metadata.kind: 'milestone'` on every row a milestone fires. `type` is + unchanged, and a row that no milestone fired carries no `kind`. + + The kind is served by the same rule as the text beside it. "A milestone fired" means that a + watched field entered its declared value, so: + + - **A reader served every field that a declared milestone watches** is served `metadata.kind`. + - **A reader not served one of those fields** is served the row without `metadata.kind`, just as + it is served the change without that field. + - **A row an app writes itself** keeps its own `metadata.kind` for every reader, as written. + + The rest of the redaction is unchanged. The provenance declaration (`metadata.text_sources`) is + never served, and a change key the reader is not served is dropped. Rows written before this change + carry no kind, and this change does not backfill them. Nothing you author changes: no key, option, + export or type is added or removed. +- f313fbc: A `sys_activity` row that an `activityMilestones` entry fired is withheld from a reader who is not served every field a declared milestone watches + + Clause-②: no + + A fired milestone's row speaks about its watched fields: its `type` and its summary come from the + milestone declaration, beside `metadata.kind`. Narrowing the row column by column left those two + columns speaking to a reader not served a watched field. + + The row is now withheld from that reader as a row, on every read (`find`, `findOne`, `count` and + `aggregate`), by the same pre-scan and the same served-field answer that withhold an update whose + every recorded change the reader is not served. A list's total, its pages and a by-id read all + agree with the rows served. + + - **A reader served every watched field** is served the row unchanged, with `metadata.kind`. + - **A reader withheld only fields no milestone watches** is served it too. + - **A row no milestone fired**, and a row an app writes itself, are unchanged. + - **If the pre-scan fails**, the read is denied rather than served unjudged. + + Rows written before the CRUD mirror declared the watched fields carry no `metadata.kind`, so nothing + on them says a milestone fired; they are judged as before. Nothing you author changes: no key, + option, export or type is added or removed. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- 6a40a09: A failed boot provisioning of an audit table is logged at `error`, naming what is lost and the fix + + Clause-②: no + + At `kernel:ready`, `AuditPlugin` creates the storage for `sys_audit_log`, `sys_activity`, `sys_comment` and `sys_comment_reaction`. When the driver refuses one of those DDL statements, the boot still completes and the deployment looks healthy, but the table does not exist. For `sys_audit_log` and `sys_activity` the loss is silent: audited writes keep succeeding while their audit and activity rows have nowhere to land. That failure was logged at `warn`. It is now logged at `error`, once per object per boot and never per write. The boot is still not failed. + + - **What the line says.** It names the object, what is lost without it, and that the deployment keeps looking healthy. It gives the fix: resolve the driver's refusal, then restart (this provisioning runs on every boot) or run `os migrate apply`, which creates the missing storage. `os migrate plan` lists it as a `create_table` entry for that object. Both remedies were measured to create a missing `sys_activity` once the fault was gone, with and without the telemetry sibling database. + - **Where the driver's error is.** In the record's meta as `{ object, error }`, not in the message, so a multi-line driver error stays one record. + - **What is unchanged.** The other objects are still provisioned when one fails, and the placement lines (`system tables provisioned`, the non-default datasource note) are unchanged. A log search on `could not provision sys_activity storage` (or any of the four object names) still matches the line. +- 8e4a230: fix(plugin-audit): a user's own write to their own `sys_user_preference` row no longer adds a `sys_activity` row. The `sys_audit_log` row is written as before + + Clause-②: no + + The console saves the user's "recently visited" list (`ui.recent`), favourites and palette recents as `sys_user_preference` rows as they navigate. The audit writer mirrored each of those writes into `sys_activity`, and the bell's Activity tab lists the newest activity rows the caller may read. So every navigation came back to the user as `Updated User Preference "ui.recent"`, and a fresh user's bell showed nothing else. + + - **What changed.** For a per-user UI-state object, the writer skips the activity row when the acting user is the row's owner (`user_id`). The list of such objects is closed and declared beside the writer's exclusion list. Today it holds one object, `sys_user_preference`. + - **What stays.** The `sys_audit_log` row for the same write is written exactly as before. Another actor's write to a user's preference row keeps its activity row: an organization administrator editing it from Setup, a system write with no user, or a write that gives the row a different owner. Writes to every other object are unchanged. + - **Existing rows.** Activity rows written before this release are not deleted. They leave the feed when the `sys_activity` retention window (14 days) removes them. + - **Nothing to migrate.** No configuration is needed. +- d294951: Boot-time system-table provisioning honours `OS_SKIP_SCHEMA_SYNC` / `skipSchemaSync` + + Clause-②: no + + `OS_SKIP_SCHEMA_SYNC=1`, or `ObjectQLPlugin`'s `skipSchemaSync` option, declares that a deployment's DDL is managed out-of-band ("Use after running migrations manually"). The engine's boot honoured it, but `AuditPlugin` and `MessagingServicePlugin` still created their system tables at `kernel:ready`. A deployment that set the flag got tables created behind its back while its boot log said no DDL ran, and a database role without DDL rights got a DDL failure it never asked for. + + - **What changes.** With the flag set, `AuditPlugin` (`sys_audit_log`, `sys_activity`, `sys_comment`, `sys_comment_reaction`) and `MessagingServicePlugin` (`sys_notification`, `sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_subscription`, `sys_notification_template`, `sys_http_delivery`) issue no DDL and no existence probe at boot. Each logs one `info` line naming the objects it did not provision and the flag, and pointing at `os migrate apply`, which creates any that are missing (`os migrate plan` lists them as `create_table`). That path was measured to create all twelve tables, on one database, with the telemetry sibling database, and with the flag set in the migrate run's own environment. + - **Which value is read.** The one `ObjectQLPlugin` resolved: the option, else the environment variable. An explicit `skipSchemaSync: false` therefore still provisions under `OS_SKIP_SCHEMA_SYNC=1`. `ObjectQLPlugin` publishes that value on the engine instance it registers. It is a property of the instance, not a declared member, so no type or export changes. + - **What is unchanged.** With the flag unset, provisioning is exactly as before. `syncObjectSchema` is not gated, so on a flag deployment a runtime publish still gives a new object its table. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [4663801] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index 31ddb803e23..15f7f43a20f 100644 --- a/packages/plugins/plugin-audit/package.json +++ b/packages/plugins/plugin-audit/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-audit", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Audit Plugin for ObjectStack — System audit log object and audit trail", "main": "dist/index.js", diff --git a/packages/plugins/plugin-auth/CHANGELOG.md b/packages/plugins/plugin-auth/CHANGELOG.md index 969f02f2e84..2a7d4b682d0 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,542 @@ # Changelog +## 18.0.0-next.0 + +### Minor Changes + +- 34dba5a: Under the `single` tenancy posture the Default Organization is created at boot, before the application seeds and before the server accepts a request, and the first admin of a fresh deployment is its owner + + Clause-②: yes (narrowing) + + + + **BREAKING** boot and accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3). + + - **A boot invariant.** Under the `single` posture (the posture in force, so a degraded walled request counts), the auth plugin's `start()` finds or creates the Default Organization (`slug: 'default'`), with or without a platform admin. Every application plugin starts after it. Before this change the organization was created on `kernel:ready` only once a platform admin existed, so on a fresh deployment it arrived with the first sign-up, after the seeds had loaded with no owner. + - **A failure stops the boot.** If the organization cannot be created, or the store cannot be read, `start()` throws and the deployment does not boot. Before, the bootstrap logged a warning and the deployment served anyway. An install holding several organizations and none with `slug: 'default'` gets no new organization; the boot logs it and continues. + - **`autoDefaultOrganization: false` no longer means "no organization".** It now turns off only the platform admin's owner bind. A host that set it to keep an organization-less `single` deployment gets the Default Organization anyway: under `single` no row is organization-less. + - **Every user belongs to it from the first boot.** Because the organization exists before anyone signs up, the membership reconciler binds every new user (under the `auto` membership policy) to it as `member` the moment they are created, so every session carries it as the active organization. Before, a user created before the first admin carried none. The one-time membership backfill likewise has its target at the first `kernel:ready`. + - **The first admin is the owner.** That includes the first admin, whom the reconciler binds as `member` before the owner bind learns they are the platform admin. While the one-time owner bind is undecided and the Default Organization has no owner, the bootstrap promotes that `member` row to `owner` in place and records the decision as `promoted`. A decided bind, an existing owner, or a membership elsewhere is never touched. + - **The in-memory driver answers `503` until C8.** `@objectstack/driver-memory` refuses tenant-scoped reads, and every session now carries the Default Organization, so under `single` its signed-in reads answer `503` until ADR-0131 C8 (#15212, D8) gives `single` no read predicate. The platform admin met the same refusal before this change, once the Default Organization existed. A SQL driver (`connection: { filename: ':memory:' }` for an in-process store) serves the deployment meanwhile. + + **The remedy.** If the boot stops on the Default Organization, make the `sys_organization` insert land: check the datasource's write permission and connectivity, and whether a legacy unique index on `slug` refuses `default`. A host relying on `autoDefaultOrganization: false` for an organization-less `single` deployment has no such deployment any more; run a walled posture if organizations are meant to be absent until created. +- aa71c4d: `sys_user_permission_set` gains `permission_set`, the name of the permission set a grant holds, written beside `permission_set_id` (ADR-0131 D4) + + Clause-②: yes (widening) + + - **The column.** `permission_set` is a read-only text column, at most 100 characters, holding the `name` of the `sys_permission_set` row that `permission_set_id` points at. It is readable everywhere the grant row is readable. A grant written before this release has `NULL` here until the backfill stage rewrites it. No reader uses the column yet: the grant is still resolved from `permission_set_id`, which stays until it is dropped in a later major (ADR-0131 D10). + - **The platform writes it, on every write that carries `permission_set_id`, for every caller.** Two `@objectstack/plugin-security` engine hooks (`beforeInsert` and `beforeUpdate` on `sys_user_permission_set`) look up the set by id and store its name. A write that sends only the id, which is how the data door and the Setup forms write, gets the name filled in. + - **A name that names a different set is refused** with `400 VALIDATION_FAILED`, `invalid_value` at `permission_set`. This covers a name that disagrees with the id written beside it, or with the id already stored when only the name is written. For a non-system caller it also covers a name beside an id that names no set this caller's organization can see. A name that agrees is accepted. A cleared name (`null`) is not stored as a clear: the derived name is written back. Before this change the column did not exist, so a write naming it was refused with `400 INVALID_FIELD`. No write that was accepted before is refused now. + - **Every platform grant writer writes both columns:** the organization-admin reconcile and the platform-admin promotion in `@objectstack/plugin-security`, the self-registration grant in `@objectstack/plugin-auth`, and the RLS probe persona in `@objectstack/verify`. + - **Nothing to migrate.** No principal's grants change. To fill the column on grants written by your own code, write the set's name as `permission_set`, or leave it out and the platform fills it in. Do not write any other value there. +- 2e10c9a: feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id. + + - **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing. + - **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`. + + **What stops conferring.** + + - **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report. + - **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing. + - **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again. + + **What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name. + + **What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect. + + **Nothing to migrate** in code or metadata. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate`), which applies `convertPositionBindingRows`. A name that organizations bound differently is reported `conflicting` and is never merged. +- e7e6544: feat(plugin-auth)!: the last-administrator guard, the self-registration admission and the default-organization step read the security catalog and the activation ledger, never a `sys_permission_set` row (ADR-0131 D3/D4) + + Clause-②: yes (narrowing: a zero-administrator write is refused when an unscoped grant names a set the security catalog does not hold, and self-registration is refused when its declared set is not in the catalog or is switched off in the activation ledger; widening: writes to a `sys_permission_set` row are no longer judged by the guard, and a ledger write switching `admin_full_access` off is refused only when it would leave no administrator) + + + + **BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. It finishes, for `plugin-auth`, the move the authorization resolver made in the same release: a permission set exists when the security catalog bound to the engine holds its definition, and it is switched off by a `sys_metadata_activation` row of type `permission` whose `active` is false. No `sys_permission_set` row decides either any more, its `active` column included. + + **The last-administrator guard.** + + - A grant-anchored platform administrator is counted the way the resolver derives one: an unscoped, in-window grant naming `admin_full_access`, which the catalog holds and the ledger leaves on. An engine with no catalog bound counts none, as the resolver grants none. + - Switching `admin_full_access` off in the ledger (an insert, an update of its row, or an update moving another row onto that pair) is judged like every other standing write. It is refused only when it would leave no administrator who can sign in. A config-anchored or organization administrator still standing permits it. The flat refusal of every such write is gone, and with it `refuseLedgerSwitchingAdminOff`. + - Writes to a `sys_permission_set` row (delete, rename, `active`, `organization_id`) are no longer judged. The resolver reads no row, so none of them moves an administrator. `PERMISSION_SET_STANDING_KEYS` is removed, and with it the guard's two `sys_permission_set` hooks. + - With zero administrators, the guard still tells the bootstrap window from an emptied environment. The evidence is now an unscoped, in-window grant naming a set the catalog does not hold (or naming none), or `admin_full_access` switched off in the ledger while such grants name it. Its remedy is to switch the set back on, and that write is permitted. + + Two exports of the guard are removed, with nothing in their place: `PERMISSION_SET_STANDING_KEYS` (the table it judged is no longer read) and `refuseLedgerSwitchingAdminOff` (`registerLastAdminGuard` judges ledger writes itself). No package in this repository imports either outside `plugin-auth`. + + **Self-registration.** The admission asks whether the declared `selfRegistrationPermissionSet` would grant: the catalog holds it and the ledger leaves it on. A `sys_permission_set` row of that name must also exist, because the grant's `permission_set_id` is written with its id. The row's `active` is no longer read, at admission or when the grant is written. + + **The default organization.** The platform administrator the default-organization step binds as owner is found through a set the catalog holds and the ledger leaves on, not through a `sys_permission_set` row. +- 8c5aa50: feat(plugin-email,plugin-auth)!: an organization can no longer create or edit an email template row; the template provenance stamp and the auth SMS template seed retire + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing and three retired exports), shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 and the maintainer's ruling C on ADR-0131 §6 Q1: email templates are not overridden per organization. + + **What stops being accepted.** + + - **The `sys_email_template` organization door is closed.** An organization's create and update of a template row are refused with `403 PERMISSION_DENIED`, and the message names the closed door: every engine `insert` / `update` whose context names a caller and is not system-elevated. That covers `POST` / `PATCH /api/v1/data/sys_email_template`, the Studio record editor, batch and import routes, and scripts and flows that run as a user or a service principal. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio `email_template` save into its row, and the v18 migration ceremony's promotion. Delete is not part of this door. + - **The template provenance stamp is retired.** It marked a package- or platform-seeded row `customized: true` when a non-system caller updated it. With the door closed no such update reaches the engine's write, so nothing marks a row any more. Rows already marked keep their mark and are still never overwritten by the boot seeders; they are the population the v18 migration ceremony promotes to environment-level Studio templates. + - **Retired exports of `@objectstack/plugin-email`:** `bindEmailTemplateProvenanceStamp`, `unbindEmailTemplateProvenanceStamp` and `EMAIL_TEMPLATE_PROVENANCE_PACKAGE`. They have no replacement. `EmailServicePlugin` closes the door itself, and there is no stamp left to bind, so a direct call is deleted, not rewritten. + - **The auth SMS template seed is retired.** A boot with phone sign-in on no longer writes the built-in OTP and invitation texts into `sys_notification_template` as rows. It was internal to `@objectstack/plugin-auth` and exported nothing. + + **What renders unchanged.** + + - Every email template renders as before: the template loader still reads `sys_email_template`, and a Studio edit of an `email_template` still reaches the mail at once and survives a restart. + - Every auth SMS text renders byte for byte as the seeded store rendered it, for every built-in text and every recipient locale. Each locale rung renders an operator's active row when one exists, and the built-in text where no row exists at that locale. A deactivated or blank row passes its rung on, as it did before. + - A `sys_notification_template` row an operator already has still wins, and no existing row is touched. + + **What changes for you.** To change what an email template sends, edit the `email_template` in Studio. A script or integration that wrote `sys_email_template` rows through the data API now receives `403 PERMISSION_DENIED`. `@objectstack/platform-objects` corrects the `is_system` and `customized` field help on `sys_email_template`, which said an organization may edit a row. +- c6fe02d: `createIdentityObjectsPlugin()` registers plugin-auth's identity objects (`sys_user`, `sys_member`, `sys_organization` and the rest of the list `AuthPlugin` registers) on a kernel that does not mount `AuthPlugin`, such as an app's or a plugin's own test kit. + + Clause-②: yes (widening) + + - **What is new.** `createIdentityObjectsPlugin(options?)`, the `IdentityObjectsPlugin` class it returns, `IdentityObjectsPluginOptions` (`manifestDatasource`, with the meaning `AuthPluginOptions.manifestDatasource` has) and `IDENTITY_OBJECTS_PLUGIN_NAME` (`com.objectstack.auth.identity-objects`, its kernel plugin name). + - **One list.** It registers the identity half of plugin-auth's manifest: the header, the objects and the field plugin-auth adds to `sys_sso_provider`. `AuthPlugin` spreads the same builder into the one manifest it registers, so a reduced kernel and a full one register the same objects, and a kit no longer copies plugin-auth's object list or manifest id. + - **Same owner.** The objects register under plugin-auth's package id, `com.objectstack.plugin-auth`, as `AuthPlugin` registers them. The registry records one owning package per object, so a different id would make the owner of `sys_user` depend on which plugin a kernel mounts. + - **No authentication.** It registers objects only: no sessions, no routes, no `auth` service. + - **Not beside `AuthPlugin`.** `AuthPlugin` registers the same objects itself. A kernel that mounts both is refused at boot, by the identity plugin's `init()`, with an error naming both. + - **Usage.** `await kernel.use(createIdentityObjectsPlugin())` after `ObjectQLPlugin`. A suite that boots through `@objectstack/verify` already gets these objects: its `bootStack` mounts `AuthPlugin`. + - **Unchanged.** `AuthPlugin` registers the same manifest it registered before, under the same id. +- f66c440: fix(auth): the plugin-auth doors that re-dispatch to better-auth or call its endpoints in-process no longer renew a browser session behind its cookie (#22398) + + **What was wrong.** A better-auth session read renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that read's own response. Eight doors read the session in-process by a route other than `auth.api.getSession`, so the rule the `getSession` readers follow did not reach them, and each kept only the JSON, or the status and body, of the response the cookie was staged on. Measured on better-auth 1.7.3 with a session aged to `now + expiresIn − updateAge − 60 s`, each moved `expires_at` by +86460 s on a cookie request and set no session cookie, before its own answer (a refusal included): + + - through a `/get-session` re-dispatch and the bridge's forward to a better-auth route: `POST /api/v1/auth/admin/sso/register`, `POST /api/v1/auth/admin/sso/register-saml`, `POST /api/v1/auth/admin/sso/request-domain-verification`, `POST /api/v1/auth/admin/sso/verify-domain` and `POST /api/v1/auth/send-verification-email`; + - through an in-process vendor endpoint call carrying the request's headers: `POST /api/v1/auth/organization/add-member` (`addMember`), `POST /api/v1/auth/set-initial-password` (`setPassword`) and `POST /api/v1/auth/sys-oauth-application/register` (`createOAuthClient`). + + **The rule now** is the one every in-process `getSession` reader follows, decided by what the request carries: + + - **A session cookie** (a browser): the re-dispatched URL carries `disableRefresh=true`, and a vendor endpoint call takes `inProcessSessionReadInput(headers)` from `@objectstack/types`, whose `query` better-auth's session middleware passes into its read. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie, so cookie and session expire together. Measured after the change: each door leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client): unchanged. Each door still renews the session to `now + expiresIn` and sets no cookie on a response to a request that sent none. + + **Upgrading.** Nothing to change. The shared helpers the cloud auth proxy mounts (`runRegisterSsoProviderFromForm`, `runRegisterSamlProviderFromForm`, `runRequestDomainVerification`, `runVerifyDomain`, `runResendVerificationEmail`, `runSetInitialPassword`) carry the same rule, so both mount points stay in step. `SetPasswordCapableApi.setPassword` now also accepts an optional `query: { disableRefresh: true }`; better-auth's own `auth.api.setPassword` honours it (measured on 1.7.3). +- e148ca9: With SSO domain verification on, `POST /api/v1/auth/admin/sso/request-domain-verification` and `POST /api/v1/auth/admin/sso/verify-domain` answer an unknown `providerId` with `404 RESOURCE_NOT_FOUND`, no longer with `400 DOMAIN_VERIFICATION_DISABLED` + + Clause-②: yes (widening) + + - **Before.** With `OS_SSO_DOMAIN_VERIFICATION` on, a platform admin who sent a `providerId` that does not exist got `400 DOMAIN_VERIFICATION_DISABLED`, "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION)": an instruction to turn on a feature that was already on. `@better-auth/sso` answers both an unmounted route (feature off) and an unknown provider (feature on) with a `404` that carries no `code`, and both bridges mapped every such `404` to the feature-off answer. + - **Now.** The two mounts hand each bridge the environment's own setting (`AuthManager.isSsoDomainVerificationEnabled()`). Off: `400 DOMAIN_VERIFICATION_DISABLED`, as before, and the vendor is no longer called. On: a code-less vendor `404` is the provider lookup missing, answered `404 RESOURCE_NOT_FOUND` with a message naming the provider id. The decision never reads the vendor's `404` wording. + - **Unchanged.** The platform-admin gate answers first, on or off (an anonymous caller gets `401`). A missing `providerId` is still `400 INVALID_REQUEST`. With the feature on, an existing provider still gets the vendor's own answer: the DNS record, `NO_PENDING_VERIFICATION`, `DOMAIN_VERIFICATION_FAILED`, a `403` for a provider the caller may not manage. + - **The exported bridges.** `runRequestDomainVerification` and `runVerifyDomain` take an optional third argument, `{ domainVerificationEnabled?: boolean }`. A caller that omits it keeps the earlier mapping: a code-less `404` is answered `400 DOMAIN_VERIFICATION_DISABLED`, byte for byte. A host that mounts these helpers itself passes the environment's setting to get the not-found answer. +- f9ebb7d: fix(plugin-auth)!: the identity import asks each lookup target its declared exposure before matching a cell, like every other `runImport` caller (#22800) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The identity import (`runAdminImportUsers`) parses its payload with `prepareImportRequest` and runs its rows through `runImport`, with a run protocol of its own that writes through better-auth. Its `sys_user` field map, which decides which cells are references, is read through the import's declaration reader (`IdentityImportDeps.getMetaItem`). + + **FROM.** The run protocol was built without that reader, so `runImport`'s `servesReferenceTarget` answered "served" without asking: a lookup cell was matched against its TARGET object even when the target's declaration refuses `list` (`enable.apiEnabled: false`, or an `apiMethods` whitelist without `list`). A cell naming an existing record resolved and a cell naming none answered `reference_not_found`, so the row report told the two apart. + + **TO.** The run protocol carries the same reader the parser is handed (one binding, so whenever a cell is a reference its target is judged). A target the spec's decision (`canServeApiOperation`, for `list`) does not serve answers every cell, naming a record or not, a pasted id included, `reference_not_found`, and nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. The decision takes no caller, so the system identity the import's reads run under is answered like any other caller. No new error code, no status change. + + **Measured producers.** `sys_user` declares two lookups: `manager_id` into `sys_user` (the import strips that column before `runImport`; its own second pass links managers) and `primary_business_unit_id` into `sys_business_unit`. Both targets serve `list`, and the registry injects no columns into `sys_user` (`managedBy: 'better-auth'`), so no shipped import answer changes. Deployed object extensions of `sys_user` were NOT MEASURED. + + **Fix, if an import relied on the old answer:** the target's own declaration decides. Grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import. + +### Patch Changes + +- 19bb55e: The permission-set grant readers in `@objectstack/plugin-auth` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no + + - **What reads the name now.** The last-administrator guard (`registerLastAdminGuard`) counts a grant-anchored platform administrator from an unscoped, in-window grant whose `permission_set` is `admin_full_access`, provided the `admin_full_access` row of that name is active. The default-organization bootstrap (`ensureDefaultOrganization`) finds the platform administrator by the same grant name. The self-registration grant checks by name whether the new user already holds the declared set. None of them reads `permission_set_id` for this any more. + - **The guard treats `permission_set` as a standing column.** A write that clears the name on the last administrator's grant is refused like any other revocation. A write that re-points `permission_set_id` without a name is treated as taking the standing away, because the platform derives the new name only after the guard runs. A write that only repeats the grant's own id keeps the standing. + - **A grant whose name is empty.** A grant written before the name column existed has no name until `@objectstack/plugin-security`'s one-time backfill names it at `kernel:bootstrapped`. The guard does not count such a grant as an administrator. When no administrator is counted, such a grant, if unscoped and in-window, is evidence that the environment is not a fresh install, so the write is refused instead of the bootstrap window opening. The default-organization bootstrap answers `no_admin` for it and binds no owner; that answer records no decision, so a later trigger binds once the grant has its name. + - **Nothing to migrate.** +- 0253192: Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4) + + - `@objectstack/plugin-auth`: the self-registration grant names the declared set alone. Admission asks the security catalog and the activation ledger whether the set grants, and the settlement writes `{ user_id, permission_set }` with no row read, so a deployment with no `sys_permission_set` row for the set still grants it. The last-administrator guard reads which set a grant holds from its name only; writing `permission_set_id` moves no standing. + - `@objectstack/verify`: the RLS probe persona's grant names the registered probe set alone; it no longer needs, or refuses for want of, a boot-seeded `sys_permission_set` row. + - `@objectstack/platform-objects`: Setup's user page grants a permission set by name: the Add picker stores the set's machine name (`valueField: 'name'`, `linkField: 'permission_set'`), and the list shows the name column. The picker still lists `sys_permission_set` rows; listing the security catalog itself is a console change. +- 4935c66: The startup banner prints one line per warning class, each warning appears once, and a localhost boot no longer warns that OAuth is unencrypted. + + Clause-②: no + + - **One line per class.** Flows that declare a trigger but are not bound are grouped by trigger type and reason, with the flows listed: `⚠ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. Before, the full reason (up to ~650 characters) printed once per flow. The banner now shows the reason's first sentence; `--log-level debug` still streams each flow's full reason. A real binding failure, or a missing trigger, keeps its own line, worded as before. + - **Printed once.** *Boot diagnostics* no longer repeats the automation plugin's per-flow `… is NOT bound` and shadowed-flow warnings, which the banner's `Flows:` list already shows. Its header counts them instead: `(8 more already listed above)`. Every other boot warning replays exactly as before. A boot that fails before the banner still replays all of them. + - **OAuth on loopback.** `OAuth is served UNENCRYPTED: …` is logged at `info` when the issuer's host is loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), so it is not shown at the default `warn` level. It stays `warn` on a private or link-local issuer. The sentence and the transport rule are unchanged. + - ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the transport rule, the service-automation warning an embedded host reads, and every public key, export and parameter are unchanged. +- 6ff6ed6: A first boot no longer logs a refused `sys_migration` insert, and the owner-bind ledger row records the outcome that happened + + Clause-②: no + + On every first boot (`os serve` and `os dev`, on `:memory:`, file SQLite and PostgreSQL), *Boot diagnostics* showed `WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id}` with a full stack. The one-time default-organization owner bind (`createEnsureDefaultOrganizationOnce`, ADR-0093 D7) re-entered itself: the owner write makes the security plugin grant `organization_admin`, that grant insert is a bootstrap trigger, and the inner call recorded the decision first. It recorded `admin-already-member` with no organization. The outer call's accurate record (`promoted` or `bound`, with the organization id) was then refused. Nothing was bound twice. The ledger row named the wrong outcome. + + The gate now marks the decision in flight before its first `await`. A call that finds it in flight still creates a missing Default Organization, but binds nobody and records nothing. The deciding call records its own outcome once. A call that did not act (no platform admin yet, or a refused write) clears the mark, so the next trigger still decides. + + - **Nothing you author changes.** No key, export or option is added or removed. The walled `@objectstack/organizations` wiring calls the same gate and gets the same rule. + - **A real ledger write failure is still reported.** Only the gate's own second write is gone; a refused `sys_migration` insert for any other reason still logs at `error`, with the remedy. + - **Rows already written are not rewritten.** A deployment whose first boot recorded `admin-already-member` keeps that row; nothing reads its `details`. +- 0ee2d15: A seeded boot no longer reads the `auth` settings before the settings engine binds + + Clause-②: no + + Under `os serve` and `os dev`, an app with inline seed data emits `app:seeded` while plugins are still starting. The auth plugin's one-time membership backfill (ADR-0093 D6) ran on that event. Through it, the plugin bound the `auth` settings namespace before `SettingsServicePlugin` had bound its data engine. The boot logged `[SettingsService] Pre-bind READ of namespace 'auth'`, and the binding was computed from the manifest defaults, not from the saved `auth` settings. Measured on the showcase: one such line on every boot. + + The backfill is now armed by its own `kernel:ready` hook, which runs after the settings plugin binds its engine. A trigger of the pass before that hook does nothing: the `kernel:ready` pass runs afterwards and scans the same rows. A seed that settles after `kernel:ready` still re-runs the pass, as before. + + - **Which settings are read is unchanged.** Only the moment of the first read moves. + - **A saved `auth.membership_policy` governs the one-time pass.** Before, the pass's first run could read the manifest default `auto` while a saved `invite-only` sat unread. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- 8a995b8: The auth plugin registers its `app:seeded` membership-backfill handler when its backfill is armed, not when the plugin starts + + Clause-②: no + + The one-time membership backfill (ADR-0093 D6) re-runs on `app:seeded`, so users written by a seed that settles after `kernel:ready` still get a membership. Its handler used to be registered in `start()` and kept from acting early by a runtime flag: until the backfill's own `kernel:ready` hook armed it, the handler returned without doing anything. The handler is now registered by that `kernel:ready` hook, at the moment it arms the backfill, so it does not exist before the settings engine binds. The boot-ordering gate reads this from the source, which it cannot do with a flag. + + - **What the backfill does is unchanged.** An `app:seeded` that fires before the backfill is armed still does nothing, and one that fires after it still re-runs the pass. + - **No option, setting or export changes.** `OS_SKIP_MEMBERSHIP_BACKFILL=1` still registers no `app:seeded` handler at all. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- c7a3234: perf(core,plugin-auth): an authenticated request resolves its caller's grants once, not twice + + Clause-②: no + + `resolveAuthzContext` learns who the caller is from the transport's `getSession`. Against `@objectstack/plugin-auth` that is better-auth's `getSession`, whose `customSession` hook resolves the principal's grants for the session payload's `positions[]` and `isPlatformAdmin`; `resolveAuthzContext` then resolved the same grants again, with the same arguments, for the request's envelope. Every authenticated request on a door that resolves identity through `resolveAuthzContext` with a better-auth session read (the runtime dispatcher and the REST server among them) paid every grant read twice: eight reads per resolution for a caller in an organization. + + `resolveAuthzContext` now runs inside a request-scoped grants memo. A resolution that already completed inside the same call, with the same user, organization and seeds, is served to the next caller that asks for exactly that resolution, so the hook's resolution serves the resolver's own. The decision a request is authorised with is unchanged: + + - The memo lives for one `resolveAuthzContext` call (an `AsyncLocalStorage` scope, closed when the call settles). Nothing is cached across requests; the cross-request grants cache keeps its own default-off switch, `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. + - An entry is served only when a fresh read would agree with it. No write may have started, been executed at the driver, or still be in flight on the engine since the first resolution opened: the engine's write epoch (bumped when a write starts) and a write observer the memo registers as an engine middleware (counting writes into the chain and, after the driver step, out of it) must both read what they read at the open, with nothing inside the chain. The observer sees statement execution, not commit visibility. A write inside an `engine.transaction()` becomes visible only at its COMMIT, outside every chain. On driver-sql (not on the Turso remote face, which opens no transaction), a request whose step 2 falls inside that one commit round trip is authorised as of its first resolution, and the next request reads fresh — the same answer as a write from another process. No grant validity boundary may lie between the two clocks. The organization must be the same (the session arm's dropped-claim re-resolution is its own entry). A `bypassGrantsCache` caller is never served, a failed resolution is never stored, and an engine without the write-epoch seam or `registerMiddleware` declines entirely. + - `plugin-auth`'s hook now passes the session's email as its seed email, exactly as `resolveAuthzContext` does for the same session, so the two calls ask for the same resolution. The seed reaches only the envelope's `email`, which the hook does not read: the payload's `positions[]` and `isPlatformAdmin` are unchanged, and platform-admin standing still compares the stored `sys_user.email`, never a seed. + + Where the saving does not apply, the request reads the grants twice, as before — always the safe direction: + + - a request that meets a concurrent write on its engine; + - a request whose session read itself writes: the first request on a fresh auth instance generates its signing key, and with an idle timeout configured `enforceSessionControls` stamps `sys_session.last_activity_at` about once a minute per session; + - the first `resolveAuthzContext` call on an engine, which registers the write observer and stores nothing for it. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [0db5ad5] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [166a94f] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [3fdb92d] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [0511341] +- Updated dependencies [e5f9f01] +- Updated dependencies [5d63c37] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/rest@18.0.0-next.0 + - @objectstack/service-messaging@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index b207681c0e7..26a11088caa 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-auth", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Authentication & Identity Plugin for ObjectStack", "main": "dist/index.js", diff --git a/packages/plugins/plugin-dev/CHANGELOG.md b/packages/plugins/plugin-dev/CHANGELOG.md index 2c6f7782eb1..af92cdaf3a3 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,345 @@ # @objectstack/plugin-dev +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [c28f317] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [cd3d391] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [29678f2] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [3aa0c7b] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4049cac] +- Updated dependencies [7ebbc01] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [3513ac7] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [f6e1d49] +- Updated dependencies [f2626c7] +- Updated dependencies [59d993c] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [e36ee53] +- Updated dependencies [1884948] +- Updated dependencies [8a995b8] +- Updated dependencies [54c3ce1] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [166a94f] +- Updated dependencies [dd986d8] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [f782f17] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [3fdb92d] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [0aef788] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [0511341] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [3d6392f] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/runtime@18.0.0-next.0 + - @objectstack/plugin-security@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/plugin-auth@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/rest@18.0.0-next.0 + - @objectstack/service-realtime@18.0.0-next.0 + - @objectstack/service-storage@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/plugin-hono-server@18.0.0-next.0 + - @objectstack/account@18.0.0-next.0 + - @objectstack/setup@18.0.0-next.0 + - @objectstack/driver-memory@18.0.0-next.0 + - @objectstack/service-i18n@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 4c4b503b6da..bfaaf5760be 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-dev", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Development Assembly Plugin for ObjectStack — wires the real platform stack for zero-config local development", "main": "dist/index.js", diff --git a/packages/plugins/plugin-email/CHANGELOG.md b/packages/plugins/plugin-email/CHANGELOG.md index 0294a2ac16e..77f98790eab 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,391 @@ # @objectstack/plugin-email +## 18.0.0-next.0 + +### Minor Changes + +- 8c5aa50: feat(plugin-email,plugin-auth)!: an organization can no longer create or edit an email template row; the template provenance stamp and the auth SMS template seed retire + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing and three retired exports), shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 and the maintainer's ruling C on ADR-0131 §6 Q1: email templates are not overridden per organization. + + **What stops being accepted.** + + - **The `sys_email_template` organization door is closed.** An organization's create and update of a template row are refused with `403 PERMISSION_DENIED`, and the message names the closed door: every engine `insert` / `update` whose context names a caller and is not system-elevated. That covers `POST` / `PATCH /api/v1/data/sys_email_template`, the Studio record editor, batch and import routes, and scripts and flows that run as a user or a service principal. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio `email_template` save into its row, and the v18 migration ceremony's promotion. Delete is not part of this door. + - **The template provenance stamp is retired.** It marked a package- or platform-seeded row `customized: true` when a non-system caller updated it. With the door closed no such update reaches the engine's write, so nothing marks a row any more. Rows already marked keep their mark and are still never overwritten by the boot seeders; they are the population the v18 migration ceremony promotes to environment-level Studio templates. + - **Retired exports of `@objectstack/plugin-email`:** `bindEmailTemplateProvenanceStamp`, `unbindEmailTemplateProvenanceStamp` and `EMAIL_TEMPLATE_PROVENANCE_PACKAGE`. They have no replacement. `EmailServicePlugin` closes the door itself, and there is no stamp left to bind, so a direct call is deleted, not rewritten. + - **The auth SMS template seed is retired.** A boot with phone sign-in on no longer writes the built-in OTP and invitation texts into `sys_notification_template` as rows. It was internal to `@objectstack/plugin-auth` and exported nothing. + + **What renders unchanged.** + + - Every email template renders as before: the template loader still reads `sys_email_template`, and a Studio edit of an `email_template` still reaches the mail at once and survives a restart. + - Every auth SMS text renders byte for byte as the seeded store rendered it, for every built-in text and every recipient locale. Each locale rung renders an operator's active row when one exists, and the built-in text where no row exists at that locale. A deactivated or blank row passes its rung on, as it did before. + - A `sys_notification_template` row an operator already has still wins, and no existing row is touched. + + **What changes for you.** To change what an email template sends, edit the `email_template` in Studio. A script or integration that wrote `sys_email_template` rows through the data API now receives `403 PERMISSION_DENIED`. `@objectstack/platform-objects` corrects the `is_system` and `customized` field help on `sys_email_template`, which said an organization may edit a row. +- 7de0265: feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire + + Clause-②: yes (narrowing) + + + + **BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1. + + **What changes.** + + - **Templates resolve from the registry.** `sendTemplate` and `renderTemplate` read `email_template` metadata by `(name, locale)` at every send: the protocol's layered list (`GET /meta/email_template`'s items — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is unchanged: the named locale exactly, then `en-US`, then — only for a call that named none — the bundle's lowest locale tag. + - **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered at plugin init, so they now appear in Studio's email-template list. An app that declares the same `(name, locale)` wins over the built-in; the built-in still serves the locales the app leaves out; a Studio save overrides either at environment level. Every built-in renders byte-identical to the row it used to be seeded into, in every locale (pinned on the rendered subject, HTML and text). + - **A Studio save is live on the next send**, with no projection and no restart. The `PUT /meta/email_template/:name` response no longer carries `projectionApplied`, because nothing is projected. + - **No `sys_email_template` row is written at boot or read at send.** The table and its rows stay; the organization door stays closed. + - **A malformed template fails its send loudly** with the schema's own message, instead of falling through to the built-in copy of the same slot. + + **What moves for consumers.** + + - FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`. + - FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call. + - FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`. + - FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing. + - FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option. + - FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option. + + **What a deployment observes.** A `sys_email_template` row an organization customized before its door closed is no longer what sends: the template's code declaration (or an environment-level Studio save) is. The v18 migration ceremony promotes those rows to environment-level templates; to make such an edit live before it runs, re-save the template in Studio. + + **`@objectstack/spec`** (patch): the `email_template` liveness ledger is re-anchored to the registry loader, and `category` and `isSystem` are graded `dead` — their only consumer was the retired row projection. The `notify` node's `template` description and refusal messages, and the `IEmailService` contract's documentation, name the email templates rather than `sys_email_template` rows. No schema shape changes. + + **`@objectstack/service-automation`** (patch): the notify node's `template` help text served at `GET /api/v1/automation/actions` names the email templates rather than `sys_email_template` rows. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. + +### Patch Changes + +- 56bf27a: The declared-email-template boot sweep reads the stored rows in bulk and no longer rewrites a template that has not changed + + Clause-②: no + + Every boot used to look each effective email template up on its own and rewrite its + `sys_email_template` row unconditionally: one lookup, one UPDATE and the engine's two read-backs + per template, whether or not anything had changed. Measured on `ObjectQL` over `SqlDriver` + (better-sqlite3), a steady boot over 450 unchanged templates sent 1,800 statements; it now sends 3 + reads and no write (79 templates: 316 statements, now 1). + + - The sweep reads the stored rows for the declared names in `$in` pages of 200 names. Each + `(name, locale)` takes the first row the read returns, in the order the driver gives the + per-template lookup, so a slot several organizations hold resolves to the same row as before. + - A template the bulk read did not answer (new since the last boot, a read cut short at its row + bound, or a failed read) is looked up on its own before anything is inserted, as before. + - A `managed_by: 'package'` row that already holds every column the template projects is not + rewritten. `upsertDeclaredEmailTemplate` now returns `false` for it, and + `bootstrapDeclaredEmailTemplates` counts it under `skipped`, the value both already document for + a row deliberately not written. A row with any other provenance is handled exactly as before: + admin-owned and customized rows are never written, and a legacy row with no `managed_by` is + rewritten and adopted. + + An org-scoped template edit still survives the next boot. Signatures and accepted input are unchanged. +- ec7f7f8: feat(spec)!: retire an email template's `category` and `isSystem` — the grouping and the built-in flag nothing read (#22876) + + **BREAKING** — `category` and `isSystem` on an email template (`EmailTemplateDefinitionSchema`, the `email_template` metadata type) are now refused at parse. Nothing read either key once templates stopped being copied into `sys_email_template` rows: the Studio listing never offered the filter facet `category` promised, and no door refused deleting a template that set `isSystem`. Delete both keys. A template is found by its `name`, so group templates with a dotted namespace prefix (`auth.password_reset`, `crm.welcome`). A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. + + Clause-②: no (narrowing) + + Measured before removal: no reader of either key in `@objectstack/plugin-email`, `@objectstack/service-messaging`, `@objectstack/plugin-auth`, the metadata protocol, ObjectQL, the runtime or the REST layer, and none in the Studio console at its pinned commit beyond a fallback form input, which the console's own companion change removes. Both keys carried a schema default (`category: 'custom'`, `isSystem: false`), so every parsed template carried them and the liveness advisory fired on every template, with no way for an author to clear it. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `category: 'auth'` (or `'notification'`, `'workflow'`, `'marketing'`, `'custom'`) | nothing — delete the key; group by the `name` prefix | + | `isSystem: true` | nothing — delete the key | + | `isSystem: false` | nothing — delete the key | + | `import { EmailTemplateDefinitionCategorySchema } from '@objectstack/spec/system'` | nothing — the enum is removed with its key | + | `type EmailTemplateDefinitionCategory` | nothing — the type is removed with its key | + + **The one-line fix:** delete `category` and `isSystem` from every email template. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: + + > `email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). 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. + + > `email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. 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. + + ## The retirement kit + + - **Two `retiredKey()` tombstones** on `EmailTemplateDefinitionSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `system/EmailTemplateDefinition:category`, `system/EmailTemplateDefinition:isSystem`. The `category` enum, `EmailTemplateDefinitionCategorySchema`, had no other consumer and leaves with it: `RETIRED_DEFS_BY_MAJOR[18]`: `system/EmailTemplateDefinitionCategory`. + - **The D2 conversion `email-template-category-is-system-removed`** (protocol 18, retired from the load path) deletes both keys from every `emailTemplates[]` entry, one notice per key, as a lossless delete. Because both keys carried a default, every built artifact and every stored `email_template` row written under 17.x carries them; both load through the rehydration seams, which replay this conversion. The retired-default residue stage is not adopted: the replayed conversion heals those populations, and an authored source that still writes either key meets the prescription. + - **The D3 entry `email-template-category-is-system-retired`** asks the author whether anything they built read either key — a report or integration that filtered templates by `category`, or one that treated `isSystem: true` as protection against deletion. Nothing ever enforced that. + - **Studio:** the email-template form drops the `category` select and the `isSystem` checkbox. + - **Ledger:** both rows stay `dead`, re-noted as tombstones (a tombstone keeps the key in the walked shape). + - **No deprecation window**, per the project's startup-stage posture. + + **`@objectstack/plugin-email`:** the six built-in auth templates (in every locale) no longer write `category: 'auth'` or `isSystem: true`. They render and send exactly as before. + + **`@objectstack/platform-objects`:** the metadata-form translation bundles drop the two retired form inputs' labels (`email_template.fields.category`, `email_template.fields.isSystem`). The `sys_email_template` object and its `category` / `is_system` columns are unchanged. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 4e82ac33d6a..1bd6a1d1220 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-email", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Email service plugin for ObjectStack — IEmailService + transport-pluggable outbound delivery with sys_email persistence.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-hono-server/CHANGELOG.md b/packages/plugins/plugin-hono-server/CHANGELOG.md index 7f8518a259b..89912407e9c 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,246 @@ # @objectstack/plugin-hono-server +## 18.0.0-next.0 + +### Minor Changes + +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. + +### Patch Changes + +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- 0aef788: `GET /api/v1/auth/me/permissions` answers "no capabilities", not "capabilities unknown", when it fails after the caller is known + + Clause-②: no + + Once the handler knows who the caller is, it has two ways to fail. When the permission-set resolution rejects, it already answered a closed body: `systemPermissions: []`, so every capability gate closes. Any other throw inside the handler (the effective-object merge, or a resolver that throws synchronously) was caught by an outer `catch` that answered `{ authenticated: true, userId, objects: {}, fields: {} }`. That body has no `systemPermissions`, which is the shape a deployment predating ADR-0066 sends, and the console reads it as "capabilities unknown" and fails open on it. So a server-side fault offered authoring handles the server then refused. + + The outer `catch` now answers the closed body, built by the same function the success path uses: + + - `authenticated: true`, the caller's `userId`, and `tenantId` (the session's, or `null`), which this body did not carry before; + - `positions`, `permissionSets` and `systemPermissions` as `[]`, and `objects`, `fields` and `tabPermissions` as `{}`. + + The log line is unchanged: `[hono] /auth/me/permissions failed` at `warn`, once. + + **What does not change.** The success body is byte-identical. The resolver-rejection path still reports the session's positions. A stack with no SecurityPlugin or no metadata service still answers its deliberate open body without `systemPermissions`, since there the server enforces nothing either. A caller with no session still gets `{ authenticated: false }`. No accept set, export or `spec` key moves, and the console needs no change. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/observability@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 0a83383a1cd..f5d79f4199d 100644 --- a/packages/plugins/plugin-hono-server/package.json +++ b/packages/plugins/plugin-hono-server/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-hono-server", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Standard Hono Server Adapter for ObjectStack Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-pinyin-search/CHANGELOG.md b/packages/plugins/plugin-pinyin-search/CHANGELOG.md index c1c9143bc57..b4b88aebd9c 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,84 @@ # @objectstack/plugin-pinyin-search +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [4806b18] +- Updated dependencies [c8b062f] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [f85a83b] +- Updated dependencies [5cfd866] +- Updated dependencies [c8d06a9] +- Updated dependencies [8f2e808] +- Updated dependencies [fe98cc6] +- Updated dependencies [4578c56] +- Updated dependencies [0b997ea] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [e87070e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [3089848] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [440bed6] +- Updated dependencies [35ef501] +- Updated dependencies [da989bb] +- Updated dependencies [018b077] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [87cd458] +- Updated dependencies [1bb1da1] +- Updated dependencies [615cba8] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [51f36e7] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [8bc7836] +- Updated dependencies [1e62f71] +- Updated dependencies [038d4c9] +- Updated dependencies [8d8f965] +- Updated dependencies [21914c3] +- Updated dependencies [e1deaef] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [8464380] +- Updated dependencies [abf8655] +- Updated dependencies [c7a3234] + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index bea88075d0c..a5c5c2e644e 100644 --- a/packages/plugins/plugin-pinyin-search/package.json +++ b/packages/plugins/plugin-pinyin-search/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-pinyin-search", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Pinyin search recall for ObjectStack — populates the hidden `__search` companion column (full pinyin + initials of the display/name field) so `$search` hits CJK names typed as pinyin. Locale-gated via OS_SEARCH_PINYIN_ENABLED (#2486).", "main": "dist/index.js", diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index 68bb70cbe6a..f27bacd5414 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,1084 @@ # @objectstack/plugin-security +## 18.0.0-next.0 + +### Major Changes + +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- 8311650: `sys_user_permission_set` refuses an assignment whose validity window ends at or before it starts (ADR-0091 D1/D2) + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on the data door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + - **What is refused.** A write that leaves an assignment with both `valid_from` and `valid_until` set and `valid_until` at or before `valid_from` is refused with `400 VALIDATION_FAILED`, a `rule_violation` at `valid_until`. The window is half-open, `[valid_from, valid_until)`, so such an assignment can never grant anything: the resolver drops it at every evaluation. Until this release it was stored without a word, so an administrator who mistyped a date got an assignment that silently granted nothing. + - **Which writes are judged.** Every insert, from every writer, system writers included, single rows and batches alike. An update is judged by the stored row overlaid with the patch, and only when it moves a bound: by id, and for each matched row of a multi-row update. The refusal is a declared validation rule on the object, `validity_window_order`, so the engine runs it on every one of those write paths. + - **What still lands, unchanged.** A window that ends after it starts, one bound alone (either one), and no window at all. An update that moves neither bound is not judged, so an assignment stored before this release with an inverted window does not block an unrelated edit; the write that repairs it is the only one that has to state a valid window. + - **To fix a refused write**, set `valid_until` later than `valid_from`, or leave one of the two empty for an open-ended window. + +### Minor Changes + +- 959c209: feat(plugin-security)!: the six built-in positions are declared position metadata of the plugin (ADR-0131 D2) + + Clause-②: yes (narrowing) + + + + **BREAKING** on one write door, shipped as `minor` under the repo's launch-window convention for breaking changes. + + - **What is declared.** The identity positions `platform_admin`, `org_owner`, `org_admin` and `org_member` (ADR-0068 D2) and the audience anchors `everyone` and `guest` (ADR-0090 D5/D9) are registered with the engine registry as `position` metadata owned by `com.objectstack.plugin-security`, the way the plugin's own permission sets are. Their names, labels and descriptions come from one list built from `@objectstack/spec`'s `BUILTIN_IDENTITY_NAMES` and `BUILTIN_IDENTITY_METADATA` and the anchor text, and the built-in seeder reads that same list. + - **Rows and grants do not move.** `sys_position` is seeded exactly as before: the same names, the same organizations (one copy per organization under a walled posture, one organization-less pass under `single`), `managed_by: 'platform'`, `active: true`, `is_default: false`, and the same labels and descriptions. No principal's grants change. + - **What a client can now read.** `GET /api/v1/meta/position` lists the six beside the stack's own positions, and `GET /api/v1/meta/position/:name` answers `200` with each one's definition, where it answered `404 RESOURCE_NOT_FOUND`. The security catalog read (`createSecurityCatalogReader` in `@objectstack/core`) lists them from the engine registry. + - **What a client can no longer write.** `PUT /api/v1/meta/position/:name` naming one of the six is refused `403 NOT_OVERRIDABLE`: the name is provided by a code package, and `position` has no overlay. It was saved as an environment-wide definition before. Give an authored position a different name. `DELETE /api/v1/meta/position/:name` on one of the six still answers `200` and removes nothing. + - **The declared-positions seeder** keeps reading the engine registry first and the metadata service only when the registry holds no position. The six are left out of that decision and out of what it seeds, so stack-declared positions keep seeding and the six keep the built-in seeder as their one writer. +- aa71c4d: `sys_user_permission_set` gains `permission_set`, the name of the permission set a grant holds, written beside `permission_set_id` (ADR-0131 D4) + + Clause-②: yes (widening) + + - **The column.** `permission_set` is a read-only text column, at most 100 characters, holding the `name` of the `sys_permission_set` row that `permission_set_id` points at. It is readable everywhere the grant row is readable. A grant written before this release has `NULL` here until the backfill stage rewrites it. No reader uses the column yet: the grant is still resolved from `permission_set_id`, which stays until it is dropped in a later major (ADR-0131 D10). + - **The platform writes it, on every write that carries `permission_set_id`, for every caller.** Two `@objectstack/plugin-security` engine hooks (`beforeInsert` and `beforeUpdate` on `sys_user_permission_set`) look up the set by id and store its name. A write that sends only the id, which is how the data door and the Setup forms write, gets the name filled in. + - **A name that names a different set is refused** with `400 VALIDATION_FAILED`, `invalid_value` at `permission_set`. This covers a name that disagrees with the id written beside it, or with the id already stored when only the name is written. For a non-system caller it also covers a name beside an id that names no set this caller's organization can see. A name that agrees is accepted. A cleared name (`null`) is not stored as a clear: the derived name is written back. Before this change the column did not exist, so a write naming it was refused with `400 INVALID_FIELD`. No write that was accepted before is refused now. + - **Every platform grant writer writes both columns:** the organization-admin reconcile and the platform-admin promotion in `@objectstack/plugin-security`, the self-registration grant in `@objectstack/plugin-auth`, and the RLS probe persona in `@objectstack/verify`. + - **Nothing to migrate.** No principal's grants change. To fill the column on grants written by your own code, write the set's name as `permission_set`, or leave it out and the platform fills it in. Do not write any other value there. +- ba8af87: feat(plugin-security)!: under `single`, a position created or edited in Setup is also written to the environment ledger, and positions Setup wrote earlier are backfilled into it once (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0131 D3 gives positions one home, the environment registry. Under `single`, a position an administrator creates in Setup used to be a `sys_position` row only: no environment definition, so the security catalog read did not find it. Under `single`, every Setup create, edit, rename and delete of a position now also writes the position's definition through the metadata door, at environment scope. + + **What a Setup position write does now, under `single`.** + + - **Create.** The row is written as before, with the same checks: a required label, the reserved built-in names, and one name per organization. The definition `{ name, label, description, delegatable }` is then saved from that row as an environment item, and the security catalog read resolves it at once. `active` and `is_default` stay on the row only. + - **Edit.** The row is updated, and the definition is saved again from it. A patch that touches only `active` or `is_default` writes the row and nothing else. + - **Rename.** The new name's definition is saved, and the old name's definition is deleted. + - **Delete.** The row is deleted, then the definition. If the definition delete fails, an error is logged that names the remedy, because the next boot would bring the position back from the surviving definition. + - **Unchanged.** Under a walled posture, every write behaves as before. System writes (the seeders and the package door) are never translated. On a kernel without a metadata door, the row is written as before. For a position a package or a built-in declares, a Setup edit is written to the row exactly as before, and nothing is written to metadata. + - **No grant changes.** Every reader still reads the row, so a user holding a position is granted exactly what they were granted before. + + **What stops being accepted.** Under `single`, the data door now refuses a create, or a rename into, a position name that the metadata door refuses. The refusal is the metadata door's own: `400 INVALID_REQUEST` for a name outside the item-name grammar (uppercase, a space, a hyphen, a leading digit or underscore), or `422 INVALID_METADATA` for a name `PositionSchema.name` refuses (a single character, a dot). No row is kept. `PositionSchema` already declared such names rejected, and a position named that way could never have a definition. + + - An edit of an existing row that already carries such a name is still accepted. It stays a row write, with no definition. + - **Remedy.** Name the position in lowercase `snake_case`: it starts with a letter, is at least two characters, and holds letters, digits and underscores only. For example, write `sales_manager` instead of `Sales Manager`. Then re-point any assignment that names the old spelling. + + **One more refusal follows from the new definitions.** Positions, permission sets and capabilities hold one name per deployment. Once a Setup position is defined in the environment ledger, a package that registers a position under the same name is refused with `422 NAMESPACE_CONFLICT`, and the refusal names both holders. Before this change, the same registration was accepted. + + **The one-time backfill.** At `kernel:bootstrapped`, under `single`, every position whose name the security catalog read does not resolve gets an environment definition from its row. This covers positions Setup wrote before this release. + + - A name the environment ledger, a package or a built-in already declares is left alone. + - A name the metadata door refuses is not written. It is reported at `warn`, with its remedy, as a final class. + - If two rows of one name disagree, the name is reported at `error` and nothing is written for it. + - When every name is decided, the verdict is recorded in `sys_migration` (`adr-0131-position-environment-backfill`). If a write fails or two rows disagree, the verdict is not recorded, and the next boot runs the pass again. +- 99801d8: feat(plugin-security)!: under `single`, a Setup create of a position, or a rename into a name, that a package or a built-in already holds is refused with the metadata door's own refusal (ADR-0048 addendum N.2/N.3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Positions hold one name per deployment (ADR-0048 addendum, N.2). A position a package declares, and the platform's built-in positions, are managed items: the metadata door already refused an environment save under their names (`PUT /api/v1/meta/position/:name` answers `403 NOT_OVERRIDABLE`). The Setup data door did not. + + **FROM.** Under `single`, a non-system `POST /api/v1/data/sys_position` whose `name` a package or a built-in holds answered `201`. The new row sat beside the package's (or the platform's) position under the same name. A `PATCH` that renamed an administrator's position into such a name answered `200`, and it also deleted that position's own environment definition. Measured on the showcase: a create of `manager` (package-declared), `everyone` or `guest` (built-in audience anchors) answered `201`, and a rename into `exec` answered `200`. + + **TO.** Under `single`, the same create, or a rename into such a name, is refused with the metadata door's own refusal, relayed as the door builds it: `403 NOT_OVERRIDABLE`. No row is kept, a renamed row keeps its old name, and its definition is untouched. The door's own verdict is asked; nothing is written to environment metadata for such a name. + + - **Unchanged.** The engine's own refusals answer first, as before: a reserved built-in identity name (`platform_admin`, `org_owner`, `org_admin`, `org_member`) stays `400 VALIDATION_FAILED`, and a name already held in the same organization stays `409 UNIQUE_VIOLATION`. An edit that keeps such a name, and a delete, behave as before. System writes, walled postures and kernels without a metadata door behave as before. Under a walled posture, each organization already holds its own rows of the declared and built-in names, so the same create answers `409 UNIQUE_VIOLATION`, as before. + - **Remedy.** Rename the position: give it a name no package and no built-in holds (for example `regional_manager` instead of `manager`), then point any assignment at the new name. `GET /api/v1/meta/position` lists the names already held. +- 2e10c9a: feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id. + + - **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing. + - **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`. + + **What stops conferring.** + + - **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report. + - **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing. + - **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again. + + **What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name. + + **What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect. + + **Nothing to migrate** in code or metadata. +- 0253192: A permission-set grant names its set by name: `sys_user_permission_set.permission_set` is the grant's one reference, and `permission_set_id` is no longer written or required + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing on `sys_user_permission_set`, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D10's first step: every grant names its set, then the id column goes. + + - **Before.** A grant was written with `permission_set_id`, a required lookup to a `sys_permission_set` row, and the platform derived `permission_set` (read-only) from that row. A write carrying only the id was accepted and named; a supplied name had to agree with the id's row. + - **Now.** `permission_set` is required and writable: it holds the permission set's machine name, and the set is the security catalog's definition of that name (ADR-0131 D3). `permission_set_id` is optional, and nothing writes it or derives anything from it. A grant that names no set is refused `400 VALIDATION_FAILED` (`required` at `permission_set`). A non-system write naming a set the security catalog does not hold is refused `400 VALIDATION_FAILED` (`reference_not_found` at `permission_set`), the same envelope `sys_user_position.position` answers with; a deactivated set is still a set and is accepted. System writes are not judged against the catalog. + - **The platform's own grant writers** (the platform-admin promotion and the organization-admin reconcile) write the name alone and read no `sys_permission_set` row to do it, so a database with no such rows still gets its first platform administrator and its organization administrators. A row id is read only to revoke, or not duplicate, a grant written before the name column existed, which names nothing until the one-time backfill names it. Uninstalling a package revokes the grants that name its sets. + - **Unchanged.** Grants written before this release keep their id and are named by the existing backfill; the resolver and every grant reader already read the name. + + **The remedy.** A caller that wrote `{ user_id, permission_set_id: }` writes `{ user_id, permission_set: '' }`: the `name` of that set, as the metadata door (`GET /api/v1/meta/permission`) lists it. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate`), which applies `convertPositionBindingRows`. A name that organizations bound differently is reported `conflicting` and is never merged. +- 2ff0825: feat(plugin-security)!: the security plugin's own readers (set resolution, the delegated-administration gate, the permission explainer and the position-assignment refusal) read the security catalog and the activation ledger, not the catalog rows (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (enforcement stops reading `sys_permission_set` rows), shipped as `minor` under the launch-window convention for breaking changes. This is the second step of the cutover that `position-permission-sets-declared` registers: the authorization resolver already reads the catalog; now the security plugin's readers read the same catalog. + + **Permission-set resolution (`@objectstack/plugin-security`).** + + - FROM: a set name the metadata service and the bootstrap sets did not answer was loaded from a `sys_permission_set` row (the caller's organization's, else an organization-less one), its object map and field map included, and dropped when the row's `active` was false. + - TO: it is loaded from the security catalog (the environment registry the security plugin binds to its engine): every package's sets and every set an author saved through the metadata door. A set that only a row carries resolves nothing. The resolver already granted nothing through such a set; now its object and field permissions stop applying too. + - Fix: declare the set through the metadata door (`PUT /api/v1/meta/permission/NAME`) or in a package. + - A permission set that resolves only because a POSITION of the same name was folded into the request is dropped when the activation ledger (`sys_metadata_activation`, type `permission`) switched it off. The row's `active` column is not read. + + **Delegated administration (ADR-0090 D12).** + + - FROM: the sets a position distributes were its `sys_position_permission_set` rows in the caller's organization; `delegatable` was the organization's `sys_position` row's; the assignable-positions listing (`describeDelegableScope`) listed `sys_position` rows; a direct grant's set (and its `adminScope`) was its `sys_permission_set` row. + - TO: all four read the security catalog by name. A position distributes the sets its definition's `permissionSets` names, so the containment check and the listing see a binding declared only on the definition. `delegatable` is the definition's. The listing offers every position the catalog holds. A direct grant's set is the catalog's definition of the name the grant carries; a grant naming a set the catalog does not hold is refused for a delegate (a tenant administrator is not judged). + - A name a position's `permissionSets` carries that the catalog does not hold must be on the delegate's allowlist too. It grants nothing today, but it starts granting as soon as a set of that name is authored. + - A catalog read that fails refuses the delegate's write. Before, it approved it as "distributes nothing". + + **The permission explainer.** "deactivated" is read from the activation ledger through the resolver's own read, so explain and enforce agree. A direct grant whose set the catalog does not hold is reported neither as expired nor as deactivated: there is no set to lose. + + **Position assignments (`sys_user_position.position`).** + + - FROM: refused `400 VALIDATION_FAILED` unless a `sys_position` row (the writer's organization's, or an organization-less one) carried the name. A position declared only in a package or saved only through the metadata door was refused. + - TO: refused unless the security catalog holds a position of that name. A registry-declared position is accepted. A name only a `sys_position` row carries (with no definition) is refused, because it grants nothing. A deactivated position is still accepted. + + **One position name per deployment under `single`.** + + - FROM: a Setup create of a position, or a rename into a name, that an environment definition already held overwrote that definition (another organization's label and description, an empty `permissionSets`). + - TO: refused `409 UNIQUE_VIOLATION`, the answer a second row of the name in one organization already gets, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`. + + **`@objectstack/core`.** `readDisabledCatalogNames(ql, positions, sets)` is exported: the resolver's one read of the activation ledger. It returns the names switched off for this deployment and is now shared by the explainer and the security plugin. +- 149294c: A boot report lists, per organization, every security catalog reference whose name resolves nowhere (ADR-0131 D3/D4) + + Clause-②: no + + ADR-0131 D4 makes every reference to a position or a permission set a name, resolved through the security catalog (the environment registry). A stored reference whose name the catalog does not hold confers nothing, while Setup still lists it. At every boot, at `kernel:bootstrapped` and after the two one-time backfills, `@objectstack/plugin-security` now reads the stored references and logs what the catalog does not resolve. The report writes nothing: no row, no definition, no ledger entry. + + - **Where to read it.** In the boot log, as `[security]` lines at `error`, one line per kind of finding. Each line's metadata lists the organizations (`organizationId`, `null` for organization-less rows) with a count and the names or row ids, at most 50 per list. A clean boot logs one `info` line that says every reference resolves. + - **What it lists, per organization:** + - `sys_user_position` assignments whose `position` the catalog does not resolve; + - `sys_user_permission_set` grants whose `permission_set` the catalog does not resolve; + - `sys_user_permission_set` grants with no `permission_set` at all. The one-time name backfill could not name them: the set row is missing, belongs to another organization, or carries a name the catalog does not resolve (a row-only set). An unnamed grant confers nothing; + - `sys_position` rows whose name the catalog does not resolve, by name and organization. Under a walled posture these are the positions an organization authored in Setup: the catalog is environment-level, so they have no catalog home, and their assignments distribute no permission set. Under `single` these are the row-only positions the row-only backfill could not define, with `refusedNames` naming the ones whose names the metadata door refuses. The backfill reports that class once and records its verdict; this report lists it at every boot. + - **And under `single` only:** position names carried by more than one `sys_position` row (two organizations' rows, or an organization's row beside a package's or a built-in's seeded row). A position name is unique per deployment under `single`, so each is reported as conflicting, never guessed and never merged. + - **What clears a line.** The next boot re-reads everything. A reference whose definition is declared again (its package or its environment definition) leaves the report. So does a walled organization's position once a definition of that name is saved through the metadata door. Otherwise remove the row, or re-point it at a name the catalog holds. + - **A read that did not happen is not a finding.** If a scan or the catalog read fails, the report says which store it could not read, at `warn`, and reports nothing for that boot. + - **The grant-name backfill's warnings now say what such a grant is worth.** A grant the backfill could not name, because its set row's name is not in the catalog or its name write did not land, confers nothing until a later boot names it. The warning used to say these grants still resolved by id. That stopped being true when the authorization resolver began reading grants by name. + - **Nothing to migrate.** No configuration is needed. +- 990341a: feat(plugin-security): the boot no longer writes `sys_position` rows — the built-in and declared position seeders and the `everyone` baseline junction writer are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_position` row for the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) or for a position a stack declares, and no longer writes a `sys_position_permission_set` row binding the baseline sets to `everyone`. Positions are served by the security catalog (ADR-0131 D2): the built-ins are this plugin's declarations, a stack's positions are registry items, and `everyone`'s baseline — the app's default set and `member_default`, minus any set the high-privilege check refuses — is that anchor's declared `permissionSets`. `GET /api/v1/meta/position` lists all of them, and assigning a declared position needs no row. + + No authorization decision changes: since the resolver reads position definitions, no grant reads a `sys_position` or `sys_position_permission_set` row, and a fresh database and an existing one grant every principal the same. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + + FROM → TO: + + - FROM reading the built-in or declared positions from `sys_position` → TO the security catalog read (`createSecurityCatalogReader`) or `GET /api/v1/meta/position`. + - FROM the `everyone` → baseline `sys_position_permission_set` rows → TO the `everyone` definition's `permissionSets`. +- 9646991: feat(plugin-security): the curated platform capabilities are declared `capability` metadata + + Clause-②: no + + `SecurityPlugin` now declares the nine curated platform capabilities (`PLATFORM_CAPABILITIES` in `@objectstack/spec`: `manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`) to the engine registry under its own package id, from that one spec list. The security catalog read (`createSecurityCatalogReader`) and `GET /api/v1/meta/capability` now list them beside the capabilities a stack declares, and `GET /api/v1/meta/capability/:name` answers each one's definition (`name`, `label`, `description`, `scope`) where it answered `404`. A write to one of them at the metadata door is still refused, as before: `capability` is code-only. + + Nothing else changes: the `sys_capability` rows the boot seeds are identical, on a fresh database and on one already seeded, and no capability seeder reports the platform's own declarations. +- 7aa8d51: feat(plugin-security): the boot no longer writes `sys_capability` rows for the platform's curated capabilities + + Clause-②: no + + The curated and derived-default capability seeder (`bootstrapSystemCapabilities`) is deleted. On a fresh database the boot writes no `sys_capability` row for the nine curated platform capabilities (`manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`), and none for a name a permission set grants in `systemPermissions` without declaring it. The curated capabilities are served by the registry: `GET /api/v1/meta/capability` lists them and `GET /api/v1/meta/capability/:name` answers each one's definition, as before. Declare a capability your package grants with `defineCapability` (`stack.capabilities`); a declared capability is still seeded as a package row. + + No authorization decision changes: a grant is still resolved by the capability name in `systemPermissions`, and no runtime check reads a `sys_capability` row. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + + In `@objectstack/spec` (`PLATFORM_CAPABILITIES`) and `@objectstack/lint` (`validateCapabilityReferences`), the doc comments now name the registry instead of the retired seeder; no behaviour changes there. +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- c8a9783: feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. + + - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. + - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. + - **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. + - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. + + Added (additive, nothing an author writes changes): + + - `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. + - `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. + - `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `bootstrapDeclaredPermissions` + - `normalizeManagedByVocab` + - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` + - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` +- 60bcfe7: feat(plugin-security,rest,spec)!: the permission-set "Discard Overlay" action, its REST route and its contract member are retired; no door creates the overlay it discarded, and a database holding one is refused at cold boot (ADR-0131 cutover, ADR-0048) + + Clause-②: no (narrowing) + + + + **BREAKING** — a narrowing of the public surface: one HTTP route, one Setup action and one optional contract member are removed. Shipped as `minor` under the launch-window convention for breaking changes. + + The action deleted the environment-wide `sys_metadata` row that shadowed a package-declared permission set, then re-projected the set's `sys_permission_set` row. Neither half has anything left to do: + + - No door creates such an overlay. The metadata door refuses a save over a packaged permission set on every topology, whatever `OS_METADATA_WRITABLE` says. The data door refuses an edit of one (clone it instead). The data API cannot write `sys_metadata`. A package that declares a name the environment holds is refused at install. + - An overlay stored before those refusals makes the cold boot refuse the deployment with `422 NAMESPACE_CONFLICT`, so a server that could answer the route never holds one. + - The authorization resolver reads the security catalog, not `sys_permission_set` rows, so the re-projected row granted nothing. + + ### What was removed + + | removed | what to use instead | + | --- | --- | + | `POST /api/v1/security/permission-sets/:id/discard-overlay` | `os migrate security-catalog-overlays` with the flags and environment the deployment boots with lists every overlay row with no server running; `--apply` deletes them (a history tombstone per row). | + | the **Discard Overlay** action on `sys_permission_set` in Setup | the same offline step. To keep a customization of a packaged set, clone it under a new name (the **Clone** action) and assign the clone. | + | `ISecurityService.discardPermissionSetOverlay` and `PermissionSetOverlayDiscardResult` (`@objectstack/spec/contracts`) | nothing: delete the call and the import. `tsc` names each one. | + | `discardPermissionSetOverlay`, `PermissionSetNotFoundError`, `PermissionSetOverlayStateError` and the `PermissionSetOverlayDiscardDeps` / `PermissionSetOverlayDiscardResult` types (`@objectstack/plugin-security`) | nothing: delete the import. | + + **The one-line fix:** stop calling the route or the member, and clear any overlay offline with `os migrate security-catalog-overlays --apply` before the first boot on this release. + + `ERROR_CODE_LEDGER['@objectstack/plugin-security']` no longer lists `INVALID_STATE` or `NOT_FOUND`: the removed action was their only producer in this package. Both codes stay registered under `@objectstack/rest`, and the union of codes is unchanged. + + The permission-set drift diagnostic no longer names the action in its `overlay_shadow` detail, and the boot reading of shadowed packaged sets names the offline step instead. +- d112087: feat(plugin-security)!: accepting a package's `isDefault` suggestion takes effect for the whole deployment + + Clause-②: yes (narrowing) + + **BREAKING.** A package's `isDefault: true` permission set now reaches every signed-in member through ONE deployment-level acceptance, and the acceptance takes effect. Before this release a confirm wrote a `sys_position_permission_set` row that granted nothing. + + - **What an accept does.** `POST /api/v1/security/suggested-bindings/:id/confirm` records the decision once for the deployment, and the security plugin derives the `everyone` anchor's definition again as the baseline plus every accepted set. The derivation also runs at boot, on uninstall and on a package-door publish. A member's reads of the package's objects change on the next request. `bindingCreated` is `true` when the definition did not name the set before. + - **Who may decide (narrowing).** All three routes now need the `manage_metadata` capability, and under a `group` or `isolated` tenancy posture the platform operator. Before this release a tenant administrator (the `*` wildcard holder) listed, confirmed and dismissed suggestions for their organization. That caller is now refused `403 PERMISSION_DENIED`. Remedy: a `manage_metadata` holder decides, once for the deployment. + - **The record is the deployment's.** `sys_audience_binding_suggestion` rows are owned by the Default Organization. A deployment with no Default Organization (several organizations, none with slug `default`) answers `409 SUGGESTION_STATE` with the remedy. + - **Rows written before this release are never promoted.** A Default Organization row a tenant administrator confirmed or dismissed is set back to `pending`, unless the baseline already names the set. A row another organization owns, or none, is not read. Boot logs a warning with the count. Such rows granted nothing before this release and grant nothing after it. + - **The high-risk gate is on the definition (ADR-0090 D7).** Accepting a set that carries a bit the `everyone` anchor forbids answers `403 PERMISSION_DENIED`. If a publish later adds such a bit to an accepted set, the derivation withholds it and logs an `error` that names the set. + - **Revoke.** `POST …/:id/dismiss` on an accepted suggestion revokes it, and `everyone` is derived again without the set. A set that only the baseline names (`confirmed`, no resolver) cannot be dismissed (`409`). + - **Only `everyone`.** A `guest`-anchor suggestion is refused `409`, because accepting it would record a grant that never takes effect. + - **The anchors stay sealed.** No caller writes `everyone`'s definition. An acceptance changes the derivation's input, and the plugin re-declares the definition. Reconciliation reads that definition and no binding row. + - **Declarations come from the security catalog.** A suggestion is offered for an `isDefault` set that the catalog serves with an owning package, which is the source the authorization resolver reads. The module no longer reads `sys_position`, `sys_permission_set` or `sys_position_permission_set`. It also no longer materializes a set. + - **New field:** `sys_audience_binding_suggestion.deployment_decision`, a boolean that is `true` when a `manage_metadata` holder resolved the row. + + Exported TypeScript surface (the compiler names each caller): + + - Removed: `reapOrganizationLessSuggestions`, `listSuggestionOrganizationIds` and the `SuggestionReconcileScope` type. Nothing replaces them, because the record is no longer per organization. + - `reconcileAudienceBindingSuggestions` and `syncAudienceBindingSuggestions` now take `(ql, logger)`. The metadata, scope and organization parameters are gone. + - `SuggestionReconcileOutcome` reports `unread` in place of `organizations` and `reaped`. `SuggestionSyncOutcome` gains `reopened`. + - `SuggestionDeps` drops `resolveSets` and takes `posture` and `rederiveEveryone`. + - New: `readAcceptedEveryoneSets(ql, metadata)` and the `WithheldAcceptance` type. + + +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- a3bcbcf: feat(plugin-security)!: a data-engine context that carries no principal and is not a system context is refused (ADR-0096 D5 strict mode) + + Clause-②: no (narrowing) + + + + **BREAKING** for in-process code: an accept-set narrowing of the data engine's security middleware, shipped as `minor` under the launch-window convention for breaking changes. + + **What is refused now.** A data-engine operation whose execution context carries no principal (no user id, no position, no permission set) and is not a system context. That covers a call that passes no context at all, an empty one, one with only a tenant id, and one with only provenance fields. Every layer answers it the same way: + + - the engine middleware throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb (find, findOne, count, aggregate, insert, update, delete), before the operation runs; + - `canReadObject`, `canExport` and `canWriteObject` answer `false`; + - `getReadFilter` answers the deny filter (zero rows). + + It used to be handed straight through, with no CRUD gate, no row-level security, no field mask and no tenant wall. That contradicted the published contract for an empty tool-execution context, "unauthenticated (RLS-on, sees-nothing)", which this change now keeps. The field projections (`getReadableFields` and its siblings) answer such a context as they answer any caller that resolves no permission set. + + **What is unchanged.** + + - A system context (`isSystem: true`) is admitted everywhere, as before. + - A context that carries a principal without a user id is decided by what it carries: a named permission set, the guest principal, or the public-form grant. + - An unauthenticated HTTP request is answered as before: a door that requires a session answers `401 UNAUTHENTICATED` before the engine is asked anything, and an endpoint an application declared open runs it as the guest principal. + + **What to do.** Code that calls the data engine in-process must state who it acts for. FROM: an engine call with no context, or with a context that names nobody. TO: either + + - pass the caller's execution context, so the caller's own permissions decide; or + - for platform plumbing whose own door already authorized the caller (a store read or write the platform owns), pass the explicit system opt-in, `context: { isSystem: true }`. + + The one-line fix: give every in-process engine call a context, the caller's or `{ isSystem: true }`. The refusal message names both. + + `@objectstack/metadata-protocol`: the protocol's own platform-store reads and writes behind the publish, history, audit, diff, commit, migration and code-only delete doors now pass the explicit system opt-in, like its other store calls. Nothing those doors answer changes. +- c6fe02d: `SecurityPlugin` declares the identity objects its authorization store reads, `sys_user` and `sys_member`, and refuses to boot a kernel that does not register them. The refusal names the missing objects and the plugin that registers them, where the kernel used to boot and then fail every authenticated request's permission read as an outage. + + Clause-②: yes (narrowing) + + + + **BREAKING**: a kernel that booted before is refused at boot, shipped as `minor` under the launch-window convention. + + **Why.** Permission resolution, `resolveUserAuthzGrants` in `@objectstack/core`, reads `sys_user` and `sys_member`, which `@objectstack/plugin-auth` registers. The engine refuses a read of an object its registry does not hold, so a kernel with `SecurityPlugin` and without those objects booted cleanly, and then every authenticated request's permission read failed with `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503), which reads as an outage and names no missing dependency. + + **What is refused.** A kernel where `SecurityPlugin.start()` ran and the engine registry does not hold `sys_user` or `sys_member` at `kernel:ready`: `bootstrap()` rejects with an error named `AuthzIdentityObjectsMissingError`, whose `missingObjects` lists the absent names and whose message names `@objectstack/plugin-auth`, `AuthPlugin` and `createIdentityObjectsPlugin()`. Measured in this repository: three test harnesses that boot `SecurityPlugin` without `AuthPlugin` (one in `@objectstack/runtime`, two in `@objectstack/service-automation`), each now mounting `createIdentityObjectsPlugin()`. By reading the code, not by a run: `objectstack dev` through `DevPlugin` with `services: { auth: false }` and security left on is refused too. + + **What still boots, unchanged.** A kernel that mounts `AuthPlugin` (`os serve` pairs the two; `@objectstack/verify`'s `bootStack` mounts both); a kernel that registers the two objects any other way; and a boot that composes `SecurityPlugin` for its declarations only (`os migrate`, which suppresses `start()`). + + **The remedy.** on a kernel that mounts `SecurityPlugin` without `AuthPlugin`, such as a test kit, add `await kernel.use(createIdentityObjectsPlugin())` from `@objectstack/plugin-auth`. It registers plugin-auth's own identity list, so a hand-written plugin that registers `SysUser` and `SysMember` is no longer needed, though it still satisfies the check. A `DevPlugin` stack with `services: { auth: false }` either turns security off as well or passes `createIdentityObjectsPlugin()` in `extraPlugins`. Nothing in an app's metadata changes. +- ffb31fc: fix(plugin-security)!: `granted_by` on the grant tables is provenance: the platform stamps the writer, and a non-system caller can no longer name the granter + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `granted_by` records who wrote a grant. The platform already treated it that way: the delegated-admin gate stamped the writer, and the organization-admin reconcile writes the attributed human or null (ADR-0118 D1, an id or null and never a sentinel). But the column was a plain lookup, so a non-system caller could store any existing user as the granter, and the integrity audit filed a granter that no longer resolves as a broken business reference, which raised its `[integrity] stored references that resolve to nothing` warning on every run. + + **What moves for consumers.** + + - **Non-system inserts store the writer.** The delegated-admin gate now stamps the caller's user id into `granted_by` on every `sys_user_permission_set` and `sys_user_position` insert it admits, whatever the payload carried. A tenant-level admin's insert and a delegate's insert are stamped alike. Before, a supplied value was stored as sent, and a tenant-level admin's insert that supplied none stored `null`. + - **Non-system updates cannot change it.** Both columns are now `readonly: true`, so the engine's update strip drops a caller's `granted_by` and the row keeps its granter. Before, the update landed. A caller that passes `strictReadonlyWrites` is refused instead, as for every read-only column. + - **The integrity audit files it as provenance.** A `granted_by` that names a user who no longer exists is reported under the audit's `provenance` bucket, which does not raise the warning. A business reference that resolves to nothing is reported under `dangling` and warns, as before. + + **Unchanged.** + + - System-context writers keep the value they write: invitation placement stamps the issuer, the organization-admin reconcile stamps the attributed human or `null`, and the platform-admin promotion writes `null`. A server-side writer that must record someone other than its caller writes under a system context, as these do. + - Who may write a grant, and what a grant gives, are unchanged. The gate's authority decision runs first; a refused write is not stamped. + - Stored rows are not rewritten, and no stored row is refused on read. A row whose `granted_by` already names someone else, or holds a value that names no user, keeps it. + - `sys_record_share.granted_by` (plugin-sharing) is unchanged: its only writer is the sharing service, which already stamps the acting user under a system context. +- 81bd9fa: fix(plugin-security)!: a position assignment or permission-set grant scoped to an organization must name a member of that organization + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What stops being accepted.** A `sys_user_position` or `sys_user_permission_set` row whose `organization_id` is set may name only a user who holds a `sys_member` row in that organization. A non-system insert or update that would store any other user is refused with `400 VALIDATION_FAILED`, one `fields[]` entry at `user_id` with `code: reference_not_found` — the envelope a `user_id` naming no user already receives. No new error code. It applies to every non-system caller, platform administrators included, and to every engine write: `POST` / `PATCH /api/v1/data/...`, batch inserts, predicate updates, and scripts and flows that run as a user or a service principal. The organization judged is the one the row is stored with: its own `organization_id`, or, when it names none, the caller's active organization stamped at write time. + + **What stays accepted.** + + - System-context writes: seed replay, invitation acceptance, the organization-admin reconcile and the platform bootstraps. + - A row stored with no organization: a global grant names no organization to be a member of. + - An update that changes neither `user_id` nor `organization_id`, so a stored row whose holder has since left the organization stays editable and can be end-dated. + - A stock `single`-posture deployment: under the default `auto` membership policy every user is a member of the default organization, so every assignment there names a member. + + **What changes for you.** Add the user to the organization first, then assign the position or grant the permission set. A script that assigned a grant before the user joined now receives the `400` above; reorder it. +- f2626c7: fix(plugin-security)!: the Layer 0 tenant write wall refuses a non-system update that would leave a row with no organization + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What stops being accepted.** Under a walled tenancy posture (`isolated` or `group`), on an object the Layer 0 tenant wall covers, a non-system update that would leave the row with no organization is refused with `403 PERMISSION_DENIED`. That holds for the value the caller sends and for the value the stored row ends up with after the `beforeUpdate` hooks, on the by-id and the predicate update paths alike, and for every non-system caller the wall covers, platform administrators on public tenant objects included. The rule the wall already stated for an update, that a row's organization stays one the caller's organization scope admits, now covers "no organization" as well as "another organization". An organization-less row is not inert: on an ordinary tenant object it leaves every reader's scope, and on the organization-scoped grant tables (`sys_user_position`, `sys_user_permission_set`) it is read as a grant that applies in every organization. + + **What stays accepted.** + + - An update that does not touch `organization_id`, and one that keeps the row's own organization. + - System-context writes (seed replay, imports, migrations, platform bootstraps). + - A platform administrator on an object whose posture permits crossing the wall, as before. + - The `single` posture, where no tenant wall applies. + - Inserts: an absent or empty organization on an insert is still filled with the caller's active organization. + + **What changes for you.** A script or flow that cleared a row's organization through a user or service-principal context now receives the `403` above. Keep the row in its organization; if a row genuinely must carry no organization (a global grant, for example), write it from a system context. +- 00bee27: fix(plugin-security)!: a definition edit or delete of a position a code package declares is refused at the data door, as the metadata door already refuses it; its row now carries package provenance, and its row state stays switchable + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing of the `sys_position` data door, shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 (no door edits a managed definition) and D3 (managed items are read-only and clonable). + + **What was wrong.** The declared-position seeder wrote the row of a package-declared position with no provenance stamp, so the row carried the object default, `managed_by: 'admin'`, and the system-row write gate did not protect it. A Setup or data API edit of its label answered `200`, and the next boot wrote the declared label back over it with no message. The metadata door already refused the same edit with `403 NOT_OVERRIDABLE`. + + **What is refused now.** A position is package-declared when the engine registry's artifact lookup answers a code package for its name, the same lookup the metadata door refuses a save from. On such a position's row, the admin door now answers `403 PERMISSION_DENIED`, with the message it already gives for a built-in position, to: + + - an update that touches a definition column (`name`, `label`, `description`, `delegatable`) or any column other than the two row-state columns below. That covers single-record edits, `updateMany`, and filtered updates. Until now a label or description edit answered `200` and the next boot wrote the declaration back over it; a `delegatable` edit answered `200` and persisted. + - a delete. Until now it answered `200` and the next boot created the row again. The other lifecycle writes (transfer, restore, purge) are refused as well. + + **What stays switchable.** A patch that touches only row state (`active` and/or `is_default`) still passes, so Setup's Activate, Deactivate and Set as Default keep working on a package-declared position and persist across restarts. This is the rule a packaged permission set already follows: switching it off is not an edit of its definition. A filtered update passes on the same terms, as long as its filter reaches no built-in position. + + System-context writes are unchanged: the boot seeder still refreshes the row's label and description from the declaration. + + **What is unchanged.** + + - A position an administrator created in Setup, and a position the environment authored through the metadata door, keep an unmanaged row (`admin`) and stay editable. + - The built-in positions are refused exactly as before, a bare `active` or `is_default` patch included. + - Assigning a position to users and binding permission sets to it are writes on other rows (`sys_user_position`, `sys_position_permission_set`), and this change does not touch them. + + **At the first boot after upgrading.** The existing row of each package-declared position is re-stamped `package` in place when it carries `admin`, no value, or the legacy `user`. Only `managed_by` changes: the `active`, `is_default` and `delegatable` values an administrator set before the upgrade are kept. The boot's `declared positions seeded` info line counts these rows as `restampedPackageProvenance`. The seeder matches a row by name, as its label refresh always has, so a position created in Setup whose name a package declares later becomes that package's position: its definition is refused the same way, and its row state stays switchable. + + **What to do.** To change a package-declared position's label, description or `delegatable`, change it in the package and publish a new version. To have a position you can edit in Setup, clone it under a new name with the Clone Position action, bind its permission sets, and assign the clone. +- ce3d0ad: The registered `security` service serves `checkControlledByParentWrite(object, recordId, context)`, the ADR-0055 master-detail write check that `ISecurityService` declares as optional. + + - **What it answers.** It answers what a by-id update of the record gets from the master-detail write check. It runs the write path's own composition and never re-derives it: the engine middleware's context prologue (a principal-less context, permission sets that cannot be resolved and a missing on-behalf-of delegator are refused with the same `403 PERMISSION_DENIED`), and then the check for the principal's permission sets and, on a delegated context, for the delegator's (ADR-0090 D10). The first refusal is the answer. A refusal resolves `deny` with the leg it came from, the three non-verdicts resolve `unresolvable` with their reason, and an object that is not `controlled_by_parent` resolves `not_applicable`. A system context resolves `allow`. A store fault rejects with the engine's own error, so a datasource outage keeps its `503`. + - **Where it differs from the write path's guard.** A principal whose permission sets resolve to none is judged over that empty list, so it answers `deny` on `object_permission` as the contract declares. A principal with positions or sets and no user id is also judged, where the write path skips the check. It answers `deny` when it holds no `update` grant on the master, and its own update is refused by the object-level gate anyway. + - **Who asks it.** The `sys_attachment` parent gate in `@objectstack/service-storage` and the `sys_comment` moderation gate in `@objectstack/plugin-audit` ask it, so they judge a `controlled_by_parent` parent through its master. + - The boot line that announces the `security` service now lists the members the registered service actually serves, read from the service object. The hand-kept list it replaced had fallen behind by five members. + + Nothing an author writes changes, and no by-id write is judged differently. The middleware's context prologue moved into one method that the middleware and the member both call. Its refusals, their order and their wording are unchanged. +- d303b3e: `security/explain` answers an update of a `controlled_by_parent` record the way that record's own by-id update is answered: its record verdict now comes from the master-detail write check (ADR-0055), which it never asked. + + - **What was wrong.** On such a record the sharing service's per-record gate abstains, because the effective model of `controlled_by_parent` is `public`, and explain read the abstention as "writable". It also kept the platform ownership floor (`owner_only_writes`) on the record, which the write path hands over to the master-detail write check. So `record.visible` for an update was decided by that floor (`decidedBy: 'rls'`) for every principal who did not create the record. That was refused, on the wrong layer and with no word about the master, for a principal who may not edit the master. It was also refused for a principal who may edit the master, beside a `PATCH` that answers `200`. + - **What it does now.** For every update of a record that exists, explain asks the security service's `checkControlledByParentWrite` with the context it is explaining (another user's, when `userId` names one). A `deny` or `unresolvable` outcome makes the record not writable, decided by the `sharing` layer, whose record attribution names the refusing leg (`object_permission`, `row_level_security`, `record_sharing` or `master_chain`) or the reason the check reached no verdict. A rejection of the check is reported fail-closed, as a dependency that throws already is. `allow` and `not_applicable` leave the report exactly as before. The update's row-level composition carries the write path's own master-gate coverage vouch, so the ownership floor is handed over to that check as the write path hands it, and never on behalf of a delegator. + - **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field, which answers whether the principal may update the object at all. `delete` is unchanged too: explain asks no master check for it and keeps its floor. + - **Public surface.** `ExplainEngineDeps` gains the optional `checkControlledByParentWrite` dependency. A deps bag wired without it explains an update as before, and claims nothing about a master. +- ad14957: fix(metadata-protocol): a create under a name a managed package or a built-in holds is told to choose another name, not to edit the source artifact (#22591) + + Clause-②: yes + + The public input surface widens: `packagedBaseRefusal` accepts `operation: 'create'`, and `tenantAuthoredWriteRefusal` takes an optional operation; no verdict, code or status moves. + + A write onto an item a managed package ships, on a type with no environment overlay, is refused with `403 NOT_OVERRIDABLE`. The refusal's remedy used to assume the write was an edit of that item: "Edit the source artifact and redeploy", or, for a flow, an action or a permission set, its clone or on/off switch. That remedy is wrong for a create. An administrator who creates a position of their own under a name a package or a built-in already holds (`manager`, `everyone`, `guest`, …), or renames one into such a name, cannot edit a source artifact they did not write, and did not ask to customize the package's item. + + The remedy now follows what the write did: + + - **A create, or a rename into the name**, is told: the name is taken; to author your own item, choose a name no package or built-in holds, and `GET /api/v1/meta/:type` lists the names in use. This is one sentence for every metadata type. + - **An edit** keeps the sentence it had, byte for byte. + + Which writes are refused does not change, and neither do the code and the status. + + Where the remedy is a create's: + + - **Setup** (`@objectstack/plugin-security`): a create of a `sys_position` row, or a rename into a name a package or a built-in holds, under the `single` posture. The data door relays the metadata door's refusal for a create. + - **The metadata door** (`PUT /api/v1/meta/:type/:name`): a save sent with `If-None-Match: *`, the first-write precondition (`parentVersion: null` in `saveMetaItem`). Any other save is an edit, as before. + - **`ObjectStackProtocolImplementation.packagedBaseRefusal`** takes `operation: 'create'` beside `'save'` and `'delete'`, for a door that knows its write takes a name. The verdict is the `'save'` verdict. `tenantAuthoredWriteRefusal` takes an optional `operation: 'create'` the same way. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. + +### Patch Changes + +- fec87e7: feat(spec)!: a package manifest's `permissions` no longer takes a flat list of permission strings — the structured `{ services, hooks, network, fs }` block is the only form (#13458) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). + + `ManifestPermissionsSchema` was a union of a flat `string[]` and the structured ADR-0025 §3.2 block. Nothing ever acted on the list: the loader registers the consented `grantedPermissions` set from the environment artifact with the permission enforcer, never the manifest's request, and the only code that met a list on a manifest was two reports saying it had been skipped. The marketplace install disclosure reads only the four lists, so a list was shown to an installer as "Requests no special permissions." (ADR-0049 enforce-or-remove). `ManifestPermissionsSchema` is now the structured block itself. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `permissions: ['system.user.read', 'system.data.write']` | `permissions: { services: [...], hooks: [...], network: [...], fs: [...] }`, naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches. | + | `permissions: []` | delete `permissions`: absence is the spelling for "requests nothing". | + | a list on an app manifest that ships no code | delete `permissions`. An app's record access is its permission SETS, in the stack's own top-level `permissions` collection. | + + **The one-line fix: replace every manifest `permissions` list with the structured block, or delete it.** A permission string has no mechanical mapping onto the four lists, so the translation is done by hand. `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + **What an author now sees.** Writing a list fails `tsc` (the key's type is the block), and `os validate`, `os build`, `os plugin build` and `defineStack` refuse it at `manifest.permissions` with the block's own answer: `Expected the plugin permission block { services?, hooks?, network?, fs? }, received a flat list.`, followed by the prescription. Every structured block that parsed before still parses, unchanged. + + ### The retirement kit + + - **Schema.** `ManifestPermissionsSchema` is `PluginPermissionsSchema`, by identity, and both export names stay. The block answers a list with its prescription on its own error map, the bare-array pattern `ListViewExportOptionsSchema` uses. The block also carries `EnvironmentArtifactSchema.grantedPermissions` values, so the answer is worded true there too, where a list was never legal. + - **D2 conversion `manifest-permissions-string-list-removed`** (step 18, retired from the load path): a lossless delete of an all-string list from the stack's `manifest` and every `packages[].manifest`. The notice carries the dropped strings. A built artifact replays it at the artifact door, so an artifact built while the list was legal still boots. It never touches the structured block, an array of objects, or the top-level ADR-0090 permission-set collection. + - **D3 entry `manifest-permissions-string-list-retired`** carries the judgement the delete cannot make: what each dropped string meant in services, hooks, hosts and paths. + - **Liveness.** `manifest.permissions` stays `live` on corrected evidence. Its consumer is the marketplace install disclosure, and it refuses nothing at load. The four keys are now drilled. + - **The two skip reports reworded.** `AppPlugin`'s security registrar and `@objectstack/plugin-security`'s audience-binding reconciler both report a manifest-stage `permissions` they cannot read as permission sets. They now name the flat list as the retired legacy form. They behave as before. + + **Measured producers: none outside tests.** On origin/main e67ba80049, no manifest in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes a list. The exceptions are five `@objectstack/spec` `manifest.test.ts` fixtures, re-triaged here, and the skip-report tests of `@objectstack/runtime` and `@objectstack/plugin-security`, which hand a list to an unparsed bundle on purpose and still pass. The same instrument finds those five fixtures, which is its control. At the objectui pin `a58626c88dc8`, nothing reads `manifest.permissions` (control: 91 `manifest.(id|name|version)` reads), and the install disclosure reads the structured block alone. Deployed and cloud-held manifests NOT MEASURED. +- 17e4425: A position saved through the metadata door no longer stops an app's declared positions from being seeded + + Clause-②: no + + Every seeding pass copies the app's declared positions into the `sys_position` catalog: the boot's pass and, on a walled deployment, the pass each new organization gets. That seeder read one source or the other: the engine registry alone whenever it held any position besides the six built-ins, otherwise the metadata service. A position a platform administrator saves with `PUT /api/v1/meta/position/:name` lives in the registry. So after one such save, every later pass seeded that position and none of the app's own. Measured on the showcase, walled: an organization created after the save held 7 positions (the six built-ins and the saved one) instead of 17. + + The seeder now reads through the security catalog read (`createSecurityCatalogReader`). It reads both sources on every pass, and for a name both hold, the registry answers first. + + - **New organizations** get the built-ins, every declared position and every door-saved one. Measured on the same walled showcase: 17 positions. + - **Rows are written as before.** The door-saved position's row is unchanged: same name, label and description, `active: true`, `is_default: false`, no provenance stamp. For a name both sources hold, the registry's definition is still the one written. + - **The six built-in positions** are still written only by the built-in seeder, with `managed_by: 'platform'`. Some deployments saved a definition under a built-in name before the six were declared, and that definition still shadows the declaration when the catalog is read. This seeder skips it by name, so it never reaches a row. + - **A catalog read that fails** now writes nothing for that pass, and the boot reports it once as `[security] declared-position seeding failed`. That covers a source that cannot be read and a metadata list that lost a loader. Before, the seeder seeded whatever part it could read, without saying so. + - **No grant changes.** A principal's permissions move only if it holds a position that is newly seeded into its organization. +- 7d7943d: Grants written before `sys_user_permission_set.permission_set` existed now get their permission set's name, once, at boot (ADR-0131 D4) + + Clause-②: no + + - **What happens on the first boot after upgrading.** At `kernel:bootstrapped`, `@objectstack/plugin-security` fills `permission_set` on every grant that has no name yet. The name is the `name` of the `sys_permission_set` row that the grant's `permission_set_id` points at. The set row is read inside the grant's own organization: a grant of an organization may name that organization's set or an organization-less one, and an organization-less grant may name only an organization-less set. Each name is checked in the security catalog before it is written. Only the name column is written: no id changes, no grant is moved and no row is deleted. No principal's grants change, because readers still resolve grants from `permission_set_id`. + - **What is left unnamed, and reported in the boot log by count and grant id.** A grant whose id names no set row (`warn`). A grant whose id names another organization's set row (`warn`); nothing about that organization is logged. A grant whose set row carries a name the security catalog does not hold at that boot (`error`): register the permission set definition, or re-point the grant. + - **It runs once.** When the pass has nothing left that a later boot could decide differently, it records its verdict in `sys_migration` under the id `adr-0131-grant-permission-set-name-backfill`, and later boots skip it. A grant the catalog could not verify, or a write that did not land, leaves the verdict unrecorded, so the next boot tries again. Without a `sys_migration` table (no `PlatformObjectsPlugin` in the composition) the pass still runs on every boot, and renames nothing it already named. + - **Nothing to migrate.** No configuration is needed. +- 19bb55e: The permission-set grant readers in `@objectstack/plugin-security` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no + + - **What reads the name now.** The explain engine's dropped-grant provenance (`buildContextForUser`: an expired grant, or a grant of a deactivated set), the platform-admin bootstrap's existing-holder check (`bootstrapPlatformAdmin`, and the seed-ownership claim's `findExistingPlatformAdmin`), the organization-admin reconcile (`reconcileOrgAdminGrant`, `backfillOrgAdminGrants`) and the delegated-administration gate's judgement of a stored grant it is asked to change or delete. Each reads the grant's `permission_set` instead of its `permission_set_id`. Deactivation is still read from the `sys_permission_set` row, now found by that name: the grant's own organization's row, else the organization-less one. The authorization resolver in `@objectstack/core` still reads the id, and nobody's resolved permissions change. + - **A grant whose name is empty.** A grant written before the name column existed has no name until the one-time backfill names it at `kernel:bootstrapped`, and the backfill leaves a grant unnamed when its id names no set row or another organization's set row. Such a grant grants nothing through these readers. Explain reports nothing for it. The organization-admin reconcile still finds it through its id when it revokes the grant or checks for a duplicate before inserting one. The delegated-administration gate refuses a delegate's change to it; a tenant administrator is not affected. The platform-admin bootstrap does not promote a second administrator while an unscoped grant on the `admin_full_access` row is still unnamed. It returns `reason: 'admin_grant_unnamed'` without an `adminUserId`, logs a warning, and the next boot reads the grant by its name. + - **Within the organization-admin reconcile,** a pair that already holds the organization-admin set by name, through any organization's copy of it, gets no second grant. + - The earlier release notes for the name column said no reader used it yet. That is no longer true of the readers listed above. + - **Nothing to migrate.** +- 48cca4a: Setup's user page grants a position or a permission set by picking its name from the security catalog (ADR-0131 D4) + + Clause-②: no + + - `@objectstack/plugin-security`: `sys_user_position.position` declares `widget: 'position-ref'` and `sys_user_permission_set.permission_set` declares `widget: 'permission-ref'`, so the grant form offers a picker over the positions and permission sets the catalog serves, and stores the chosen name. A console without those widgets keeps the text input. `sys_user_permission_set.permission_set_id`, the legacy id that grants nothing, is now `hidden` and no longer appears on the grant form; the column stays until a later release drops its data. + - `@objectstack/platform-objects`: the user page's Positions and Permission Sets lists no longer carry the "Assign position" / "Grant permission set" buttons. Those buttons listed `sys_position` / `sys_permission_set` rows, which a deployment need not hold. Grant with the list's **New**, which opens the grant form; removing a grant is unchanged. +- 9e9e6f9: feat(cli)!: `os meta resync` is deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + + In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. +- 2fbe1fd: Internal move with no behavior change: the organization-scoping helpers and the seed write-refusal accumulator leave `per-organization-catalog.ts` for two new internal modules, `organization-scope.ts` and `write-refusals.ts` + + Clause-②: no +- e67ba80: The seed ownership claim no longer fires app hooks or record-change flows + + Clause-②: no + + On a freshly seeded database, the first sign-up promotes the first user to platform admin, and `claimSeedOwnership` hands every seeded row to that admin inside the same request. That write ran under a bare system context, so every claimed row went through the full write pipeline: hooks bound from app metadata fired, record-change flows ran, approvals opened on seeded records and notifications went to the new admin. Measured on hotcrm `56d98f7e` (17.7.0, a 354-row seed), the first sign-up took about 45 s, and in that time the claim fired 1,254 app hooks, ran 8 flows, opened 2 approvals and handed 8 emails to the transport. + + The claim's write now runs with `{ isSystem: true, skipAutomations: true }`. The seed itself is end-state data written without automation, and the claim keeps that rule for the write that completes it. + + - **App hooks no longer fire for the seed ownership claim.** No hook bound from metadata (an app's `hooks`, sandboxed bodies included) runs on the claim's owner change, and no record-change flow is dispatched for it. So the claim opens no approval and sends no notification. + - **Which claims.** Every pass of the claim: the promotion pass inside the first sign-up, and the pass that runs on `app:seeded` on every boot, the first and every later one. + - **Still runs.** Hooks that plugins register in code still run, so the claim still writes one audit row per claimed record (plugin-audit) and plugin-sharing still recomputes the grants the owner change earns. Every claimed row's `owner_id` is the admin and its `updated_at` still advances, exactly as before. The per-row hook ceiling and the paged fallback for very large objects are unchanged. + - **If an app relied on it.** An app hook or flow that reacted to the claim's owner change no longer sees it, just as it never saw the seed's own writes. None of the hooks or flows in ObjectStack's own example apps reads it. +- 4049cac: fix(plugin-security): the boot heal of drifted `sys_permission_set` rows converges on duplicated names, stops paying a discarded metadata read per row, and a refused existence read no longer inserts a duplicate (#22169) + + Clause-②: no + + **The heal writes the row it read, by its id.** `reconcilePermissionSetProjection` heals every overlay-less, env-authored record that drifted from its metadata definition. It used to hand each drifted record to a by-name upsert, which re-read the first record of that name (lowest id). On a table holding more than one record per name, only that one was ever written: every other record was logged "re-projected" and left drifted, on every boot. Now each drifted record is updated by its own id, facets only (label, description and the permission columns; identity, `active` and provenance are never touched). The drift line is logged after the write and names the record's id; a refused write is logged as refused instead of as re-projected. On a real engine with 8 names × 3 records, the boot after the healing one went from 116 statements and 16 drift warnings to 4 statements and none. + + **The layered metadata read is lazy, once per name.** The heal awaited `getMetaItemLayered` (four statements on the protocol) for every candidate record before deciding whether it needed it, and it does not need it while a `SchemaRegistry` is readable. It is now read only when no registry answers, and once per name per pass. + + **A refused existence read declines the insert.** `bootstrapPlatformAdmin` reads each default permission set by name and inserts when it finds none. A refused read answered as "none", so on a table without the per-organization unique index the boot inserted a second record of a set that already existed. A refused read now inserts nothing, and the pass says so once, naming the sets. When `admin_full_access` is one of them, the promotion half reports `admin_permission_set_unreadable` instead of `admin_permission_set_missing`. + + **Unchanged.** Existing duplicate records are healed, never deleted: removing them is an operator repair. The live single-mutation projection keeps its per-item read. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- 86bf9ed: `security/explain` answers a delete and a transfer of a `controlled_by_parent` record the way each verb's own door answers it: their record verdicts now come from the master-detail write check (ADR-0055), as the update's already did. + + - **What was wrong.** The write path runs the master-detail write check on every by-id write of such a record (an update, a delete, a transfer), and hands the record's platform ownership floor over to that check. explain asked the check for an update only. + - A delete kept its `owner_only_deletes` floor. A principal who may edit the master but did not create the record was explained as refused (`decidedBy: 'rls'`) beside a `DELETE` that answers `200`. A principal who may not edit the master was refused on that floor, with no word about the master, beside a `DELETE` whose `403` names the master. + - A transfer asked only the sharing service's per-record gate, which abstains on such a record. A principal who may not edit the master was explained as allowed to transfer it, beside the `PATCH` that writes `owner_id` and answers `403`. + - **What it does now.** For an update, a delete or a transfer of a record that exists (and a restore or a purge, which the object gate still refuses to everyone), explain asks the security service's `checkControlledByParentWrite` with the context it is explaining. The check judges edit access to the master whatever the record's own verb, so its answer is each verb's answer. A `deny` or `unresolvable` outcome makes the record not writable, decided by the `sharing` layer, whose record attribution names the verb and the refusing leg (or the reason no verdict was reached). A delete's row-level composition carries the write path's master-gate coverage vouch, as an update's does, so the ownership floor is handed over as the write path hands it, and never on behalf of a delegator. + - **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. A `create` asks no master check: the write path reads an insert's master from the request body, which an explanation does not carry. The `ExplainEngineDeps` type: the `checkControlledByParentWrite` dependency keeps its signature and is now asked for every by-id write, not only an update. +- 25be876: `security/explain` composes a transfer's row-level security as the update its door is, and a restore's and a purge's as the write class the by-id write path composes them as. + + - **What was wrong.** The by-id write path composes a lifecycle verb's row-level security for its nearest write class: transfer and restore as `update`, purge as `delete`. explain composed it for the verb as asked, which the RLS compiler reads as a read (`select`). So no update-class policy and no ownership floor reached a transfer's verdict. On a row an app-authored update policy excludes, explain answered `record.visible: true`, with "No business RLS policy applies to this record", beside the `PATCH` that writes `owner_id` and answers `403 PERMISSION_DENIED`. + - **What it does now.** The mapping is declared once, in plugin-security, and both readers read it: the by-id write pre-image gate (byte-identical to the expression it replaces) and explain. explain composes both of its row-level readings for the mapped operation: the record's row story and the object-level `rls` layer. A transfer of a row an update policy excludes is now `visible: false`, decided by `rls`, as an update of that row is. The object-level `allowed` reads the `rls` layer's deny-all verdict, so for a transfer it now reads the update class's. Restore and purge are still refused at the object gate for every principal, so `object_crud` still decides their verdict first; the `rls` layer under it now reports the update and delete classes. + - **What is unchanged.** Every other operation: `read`, `create`, `update`, `delete` and `export` are composed exactly as before. The RLS compiler's own default for an unlisted operation. The read path's row-level filter injection, which never sees a lifecycle verb. No export is added to the package. +- 18d9990: `security/explain` answers a transfer of a row the caller cannot read the way the transfer door does: as a record that does not exist. + + - **What was wrong.** On the write doors, a row the caller cannot read answers what a nonexistent id answers: the by-id update or delete the caller addresses gets `404 RECORD_NOT_FOUND`. A transfer's door is the `PATCH` that writes `owner_id`, an update, so a transfer of an unreadable row gets that 404 too. explain's matching rewrite (the missing-record shape, `record: { visible: false }` with no `decidedBy`) keyed on the verb as asked, `update` or `delete`, so it never ran for `transfer`. Where the caller's update policy admitted the hidden row, explain answered `visible: true`, decided by `rls`. Where the caller declared no write policy, it answered `visible: false` decided by `rls`, which is the shape of a 403 refusal, not of the door's 404. + - **What it does now.** The rewrite asks which operation the verb's door is through the same mapping the by-id write path reads (`transfer` and `restore` as update, `purge` as delete). So every write verb whose door is a by-id update or delete is asked the read question, and a transfer of a row the caller cannot read is `{ visible: false }` with no decider, beside the door's 404. + - **What is unchanged.** `update` and `delete` are answered exactly as before. `restore` and `purge` are still refused at the object gate for every principal, which answers first. A readable row keeps its verdict for every verb. The object-level `allowed` answers the object question and is not touched. No door changes, and no export is added to the package. +- 680a86b: `security/explain` answers a by-id write of a row the caller cannot read with the whole answer it gives for an id no row carries, layers included. + + Clause-②: no + + - The update, delete and transfer doors already answer such a row `404 RECORD_NOT_FOUND`, as they answer a nonexistent id, and explain's record verdict already took the missing-record shape. Every row-scoped layer's `record` block, and the tenant layer's verdict, now come from the same path a nonexistent id takes, so no layer names a status the door does not give. + - Nothing you author changes. The doors and the record verdict answer exactly as before, and an explanation of a row the caller can read is unchanged. +- b1d701b: `security/explain` decides whether a record-grained explanation may say a record exists by the read access of whoever asked for it, not by the explained principal's. + + Clause-②: no + + - **An asker who cannot read the record** gets, for every operation, exactly the answer an id no row carries gets: `record` is `{ recordId, visible: false }` with no `decidedBy`, and every layer's per-record attribution is the missing record's. + - An asker explaining another user (`manage_users`, or a delegated administrator's scope) cannot read a record its own by-id read finds no row for, or refuses (a declared 4xx, such as no read grant on the object). + - A principal explaining itself cannot read a record its own by-id read finds no row for. A refused read does not count, so the explanation shows what that principal's own doors answer: a principal that may edit a record it may not read is told what its edit does. + - Before, a principal explaining itself a `read` of a row its read finds no row for was told the row's verdict and why, and an administrator with no read grant on the object was told any row's story; the by-id write verbs already answered a self-explanation of such a row as missing. + - **An asker who can read the record** gets the explained principal's full record story. For a `read`, that is the principal's own verdict, decider included, so the `controlled_by_parent` read verdict on the master leg (not visible, decided by the `sharing` layer) is what such an asker is told. For a by-id `update`, `delete` or `transfer` of a row the principal cannot read, the verdict stays `visible: false`, the answer that principal's write gets, and now names the layer that decided the principal's read; each row-scoped layer's `record` carries that read's attribution, prefaced with the write's `404 RECORD_NOT_FOUND`. Before, such an asker was told the missing record's answer. + - A system asker reads every record. The batch form (`recordIds`) answers each id as the singular form does. + - `@objectstack/spec`: the `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say which ids an asker cannot read, by asker, and that those answer as missing. Description text only; no schema shape changes. + - Nothing you author changes, and no exported type or signature moves. Object-level explanations (no `recordId`) are unchanged. +- c11b758: `security/explain`'s record-grained `read` verdict for a `controlled_by_parent` record now equals the read door's answer on the master leg (ADR-0055). + + - **What changed.** The read door scopes such a record by its master as well as by its own row-level security. explain's read branch modelled the record's own row-level security only. It now asks the read door's own by-id read, with the context it is explaining, wherever every other leg of the read's row story admits the record. A record the door does not return is reported not visible, decided by the `sharing` layer. A record the door returns is reported exactly as before. A rejection of that read is reported fail-closed, as a dependency that throws already is. An `export` explanation, which streams the same find, answers the same way. + - **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. Every write verb. The answer for an id no row carries. The `ExplainEngineDeps` type: the `recordAbsentToCaller` dependency keeps its signature and is now asked for such a read too. +- 7856110: The RLS deny sentinel and the composed `ReadableIds` leaf are declared once in `@objectstack/spec/data`, and every place in `@objectstack/plugin-security` that handed out a copy of the sentinel now returns the object itself (ADR-0139 D2) + + Clause-②: yes (widening: new exported members of the published spec `data` entry) + + - **`RLS_DENY_FILTER` moves into the spec.** FROM `@objectstack/plugin-security`'s own frozen literal TO `RLS_DENY_FILTER` exported by `@objectstack/spec/data`. The value is unchanged: `{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`, an `id` equality no record satisfies, so a consumer that never heard of it still reads zero rows. `@objectstack/plugin-security` keeps exporting `RLS_DENY_FILTER` under the same name and type; it is now the spec's object, not a second copy. Nothing to change in code that imports it from either package. + - **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself, not a copy, for a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set and a resolution failure. It also returns the sentinel itself when the only layer that constrains is the tenant wall's deny, the RLS compiler's deny (every applicable policy dropped), or the deny of a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. Not every zero-row answer is the sentinel: a deny ANDed with another layer is returned as the composed `$and`, with the sentinel inside it, and the record-sharing layer's own deny, `{ id: '__deny_all__' }`, is returned unchanged when sharing is the only layer that constrains. ADR-0139 D2 step 2 has the engine's `ReadableIds` composition recognise the deny by `scope === RLS_DENY_FILTER` and answer it `$in: []`; any other zero-row answer composes like any scope and still matches no row. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. + - **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is neither `undefined` nor a non-null, non-array object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. + - **`isReadableIds` answers as before.** It answers `false` for a composed leaf, so it still means "an uncomposed leaf", and a driver refuses on it. The comparand-shape door's `admitReadableIds` admits the uncomposed leaf only: a composed leaf handed to the engine from outside is refused `INVALID_FILTER` / 400 as a non-list comparand. +- dfb3ba2: A `controlled_by_parent` master that is itself a detail decides who may write its details the way its own record update is decided: whoever can edit a quote can edit its lines + + Clause-②: no + + In a two-level master-detail chain (a quote line under a quote under an account, every level below the account `controlled_by_parent`), one person got two answers about the same quote. An organization member who could edit a quote they had not created was allowed to update the quote itself, but every insert, update and delete of its lines was refused `403 PERMISSION_DENIED` ("requires edit access to its master record … not editable by this user (row-level security)"). Only the quote's creator could change its lines. The quote's own update hands the platform's creator-only floor (`owner_only_writes`, positions `['org_member']`) to the master-detail write check, by the 2026-08-15 ruling; the check that a line write runs on the quote kept that floor. + + Now the line write asks the same question the quote's own update asks. By the maintainer's ruling (letter A), a line write under a middle master is admitted exactly when the same person may update that middle master directly: + + - **Admitted now:** an organization member who can update the quote (and the account above it) may insert, update and delete its lines, whoever created the quote. + - **Still refused:** the account above is not editable or not readable; the quote is not editable through its object permission; or an app-authored update rule on the quote refuses the person. + - **Also refused now:** the quote's creator, writing the lines of a quote that an app-authored update rule refuses them. The quote's own update already refused them. The line write had admitted them, because the floor and the app's rule each admitted a row on its own. + - **Unchanged:** a write made on behalf of another user keeps both principals' floors at every level. A master that governs its own rows, a single-level detail, the cycle guard and the depth bound are untouched. + + `security/explain` and the parent gates of `sys_attachment` and `sys_comment` ask the same check, so they give the same answers. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 501e177b5a3..750a675ae32 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-security", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-sharing/CHANGELOG.md b/packages/plugins/plugin-sharing/CHANGELOG.md index 0d257d82503..cf5bdd9addf 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,345 @@ # @objectstack/plugin-sharing +## 18.0.0-next.0 + +### Minor Changes + +- e7e6544: feat(plugin-sharing)!: a sharing rule reads a position's deactivation from the activation ledger, as the authorization resolver does (ADR-0131 D3, ADR-0126 §4) + + Clause-②: yes (narrowing: a position switched off in the activation ledger stops receiving sharing-rule shares in every organization; widening: a position whose `sys_position` row reads `active: false` receives them again) + + + + **BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. A position's deactivation now comes from `sys_metadata_activation` instead of the `sys_position` row's `active` column, so the shares a sharing rule materializes follow the same switch the authorization resolver reads. + + - A sharing rule whose recipient is a position materializes no share while that position is switched off. Switched off means a `sys_metadata_activation` row of type `position` naming it, with `active` false (a driver `0` included). The switch is deployment-wide, like the ledger, so it no longer depends on the rule's organization. + - The `sys_position` row's `active` is no longer read. A deactivation made in Setup writes that column, so until the upgrade ceremony converts it into a ledger row it stops neither the resolver nor sharing rules. + - A name with no ledger row keeps sharing, including a membership-derived position name. A composition that registers no ledger object issues no ledger read. A ledger read that fails still grants for the pass, as the row read did. +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. + +### Patch Changes + +- 1c563af: Setup → Record Shares opens on every share, not on the shares granted to the administrator. Before this, the entry named no view, and `sys_record_share` declared the caller-scoped "Granted to Me" view (`recipient_id = {current_user_id}`) first. + + Clause-②: no + + - `sys_record_share` now declares its unscoped "All" view (`all_shares`) first. "Granted to Me" and "Granted by Me" follow it, still as tabs. No view is added, removed or changed. + - The Setup entry `nav_record_shares` now names `all_shares` with `viewName`, so it does not depend on the declared order. + - The generated translation bundles follow the new view order. No translated text changed. + - ⛔ No schema, parse, export or accept-set change. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- b3a3634: docs: four contract docblocks now describe what the code decides. No type, schema or runtime change. + + Clause-②: no + + - **`ControlledByParentWriteDenialLeg` (`@objectstack/spec`).** The `master_chain` leg has three resolution refusals: no relation to derive from, a row that is not present, and an empty master reference. Each names the master whose own master the walk could not derive. That master is the record's own master, when it is itself `controlled_by_parent`, or any master above it. The docblock said only a master above the record's own master. The next sentence now says "past the record itself" where it said "above the first hop", so the two agree. The `record_not_found` bullet of `ControlledByParentWriteUnresolvedReason` had the same first-hop gap for a missing master row: it now says an absent row of a master that is itself `controlled_by_parent` is the `master_chain` leg, on the first hop or above it, and the legs judge a missing row only where the master governs its own rows. + - **`ISharingService.canEdit` (`@objectstack/spec`) and `SharingService.canEdit` (`@objectstack/plugin-sharing`).** The caller lists no longer name the `sys_attachment` and `sys_comment` parent gates. Those gates read `checkEdit` and the master-detail write check. The `ISharingService.canEdit` sentence on how they reach the service now names `checkEdit`. + + `ControlledByParentWriteDenialLeg`, `ControlledByParentWriteOutcome`, `canEdit` and `checkEdit` keep their declared shapes, and no verdict, outcome or refusal changes. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [4663801] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 5c08c67e3a3..573926313a1 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-sharing", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Record-level sharing for ObjectStack — sys_record_share + middleware that enforces sharingModel + ISharingService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-webhooks/CHANGELOG.md b/packages/plugins/plugin-webhooks/CHANGELOG.md index e7927df8dba..08c99b60d9c 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,256 @@ # @objectstack/plugin-webhooks +## 18.0.0-next.0 + +### Minor Changes + +- 0984817: feat(plugin-webhooks): the `webhooks` service serves the redeliver door from a web-standard `Request` (`IWebhookService.handleRedeliver`), and the redelivery veto no longer waits for realtime (#22756) + + Clause-②: yes + + - **What is new.** The plugin registers the `webhooks` service and implements its optional `IWebhookService.handleRedeliver(request)` member: the webhook redeliver door, `POST /api/v1/webhooks/redeliver`, from a `Request` to a `Response`, with no raw app, no Hono context and no `http.server`. A hosted tenant kernel owns no socket, so this is how the door reaches it; its one caller is the runtime HTTP dispatcher's `POST /webhooks/redeliver` domain, which lands separately. + - **One door, two faces.** The member and the self-hosted route run the same code: the same session check, the same active-organization tenant, the same replay through `MessagingService.redeliverHttp` and the same answer for every outcome. For the same request they answer the same status, the same `Content-Type: application/json` and the same bytes: `401 UNAUTHENTICATED`, `400 INVALID_REQUEST`, `400 MISSING_REQUIRED_FIELD`, `404 RESOURCE_NOT_FOUND`, `409 DELIVERY_NOT_ELIGIBLE`, `409 DELIVERY_NEVER_SENT`, `500 INTERNAL_ERROR`, or `200` with the row's id and new status. + - **The redelivery veto is installed wherever the door is.** The veto that refuses to replay a webhook delivery whose subscription is gone, or whose signing secret cannot be recovered, now needs only the data engine and the messaging service. It used to be installed only when realtime was present and `autoEnqueue` was not `false`, so on other kernels such a delivery was replayed. It is now refused with `409 DELIVERY_NOT_ELIGIBLE` there too. The `webhooks` service is registered only where the veto is installed. + - **The route is found under `http.server` too.** The plugin reads `http.server` first, then the `http-server` alias. A host that registers its server only as `http.server` now gets the self-hosted route; one that registers it under both names gets it once. + - **The self-hosted route is otherwise unchanged.** Where it was mounted before, it answers the same status, headers and bytes for the same request, and it is never mounted twice. + +### Patch Changes + +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- bb0db13: The webhook redeliver veto now refuses a delivery whose `sys_webhook` subscription is inactive. The `active` field declares that an inactive webhook is skipped by the dispatcher, and an operator's explicit replay is the dispatcher too, so the declaration now holds on both paths. + + Clause-②: no + + - FROM: the redeliver door (`POST /api/v1/webhooks/redeliver`, and the `webhooks` slot's `handleRedeliver`) replayed a finished delivery whose subscription had `active` switched off. The veto refused only a subscription that was gone, or whose signing secret could not be recovered. + - TO: the door answers `409 DELIVERY_NOT_ELIGIBLE` for that delivery and leaves the row as it is. The message names the webhook, says it is inactive, and names the remedy. The test is the dispatcher's own predicate, `active === true`, so a row whose `active` is null or absent is refused exactly as the dispatcher skips it. Every row the engine writes carries a boolean there: the field is required, with default `true`. + - Fix, for an operator who means to replay: re-activate the webhook, then redeliver. + + The gone-subscription and unrecoverable-secret refusals are unchanged, in text and in order. The inactive check runs after the gone check and before the secret check, so an inactive webhook whose key is also unrecoverable is refused as inactive, the first remedy to try. No code, status, field or export changes. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [0db5ad5] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [5d63c37] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/service-messaging@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index c120ffc98ee..5ca6c050396 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-webhooks", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.", "type": "module", diff --git a/packages/qa/dogfood/CHANGELOG.md b/packages/qa/dogfood/CHANGELOG.md index 5cfd28f3cbf..528bc55a954 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,375 @@ # @objectstack/dogfood +## 0.0.48-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [e7e6544] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [e078506] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [54f062b] +- Updated dependencies [9f4b652] +- Updated dependencies [965a1bf] +- Updated dependencies [eab7236] +- Updated dependencies [f93bf12] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [8a399b2] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [f0022c4] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [0db5ad5] +- Updated dependencies [93125ae] +- Updated dependencies [29678f2] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [56bf27a] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [9ad3d24] +- Updated dependencies [036bf1d] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4049cac] +- Updated dependencies [7ebbc01] +- Updated dependencies [0e9371f] +- Updated dependencies [0b7ba4d] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [3513ac7] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [345d3f3] +- Updated dependencies [f2626c7] +- Updated dependencies [e175479] +- Updated dependencies [59d993c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [fbb065f] +- Updated dependencies [27f0d83] +- Updated dependencies [a8f24b0] +- Updated dependencies [37c7114] +- Updated dependencies [b4ce5e2] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [e36ee53] +- Updated dependencies [1884948] +- Updated dependencies [3599fef] +- Updated dependencies [8a995b8] +- Updated dependencies [54c3ce1] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [3403be8] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [f8566a0] +- Updated dependencies [94379f4] +- Updated dependencies [dd986d8] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [f782f17] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [5495331] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d1d42ec] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [e194ab4] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [b98e984] +- Updated dependencies [76bc1e0] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [3d0eeef] +- Updated dependencies [83b8b80] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [a6f3a13] +- Updated dependencies [f53f14b] +- Updated dependencies [e8c6666] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [86da194] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [7399080] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [896a434] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [0984817] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [8bd0fcd] +- Updated dependencies [5fc57b3] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [f313fbc] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [bb0db13] +- Updated dependencies [efcbac7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [6a40a09] +- Updated dependencies [1959aac] +- Updated dependencies [4663801] +- Updated dependencies [8e4a230] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [de24373] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [5d63c37] +- Updated dependencies [dc93781] +- Updated dependencies [3d6392f] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/plugin-security@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/plugin-auth@18.0.0-next.0 + - @objectstack/verify@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/plugin-sharing@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/plugin-email@18.0.0-next.0 + - @objectstack/metadata@18.0.0-next.0 + - @objectstack/plugin-audit@18.0.0-next.0 + - @objectstack/service-storage@18.0.0-next.0 + - @objectstack/service-messaging@18.0.0-next.0 + - @objectstack/service-datasource@18.0.0-next.0 + - @objectstack/plugin-approvals@18.0.0-next.0 + - @objectstack/service-analytics@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + - @objectstack/mcp@18.0.0-next.0 + - @objectstack/plugin-webhooks@18.0.0-next.0 + - @objectstack/connector-mcp@18.0.0-next.0 + - @objectstack/example-crm@4.0.100-next.0 + - @objectstack/example-multi-package@0.0.7-next.0 + - @objectstack/example-showcase@0.3.22-next.0 + - @objectstack/connector-openapi@18.0.0-next.0 + - @objectstack/connector-rest@18.0.0-next.0 + - @objectstack/trigger-record-change@18.0.0-next.0 + - @objectstack/trigger-schedule@18.0.0-next.0 + - @objectstack/plugin-pinyin-search@18.0.0-next.0 + ## 0.0.47 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index aa767ed7377..a5e03ffb103 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.47", + "version": "0.0.48-next.0", "private": true, "license": "Apache-2.0", "description": "Dogfood regression gate — hand-written golden tests that boot real example apps through @objectstack/verify's in-process HTTP stack, pinning historical runtime regressions (#2018 timezone bucketing, #1994 cross-owner RLS, #2004 field fidelity) that static checks miss.", diff --git a/packages/qa/downstream-contract/CHANGELOG.md b/packages/qa/downstream-contract/CHANGELOG.md index 3373271fb8b..c842209a2ee 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,177 @@ # @objectstack/downstream-contract +## 0.0.46-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + ## 0.0.45 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index 8bfae3ba548..8d2cd2e08e9 100644 --- a/packages/qa/downstream-contract/package.json +++ b/packages/qa/downstream-contract/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/downstream-contract", - "version": "0.0.45", + "version": "0.0.46-next.0", "description": "Frozen third-party consumer fixture — a backward-compatibility gate for @objectstack/spec. Authored the way an external project on a published release authors metadata; if a spec change breaks it, that change is breaking (#2035).", "license": "Apache-2.0", "private": true, diff --git a/packages/qa/http-conformance/CHANGELOG.md b/packages/qa/http-conformance/CHANGELOG.md index f0cb683aa87..e24be647dde 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,38 @@ # @objectstack/http-conformance +## 0.1.8-next.0 + +### Patch Changes + +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [41d0d40] +- Updated dependencies [4578c56] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [3089848] +- Updated dependencies [db9cf80] +- Updated dependencies [35ef501] +- Updated dependencies [874a38d] +- Updated dependencies [91f46d0] +- Updated dependencies [d7b26df] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [b7cd1af] +- Updated dependencies [51f36e7] +- Updated dependencies [1e62f71] +- Updated dependencies [e1deaef] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0-next.0 + ## 0.1.7 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 668709b1788..c1879a49a34 100644 --- a/packages/qa/http-conformance/package.json +++ b/packages/qa/http-conformance/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/http-conformance", - "version": "0.1.7", + "version": "0.1.8-next.0", "private": true, "license": "Apache-2.0", "description": "HTTP transport-port conformance gate (ADR-0076 D11/OQ#10, #2462) — a zero-dependency node:http reference implementation of IHttpServer plus a cross-adapter suite that boots the dispatcher bridge and REST generator on it AND on plugin-hono-server, pinning that the port stays free of framework-isms. Not published; validation instrument, not a product server.", diff --git a/packages/rest/CHANGELOG.md b/packages/rest/CHANGELOG.md index b83086a8a1e..bc41e6fb2ee 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,628 @@ # @objectstack/rest +## 18.0.0-next.0 + +### Minor Changes + +- 60bcfe7: feat(plugin-security,rest,spec)!: the permission-set "Discard Overlay" action, its REST route and its contract member are retired; no door creates the overlay it discarded, and a database holding one is refused at cold boot (ADR-0131 cutover, ADR-0048) + + Clause-②: no (narrowing) + + + + **BREAKING** — a narrowing of the public surface: one HTTP route, one Setup action and one optional contract member are removed. Shipped as `minor` under the launch-window convention for breaking changes. + + The action deleted the environment-wide `sys_metadata` row that shadowed a package-declared permission set, then re-projected the set's `sys_permission_set` row. Neither half has anything left to do: + + - No door creates such an overlay. The metadata door refuses a save over a packaged permission set on every topology, whatever `OS_METADATA_WRITABLE` says. The data door refuses an edit of one (clone it instead). The data API cannot write `sys_metadata`. A package that declares a name the environment holds is refused at install. + - An overlay stored before those refusals makes the cold boot refuse the deployment with `422 NAMESPACE_CONFLICT`, so a server that could answer the route never holds one. + - The authorization resolver reads the security catalog, not `sys_permission_set` rows, so the re-projected row granted nothing. + + ### What was removed + + | removed | what to use instead | + | --- | --- | + | `POST /api/v1/security/permission-sets/:id/discard-overlay` | `os migrate security-catalog-overlays` with the flags and environment the deployment boots with lists every overlay row with no server running; `--apply` deletes them (a history tombstone per row). | + | the **Discard Overlay** action on `sys_permission_set` in Setup | the same offline step. To keep a customization of a packaged set, clone it under a new name (the **Clone** action) and assign the clone. | + | `ISecurityService.discardPermissionSetOverlay` and `PermissionSetOverlayDiscardResult` (`@objectstack/spec/contracts`) | nothing: delete the call and the import. `tsc` names each one. | + | `discardPermissionSetOverlay`, `PermissionSetNotFoundError`, `PermissionSetOverlayStateError` and the `PermissionSetOverlayDiscardDeps` / `PermissionSetOverlayDiscardResult` types (`@objectstack/plugin-security`) | nothing: delete the import. | + + **The one-line fix:** stop calling the route or the member, and clear any overlay offline with `os migrate security-catalog-overlays --apply` before the first boot on this release. + + `ERROR_CODE_LEDGER['@objectstack/plugin-security']` no longer lists `INVALID_STATE` or `NOT_FOUND`: the removed action was their only producer in this package. Both codes stay registered under `@objectstack/rest`, and the union of codes is unchanged. + + The permission-set drift diagnostic no longer names the action in its `overlay_shadow` detail, and the boot reading of shadowed packaged sets names the offline step instead. +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 8f2e808: feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data + + Clause-②: yes (narrowing) + + **BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. + + ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. + + - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. + - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. + - **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. + - **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. + + +- 3aa0c7b: `metaSaveRequestOptions` is exported: the one mapping from a `PUT /meta/:type/:name` request's `If-Match`, `If-None-Match` and `?mode=draft` to `saveMetaItem`'s `parentVersion` and `mode` + + Clause-②: yes (widening) + + - `RestServer`'s `PUT /api/v1/meta/:type/:name` door now reads its precondition and its lifecycle through this function. The runtime dispatcher's `/meta` door, the one behind the `@objectstack/hono` catch-all, reads them through it too, so the two doors cannot answer the same save differently. It accepts a `Headers`-like object (`get`) or a plain header record, plus the query. It answers `{ ok: true, request }`, where `request` holds only the members the caller asked for, or `{ ok: false, message }`, the sentence of a `400`. + - Unchanged: `RestServer`'s answers. The token still has ETag quotes stripped, `If-None-Match: *` still pins "no row of this lifecycle", and the two `400` refusals keep their wording. `?mode=draft`, matched case-insensitively, still stages a draft. Any other `mode` value is still an active save. +- fbcbcf1: `metaItemPackageBinding` is exported, and every `/meta` door that reads `?package=` reads it through that function, so `?package=all` names no package on the layered read, the list, the book tree and the diagnostics sweep + + Clause-②: yes (widening) + + - `metaItemPackageBinding(raw)` answers the package a request's `?package=` names, or `undefined` for `all` (the metadata list's "show everything" scope), the empty value or a non-string. The item read, the save and the publish already read `?package=` through it. The runtime dispatcher's `/meta` domain now reads it through the same function. + - `GET /meta/:type/:name/layers?package=all` answered `404` for an item stored in a package, and now serves the layers that the read without `?package=` serves. The Studio editor sends this read when it is opened from the list's "show everything" scope. + - `GET /meta/:type?package=all` answered `[]`, and now lists what `GET /meta/:type` lists: the items of every package and the env-local ones. + - `GET /meta/book/:name/tree?package=all` served the empty implicit book of a package called `all`, and now resolves the declared book as the read without `?package=` does. + - `GET /meta/diagnostics?package=all` swept nothing, and now sweeps what the plain sweep sweeps. + - Unchanged: a real package id still scopes each of these reads. The item read's cache bypass still reads the raw parameter, so `?package=all` keeps the uncached read and its served `version`. +- f4069c5: fix(metadata): the object read's `sortability` follows the caller's field permission for every caller class + + Clause-②: yes (widening) + + - **What was wrong.** ADR-0106 D4 serves a caller holding `manage_metadata`, `studio.access` or `setup.access` (a platform admin, and an organization admin through `setup.access`) the object schema UNMASKED, because Studio and Setup authoring need the whole definition. The `sortability` projection served beside it on `GET /meta/object/:name` (and the runtime dispatcher's `/metadata/object/:name`) was derived from that unmasked document, so it marked a field `sortable: true` that the same caller's field permission marks unreadable, while the data route refuses a sort or filter on that field with `403 PERMISSION_DENIED`. A client building its sort picker from the projection offered a click the server refuses. + - **What changes.** `sortability` is now derived from the caller's runtime view: the served document for every caller the mask projects (unchanged), and for a D4-exempt caller the fields their own field permission reads. The definition (`item.fields`) is unchanged for every class: whole for an exempt caller, masked for everyone else. The data route and `/auth/me/permissions` are unchanged. + - **Caching.** On the cached `GET /meta/object/:name` branch an exempt caller's ETag folds a fingerprint of the fields withheld from their projection, and the conditional request is judged after it is folded. An exempt caller whose permission withholds nothing keeps the byte-identical ETag. An exempt caller and a projected caller denied the same field never share a validator. + - **Failure posture.** The exempt caller's field permission is asked lazily, only by the item read that serves `sortability`, and never turns their definition read into a fault: if the security service throws or cannot answer, `sortability` is derived from the served document as before, with one `warn`. + - **New in `@objectstack/metadata-core`.** `resolveObjectSchemaRuntimeView(document, posture)` returns `{ document, withheld, fingerprint }`, the view the runtime projections beside an object schema are derived from. The types `ObjectSchemaRuntimeView` and `ObjectSchemaRuntimeFieldPosture` are exported with it, and the `passthrough` member of `ObjectSchemaMaskPosture` gains an optional `runtime` (set on an `exempt` posture for a capability-exempt caller in a deployment with a `security` service). + - **New in `@objectstack/rest`.** `translateMetaEnvelope` takes an optional fifth argument, `runtimeDocument` (default: the served document), and `MetaItemAnswerSources.translateEnvelope` an optional third. Nothing is removed, renamed or narrowed. +- 166a94f: fix(rest)!: the API-description endpoints refuse an anonymous caller + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `RestServer.registerOpenApiEndpoints` mounts the API-description endpoints: the document and its viewer page. Until now they served a caller with no session, while the data and metadata routes beside them refused one. ADR-0056 D2 denies anonymous callers by default. + + - **Refused now:** a caller with no session, with `401` and code `UNAUTHENTICATED` — the same body the data routes answer. Both handlers check before any other work, so the refusal does not say whether a document is bundled. + - **Also refused:** a session held by an auth policy (an expired password, enforced MFA) gets that policy's `403`, as on every other protected route. + - **Unchanged:** a signed-in caller is served the same document and the same viewer page as before. The viewer page loads the document from the browser with the browser's session, so a signed-in browser sees the viewer work as before. + + What changes for you. If you published the API description to readers without an account, have them sign in first, or publish a static copy of the document instead. +- 7806a14: fix(rest)!: an anonymous public-form submit answers the created record's id, and nothing the insert stored + + Clause-②: no (narrowing) + + + + **BREAKING** (a response narrowing): this ships as `minor` under the launch-window convention for breaking changes. + + `POST /api/v1/forms/:slug/submit` used to answer `201` with the protocol's whole create answer, `{ object, id, record, droppedFields? }`. The `record` was the row as stored after the insert pipeline. So the anonymous caller was shown every field it never sent: a `defaultValue`, a field a `beforeInsert` or `afterInsert` hook stamped, and a value a hook running elevated (`runAs: 'system'`) derived from existing records the caller's grant may never read. The form's field whitelist filtered what the caller could write. Nothing filtered what it was then shown. + + The answer is now the created id alone: + + ```json + { "id": "r7p8cUZoBJbFWudt" } + ``` + + - **Still `201`**, as a bare JSON object with no envelope. The created id stays at the top-level `id`, where the console's public form page reads it, so its confirmation and `redirect` behaviors keep working. + - **What the submitter typed is not echoed back either.** The caller already holds it. On the console's `redirect` behavior, a `{{record.field_name}}` token over a submitted field still resolves from the submitted values, and `{{record.id}}` from the answer. A token over a field only the server fills in now resolves empty. That value is exactly what this change stops serving. + - **The write is unchanged**: the same whitelist, the same server-managed anchors stripped, the same grant, the same hooks. Only the answer shrank. + + **If your host read the record off this answer**, read it through an authenticated read instead: `GET /api/v1/data/:object/:id` with the id from the answer, as a principal allowed to see that record. +- 5081090: feat(rest): the `/meta` read gate enforces a list view's and a dashboard's `requiredPermissions` (#22639) + + Clause-②: yes (widening) + + - **What the server now does.** `requiredPermissions` on a list view and on a dashboard is a list of capabilities a user must ALL hold, the same key and meaning as on an app, a navigation item and an action. The `/meta` read gate applies it through the one predicate the app arm uses (`holdsRequiredPermissions`), on both transports (`RestServer` and the runtime dispatcher's `/meta` domain): + - `GET /api/v1/meta/view` and `GET /api/v1/meta/dashboard` list only what the caller may open. A view the caller does not hold is left out, so the console's view switcher (`?object=`) lists only the views the caller may open. A view container is listed minus the `list` / `listViews` entries the caller does not hold. + - `GET /api/v1/meta/view/:name` and `GET /api/v1/meta/dashboard/:name` refuse a view or a dashboard the caller does not hold with `403 PERMISSION_DENIED`, the same refusal and envelope an app the caller may not open gets. Every alternate door of the read refuses it too (`/published`, `/layers`, `?layers=`, `?state=draft`, `/diff`, `/history`, `/audit`), to an author as to anyone. + - `GET /api/v1/meta/object` and `/meta/object/:name` serve an object without the `listViews` entries the caller does not hold. A caller who may edit the object (the ADR-0106 D4 exemption the field mask already honours) reads every list view, so a save of what they loaded keeps them. + - A view container is served whole to a caller who may write it on the stored-version doors (`/layers`, `?layers=`, `/diff`, `?state=draft`), as an app is, so a designer's save keeps the entries withheld from its author elsewhere. + - **Caching.** The plain read's cached arm runs the same gate for `view` and `object`. Its `ETag` folds in what the gate withheld, and the conditional request is judged against that validator, so a caller who no longer holds a capability is never answered a `304` for the body that carried the gated view. When nothing is withheld the `ETag` is the same as before. + - **Type surface.** `MetaItemReadRefusal` gains the `audience-permission` reason (`403 PERMISSION_DENIED`). A consumer that switches on `reason` writes it the way it writes `app-permission`. + - **Nothing to migrate.** Only a list view or a dashboard that sets `requiredPermissions` is affected. The key was not accepted on either before the release that declared it. +- 038d4c9: feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) + + Clause-②: yes + + **What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: + + - `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). + - `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. + - `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. + - `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. + - `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. + + With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. + + **The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). + + **Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: + + - `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. + - `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. + - `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. + - `POST /meta/:type/:name/publish`: `package`, `member`. + - `POST /meta/:type/:name/rollback`: `toVersion`, `member`. + + A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. + + **What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): + + - A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. + - Resetting one language keeps the other languages' customizations served at once, not only after the next restart. + - Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. + - In a cluster, a save, reset or revert of one language converges the other servers on that language. + + **For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. + + - `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. + - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. + - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. + - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. + - A type whose items have no languages ignores the argument. + - `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. + - The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. +- c4b84ad: fix(rest)!: `PUT` and the five by-name `/meta` sub-doors declare closed query sets, so `?member=` is refused with a located `400 VALIDATION_ERROR`, never dropped + + Six by-name `/meta` doors now refuse a query parameter they do not read, with a located `400 VALIDATION_ERROR`, instead of dropping it. Each read the names it knew and ignored the rest, so a request carrying the bundle member selector `?member=` — which `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` accept — was answered for the canonical member with a `200`, and a save wrote the body's member. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What each door accepts, and what to do when it refuses: + + - `PUT /meta/:type/:name` accepts `force`, `package` and `mode`. A save is addressed by its body: to save one member of a bundled type, put that member's own discriminator in the body (an `email_template`'s `locale`) and drop `?member=`. + - `GET /meta/:type/:name/layers` accepts `package`. It shows the canonical member's layers. Read one member with `GET /meta/:type/:name?member=…`. + - `GET /meta/:type/:name/audit` accepts `limit`. The trail is the item's, with every member's attempts in it. Drop `?member=`. + - `GET /meta/:type/:name/diff` accepts `from`, `to`, `fromVersion` and `toVersion`. It compares the canonical member's versions. Read one member's lineage with `GET /meta/:type/:name/history?member=…`. + - `GET /meta/:type/:name/published` and `GET /meta/:type/:name/references` accept no query parameter. Send none. Their refusal ends "This endpoint takes no query parameters." where the other doors list the names they accept. + + The refusal body is the nested envelope the five member doors already answer for a name they do not know, `{ error: { code: 'VALIDATION_ERROR', message } }`, and the message names the refused parameter. A request that sends only names the door reads is answered exactly as before. The `@objectstack/client` SDK and the Studio metadata client send only accepted names. + +### Patch Changes + +- d112087: docs: the suggested-binding acceptance is deployment-level and takes effect + + Clause-②: no + + Text only. These are the published sentences that describe what confirming a package's `isDefault` suggestion does. They now match the behaviour `@objectstack/plugin-security` ships in this release. + + - `@objectstack/spec`: `ISecurityService.listAudienceBindingSuggestions`, `confirmAudienceBindingSuggestion` and `dismissAudienceBindingSuggestion` keep "writes the binding" for the confirm. The text now also says what that means: a `manage_metadata` holder accepts once for the deployment, `everyone`'s definition is derived again, and a dismiss on an accepted suggestion revokes it. The package-level `isDefault` note keeps "an admin confirms in Setup" and adds who that admin is. + - `@objectstack/client`: the `security.suggestedBindings.confirm` and `.dismiss` TSDoc. + - `@objectstack/rest`: the route summaries of the confirm and dismiss routes. Paths and envelopes are unchanged. + - `@objectstack/cli`: the ownership-inventory citation for `sys_audience_binding_suggestion` now says the record is deployment-level and owned by the Default Organization. +- 1fb274e: `GET /api/v1/meta/datasource/:name/published` serves a code-defined datasource's code definition while a stored row under its name still exists + + Clause-②: no + + - For a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`), the published door now serves the layered read's `effective` layer, which is the code definition. Before this change it served the leftover stored `sys_metadata` row, with that row's label, `origin` and connection settings, while `GET /api/v1/meta/datasource/:name`, the `/meta/datasource` list and `/layers` all served the code definition. The door now asks the protocol's `declinesStoredRow`, the one decision those reads make, in place of `isShippedFlowName`. A shipped flow name is answered as before. + - Unchanged: a runtime datasource's stored row is still what the door serves, and so is every stored row of every other type. A protocol that does not provide `declinesStoredRow` still gets the stored row. The row itself stays at rest, `/layers` still reports it in `overlay`, and `DELETE /api/v1/meta/datasource/:name` still removes it as the repair. +- 3b49318: fix(meta): a second unpinned draft save of a package-owned item is no longer refused 409, the item read serves that draft's `version`, and a package-less draft save keeps a package-owned datasource's stored credential + + Clause-②: yes (widening) + + - **The second draft save is accepted.** Save an item into a package (`PUT /api/v1/meta/:type/:name?package=`), then save a draft of it without `?package=` twice. The repository stores that draft in the item's package (the draft inheritance that keeps a pending change in the package it edits). The save door took its expected parent from the package-unbound draft row instead. It found none, so the second save, which sent no `If-Match`, was refused `409 METADATA_CONFLICT` ("Expected parent null but current is hmac-sha256:…"). ADR-0008 makes an unpinned save last-writer-wins, and it is accepted again. + - **One head row per write address.** `SysMetadataRepository.headAt(ref, { state, packageId })` answers `{ head, packageId }`: the row a `put` at that address upserts (`null` for a create), and the package binding that put writes under. Both come from the same resolution `put`'s optimistic lock runs. That resolution includes the draft inheritance and the adoption of a draft stored without a package. The save door's expected parent and the item read's `version` both come from it, so the token `GET /meta/:type/:name?state=draft` serves is the token a draft save at the same address accepts. Before this, that read served `version: null` while the inherited draft existed. + - **`?package=all` on the item read.** `GET /meta/:type/:name?package=all` now reads `all` as the save and publish doors read it: as naming no package. Before, the read forwarded the literal `all`. Its `version` was resolved at a package no save writes, so it served `null` beside a row the save then judged. Its `?state=draft` read answered `404 NO_DRAFT` for a draft stored in the item's package. The read now serves the item as a read with no `?package=` serves it, and `?package=all` still takes the uncached arm, so it keeps serving `version`. + - **The credential carry-forward reads the same row.** A save of a type whose read withholds a credential (`datasource`; `flow` with the automation plugin) carries the stored credential back into a body that returns as it was served. It compared that body against the row at the key the caller named. A package-less draft save of a package-owned item therefore found neither the inherited draft nor the package's active row, and compared against the code layer. The stored credential was persisted away on the first such save, measured on the real route with a datasource whose `config.url` holds one. Both reads now go through `headAt`: the draft the save overwrites, or with no draft yet the active row in the package the draft is stamped into. An item with no package binding is unchanged. + - **Descriptions only, in `@objectstack/spec`.** `SaveMetaItemRequestSchema.packageId` now states the draft inheritance, where it said "absent = env-local overlay". The `POST /meta/:type/:name/publish` route description spells the conflict code `METADATA_CONFLICT`, as the wire sends it. + - A stale `If-Match` is still refused `409`, and `If-None-Match: *` is still refused while a row exists. An item with no package binding is unchanged. No spec key, request field or response field is added or removed. + - The only change to the public surface is one added method on the exported `SysMetadataRepository`: `headAt`. No existing signature, schema or accept set changes. +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- 4b7c630: `GET /api/v1/meta/view/`, the by-name read of a `defineView` container, answers `200` on an artifact boot, and translates the views inside the container on every boot + + Clause-②: no + + The registry files a `defineView({ object, list, listViews, … })` container under the object it binds, and serves it on the by-name read of that key. The read handed the container to the translator for one view, which reads the view's own `name`. + + - **Artifact boot** (`os build`, then `os serve` of the artifact): a compiled container carries no `name`, so the read answered `500 INTERNAL_ERROR` and the log read `Cannot read properties of undefined (reading 'startsWith')`. It now answers `200`. + - **Config boot** (`os serve objectstack.config.ts`): its registrar stamps `name: ` onto the container, so the read answered `200`. But it served a `label` equal to the object name, which no author wrote, and it left every view inside the container in the authored language. That label is no longer served. + - **What is translated now, on both boots.** Each view inside the container (`list`, `form`, each `listViews` and `formViews` entry) carries the same `label`, `description` and bulk-action copy as the by-name read of the view it expands to (`.`). That copy is keyed by the same `objects.._views.` entry and judged against the same packaged base, so an organization's explicit override still beats the catalog. The container's own `label` and `description` are read from `objects.._views.`, and are left unchanged when the catalog has no entry there. + - **What stays the same.** The by-name reads of the expanded views and the `GET /api/v1/meta/view` list are unchanged. The list still never includes a container. + - **Packaging.** `@objectstack/rest` now lists `@objectstack/metadata` under `dependencies` (it was a dev dependency). The read imports `deriveViewContainerObject` from `@objectstack/metadata/view-container`, the registry's own derivation of the object a container binds. +- 9859e4e: fix(rest): twenty more REST write routes answer a sandboxed hook's refusal in the hook's own words (#22719) + + Clause-②: no + + **What was wrong.** When a sandboxed hook refuses a write (`throw new Error('Locked rows cannot be changed here.')`), the error that reaches the route carries the QuickJS debug wrapper on `.message` (`hook 'guard' threw: Error: …`) and the sentence on `.innerMessage`. Twenty write routes built their error answer by hand from `.message`, so they shipped the wrapper, while the other write doors answer the sentence. Sixteen of them also answered the refusal as a server fault (`500`). + + **What changes on the wire, for a sandboxed hook's refusal.** + + | routes | before | after | + | --- | --- | --- | + | `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested `{ success: false, error: { code, message } }`, the sentence | + | `POST /security/suggested-bindings/:id/confirm` and `/dismiss` | `500 SUGGESTION_*_FAILED` / `500 INTERNAL`, the wrapper | `400 VALIDATION_ERROR`, same nested `{ error: { code, message } }` shape, the sentence | + | the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested, the sentence | + | `POST /packages/publish` | `500 INTERNAL_ERROR`, the wrapper | `400 VALIDATION_ERROR`, the sentence | + | `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper | the same status and code, the sentence | + + A refusal that declares its own `code` keeps it (an unregistered spelling rides `declaredCode`), a refusal that declares its own `status` is answered at that status, and a `userMessage` the hook set rides the answer, as on every other write door. + + **Also moved, on the sharing-rule, suggested-binding and approval routes.** These routes now ask the same classification as the `/data` door before their `500` arm. So a refusal from the service that declares its own `status` (or `statusCode`) and `code`, and that no route-specific arm reads, is answered at that status, in the nested envelope, instead of `500` with the route's own code. The external-datasource routes keep answering every refusal at `400` with their own code, and `POST /packages/publish` already answered a declared status. + + **Unchanged.** A plain fault keeps each route's own `500` answer and code. The route-specific refusals (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RULE_NOT_FOUND`, `SHARING_NOT_ENABLED`, the approval prefixes, the typed security errors) answer exactly as before. + + **Upgrading.** A client that reads both envelopes, as `@objectstack/client` does, needs no change. A client of the sharing-rule or approval routes that reads a string `body.error` reads `body.error.message` for these answers. +- d952358: fix(rest): the hand-built REST write routes answer a sandboxed hook that crashed, or that declared a server error, as the data routes do (#22764) + + Clause-②: no + + **What was wrong.** Two kinds of sandboxed hook failure reach a route as faults, not refusals. One is a hook body that crashed (`ctx.input.title.trim()` on a number): the route receives the QuickJS debug wrapper on `.message`, for example `hook 'guard' threw: TypeError: boom`. The other is a hook that declared a server-band status (`status: 503`). The `/data` routes answer both as faults and withhold the text: a crash answers `500 INTERNAL_ERROR` with `Internal server error`, and a declared `503` answers `503` with its own code and `Internal server error`. The routes below build their error answer by hand, and they sent the wrapper and the native error text to the caller. The four external-datasource routes also answered a crash as `400`, the caller's mistake. + + **What changes on the wire, for those two failures only.** + + | routes | before | after | + | --- | --- | --- | + | `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | the `/data` answer, nested `{ success: false, error: { code, message } }` | + | `POST /security/suggested-bindings/:id/confirm` and `/dismiss` | `500 SUGGESTION_*_FAILED`, the wrapper | the `/data` answer, `{ error: { code, message } }` | + | the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | the `/data` answer, nested | + | `POST /packages/publish` | a crash: `500 INTERNAL_ERROR` with the wrapper; a declared `503`: `503` with the hook's sentence | the `/data` answer | + | `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper for a crash, the sentence for a declared `503` | the `/data` answer, nested | + | `POST /security/explain` | `500 EXPLAIN_FAILED`, the wrapper | the `/data` answer, nested | + | `POST /analytics/dataset/query`, a crash only (a declared 5xx already answered as `/data` does) | `500 ANALYTICS_QUERY_FAILED`, the wrapper | the `/data` answer, in this route's flat `{ code, error }` | + + "The `/data` answer" is `500 INTERNAL_ERROR` with `Internal server error` for a crash, whatever status it declared. For a refusal that declared a 5xx, it is that status, the declared code and `Internal server error`. The prose is released only when the hook marked the declaration a refusal, and a `userMessage` the hook set rides the answer. The withheld text goes to the server log. The answer is computed by the same classification the `/data` routes use, so it cannot drift from theirs. + + The same arms serve the read routes of the sharing-rule, suggested-binding and external-datasource families (`GET /sharing/rules`, `GET /sharing/rules/:idOrName`, `GET /security/suggested-bindings`, `GET /datasources/:name/external/tables`), so they answer these two failures the same way. + + **Unchanged.** A hook's refusal keeps its sentence and status on every route. A refusal that declared a 4xx on the external-datasource routes still answers `400` with that family's code. A fault that is not from a sandboxed hook keeps each route's own answer and code. A service-declared `503` on `POST /packages/publish` is one such fault. The per-record share routes already withheld the text at their own `500 SHARE_*_FAILED` and are unchanged. + + **Upgrading.** Nothing to change. A client that branched on `RULE_*_FAILED`, `APPROVAL_*_FAILED`, `SUGGESTION_*_FAILED`, `EXPLAIN_FAILED`, `ANALYTICS_QUERY_FAILED` or a `400` from an external-datasource route to detect a broken hook reads `INTERNAL_ERROR` (or the declared 5xx code) instead. Those codes still answer every other fault. +- 3fdb92d: A sandboxed hook or action that fails on the sandbox's own fault (a capability it was not granted, a `ctx.api` method that is missing or unavailable, a CPU budget or wall-clock ceiling, an install or marshal failure) is now answered by the hand-built write routes the way `POST /api/v1/data/:object` answers it: a withheld `500` with `Internal server error`. The runner's diagnostic text, which names the hook, the object and the capability, goes to the server log only. + + Clause-②: no + + Before, 23 write routes relayed that text to the caller: `POST /analytics/dataset/query`, `POST /security/explain`, `POST` and `DELETE /data/:object/:id/shares`, the three sharing-rule writes, the two suggested-binding writes, the nine approval-request writes, `POST /packages/publish` and the four `POST /datasources/:name/external/*` routes. Most of them answered `500` with their own fault code, and the four external routes answered `400`. Twenty-one of them now answer `500 INTERNAL_ERROR`, the answer each already gave a sandboxed body that crashed. The record-share routes (and the share list, `GET /data/:object/:id/shares`) keep their own `500` and `SHARE_*_FAILED` code, as they do for a crash, and withhold the sentence the same way. + + A refusal a sandboxed body throws keeps its own sentence and status on every route. No key, default, enum member or export moves. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- 0511341: fix(rest): `GET /api/v1/openapi.json` documents a valid declared endpoint that the served read decorated, and stops logging it as a declaration to fix (#23083) + + Clause-②: no + + **What was wrong.** On a composition with no endpoint matcher (no `metadata` service with `matchEndpoint`, for example `@objectstack/verify`'s `bootStack`), the document is built from the `api` items `getMetaItems` serves. That read adds `_diagnostics` to every item, and `ApiEndpointSchema` is closed, so the document's own parse refused every valid declaration by that key. The endpoints were left out of the document. For each one, the server logged at `error` that the declaration did not satisfy `ApiEndpointSchema` and should be fixed. Measured on the showcase booted through `bootStack`: 0 of its 2 declared endpoints in the document, and 2 such `error` lines. + + **What changes.** + + - The document's parse first removes the read-time decorations (`METADATA_READ_DECORATIONS` from `@objectstack/spec/kernel`: `_diagnostics`, `_draft`). It is the same strip the seed door applies. On that boot the document now lists both endpoints, and no line about them is logged. + - The ADR-0010 envelope keys (`_packageId`, `_provenance`, …) are allowlisted by the schema and parse as before. Any other unknown key is still refused. + - A declaration that really is invalid is still left out of the document and logged at `error`. The log line no longer says that the endpoint matcher excludes the item too. The document's parse does not know that: the matcher judges its own stored copy after removing the storage envelope, and where no matcher is reachable there is none to ask. + + **Upgrading.** Nothing to change. `ApiEndpointSchema` is unchanged. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [84c4fff] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/metadata@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/service-package@18.0.0-next.0 + - @objectstack/observability@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index e923167a324..26d36689e5b 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "ObjectStack REST API Server - automatic REST endpoint generation from protocol", "type": "module", diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 7a7ca6cd088..66ec567bc53 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,975 @@ # @objectstack/runtime +## 18.0.0-next.0 + +### Minor Changes + +- 67b669e: The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. + - **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. + - **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. + - **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. +- 45e206e: feat(runtime): `POST /api/v1/security/permission-sets/:name/clone` — clone a permission set by name from the security catalog (ADR-0126 §7.1) + + Clause-②: yes (widening) + + - **What is new.** A route that copies a permission set's whole definition under a new machine name. The body is `{ name, label }`: both are required, no other key is accepted, and `name` must be snake_case. The answer is `{ permissionSet, notice }`: the copy that was saved, and a sentence naming what was not copied. + - **Where it reads and writes.** The source is read through the security catalog (the engine registry first, then the metadata service), the same read the authorization resolver uses. It never reads a `sys_permission_set` row, so it works on a database with none. The copy is saved through the metadata protocol's `saveMetaItem` as a `permission` item authored in this environment, with no organization and no package. The save is create-only, so a row written under the name meanwhile is refused, not overwritten. + - **What is copied, and what is not.** Every facet of the definition is copied. One server-declared list (`PERMISSION_SET_CLONE_DROPPED_KEYS`) is removed: the protection envelope and read decorations (derived from the spec), `packageId`, `managedBy`, `protection`, and the two privilege decisions `adminScope` and `isDefault`. The copy records nothing about its source. + - **Who may call it.** A holder of `manage_metadata`, through the same verdict the metadata save door asks (`metaWriteCapabilityVerdict`); `isSystem` bypasses. Anyone else gets `403 PERMISSION_DENIED`, and an anonymous caller `401`. + - **Refusals.** `400 VALIDATION_FAILED` for a malformed body or a name failing the name rule; `404` for an unknown source; `409 RESOURCE_CONFLICT` for a name the catalog already holds, including the source's own; `503 SERVICE_UNAVAILABLE` when no catalog is bound or it cannot be read; `501` when no metadata protocol is composed. + - **Nothing to migrate.** No existing route changes. The Setup action `clone_permission_set` keeps working as before. +- cd3d391: feat(runtime): `POST /api/v1/security/_activation/:type/:name` switches a position or a permission set on or off for the deployment (ADR-0126 §3 regime C, as ADR-0131 D6 amends it) + + Clause-②: yes + + The authorization resolver reads whether a position or a permission set is switched off from the activation ledger, `sys_metadata_activation` (types `position` and `permission`), and never from the catalog row's `active` column (ADR-0131 D3). Until now nothing wrote that ledger for these two types, so a deactivation switched nothing off. This route is the write half: + + - **Route:** `POST /api/v1/security/_activation/:type/:name`, with `:type` either `position` or `permission`. It is also mounted under `/api/v1/environments/:environmentId` when project scoping is on, like the action door. + - **Body:** `{ enabled?: boolean }`. `enabled` defaults to `true`. Unknown keys and a non-boolean `enabled` are refused `400 VALIDATION_FAILED`, the same body reader `POST /actions/_activation/:object/:action` uses. + - **Effect:** one `sys_metadata_activation` row through the engine, carrying the definition's package. Re-enabling updates that row. Switching a position off stops every grant through it for every holder in the deployment. Switching a permission set off stops it granting by every path. No definition and no catalog row is written. + - **Authority:** the same as the flow and action activation doors. The caller needs `manage_metadata`. Under a `group` or `isolated` tenancy posture the caller must also be the platform operator (ADR-0126 §5). Refusals are `403 PERMISSION_DENIED`, and nothing is written. + - **Refusals:** the audience anchors `position/everyone` and `position/guest` are refused `400` in either direction, before anything is read or written, because one row would switch the authenticated or anonymous baseline off for every principal. A name that does not resolve in the security catalog is `404`. No catalog bound, or a catalog that cannot be read, is `503 SERVICE_UNAVAILABLE`. A composition without the ledger object is `501`. Switching `admin_full_access` off is refused `403 PERMISSION_DENIED` by the last-admin guard's ledger hook. + + The route is not in the JS SDK. Its caller is the Setup console's Deactivate on the position and permission-set pages, which calls the platform API directly. +- 4806b18: Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). + + **Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): + - the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; + - the owner's `install-local` uninstall of that package took the set away from that member; + - the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. + + The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. + - `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. + - `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. + + **Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. + + **The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. +- 1802315: Under a walled posture the `/packages` verbs that publish, discard, roll back, revert, duplicate or adopt security catalog items are the platform operator's + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant"). + + **Why.** A package's positions and permission sets are environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack: the operator published a permission set in a writable package, and another organization's member held it. An organization owner granted `manage_metadata` inside their own organization (never the platform operator) then: + - published the operator's pending draft adding `manage_users`, and that member's next resolution carried it; + - rolled the package back, and the member lost it; + - reverted the operator's commit, and the member lost it; + - copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy; + - discarded the operator's pending draft (through `revert`, and `discard-drafts` is the same discard by a second door); + - adopted the environment's catalog rows into their own package. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs: + + - `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position, permission-set or capability draft. + - `POST /api/v1/packages/:id/revert`, when the package has such a draft. + - `POST /api/v1/packages/:id/discard-drafts`, when the package has such a draft. + - `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position, permission set or capability. + - `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing. + - `POST /api/v1/packages/:id/duplicate`, when the source package's published environment rows (the ones it copies) hold one. + - `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one. + + **Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read. + + **The remedy.** The platform operator publishes, discards, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete. +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- b389e43: feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) + + Clause-②: yes (narrowing) + + + + **BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. + + **What changes.** + + - Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. + - The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. + - The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. + - `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. + - A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | + | `organizationId` on any other protocol write verb's request | drop it, same refusal | + | `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | + | `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | + | `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | + | `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | + | `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | + | a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | + + **What a deployment observes.** + + - Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. + - An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. + + **What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- b53b949: fix(runtime)!: the `/i18n` dispatcher domain refuses a caller without a session with `401 UNAUTHENTICATED`, like every other dispatcher domain + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The `/i18n` dispatcher domain serves the application's translations: the locale list, the translation bundle and the field labels. The bundle carries the labels of every object, field, app and page the application declares. Until now the domain served a caller with no session, while the metadata read of the same objects refused one. ADR-0056 D2 denies anonymous callers by default. + + **What changed.** The domain handler opens with the shared anonymous-deny decision (`shouldDenyAnonymous`), the same floor the `/meta`, `/actions`, `/automation`, `/packages` and `/analytics` domains stand on. It is the handler's first statement: + + - every face of the domain answers a caller without a session `401` with code `UNAUTHENTICATED`, in the dispatcher's wrapped envelope (`{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`), and serves nothing of the bundle; + - it runs before the i18n provider is looked up, so the answer is `401` whether or not a provider is installed, never the `501` an empty slot answers; + - it runs before a face reads its parameters, so a request that leaves out its locale is `401`, never the `400` that face answers. + + **What is not affected.** A signed-in caller, an API-key caller and an internal system context are served exactly as before: the same bundle, the same `400` for a missing locale, the same `501` when no provider is installed. A CORS preflight is unchanged. + + **The Console.** The Console this release pins renders its sign-in page from its built-in language packs and loads the application's translations after sign-in, with the signed-in session. A signed-in Console user sees the application's labels as before. + + **If you read `/i18n` from your own client,** send the signed-in user's session or bearer token, or an API key, with the request. Render anything shown before sign-in from strings your client ships, and load the application's translations once the user has signed in. +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- fe58bbc: feat(runtime,core): inbound automation hooks work on a kernel with no raw HTTP app — the dispatcher admits `POST /automation/hooks/:flowName/:hookId` with no session, through to the trigger's own signature check + + Clause-②: yes + + On a self-hosted server, `@objectstack/trigger-api` serves a flow's inbound hook by mounting `POST /api/v1/automation/hooks/:flowName/:hookId` on the raw Hono app, where a third-party sender (a payment provider, a code host) posts a signed event with no session. A hosted tenant kernel has no raw app, so on that shape the path belonged to the dispatcher's `/automation` domain, whose anonymous floor refused every sender `401 UNAUTHENTICATED`. + + **What widens: one route passes the anonymous floor and the ADR-0069 gate with no session.** `@objectstack/core`'s auth-gate allow-list gains its first parameterised row: exactly `POST /automation/hooks/:flowName/:hookId` in the dispatcher's spelling (four segments, optionally under one `/environments/:id` scope), and nothing else. `/automation/hooks`, `/automation/hooks/x`, `/automation/hooks/x/y/z`, every other method on the route and every other `/automation` path stay gated exactly as before. + + - `@objectstack/core`: `isAuthGateAllowlisted(path, method?)` and `evaluateAuthGate(user, path, method?)` take an optional method. The existing rows are exempt for every method, as before, so a caller that passes no method gets the answer it always got. The new row is exempt for `POST` only, and only when a method is passed. `shouldDenyAnonymous` now passes its `method` to the allow-list beside its `path`. New export: `matchInboundHookRoute(path, method)`, the one reading of the route that the row matches through, returning the two path segments raw. + - `@objectstack/runtime`: the `/automation` domain's anonymous floor now asks the allow-list with its path and method. On the very next statement it hands the admitted route to the request kernel's `trigger-api` service (`ITriggerApiService.handleInboundHook`), ahead of every other `/automation` gate and service probe. The request goes with its body unread and the two path parameters decoded, and the service's `Response` comes back as it is. The service verifies the HMAC signature, so a correctly signed post is accepted and a wrongly signed or unsigned one is refused by that service, with its own answer. The membership gate skips the route for `POST` only, so a signed-in caller is judged exactly as an anonymous one, as on the self-hosted mount. + - No `trigger-api` service registered, or one without `handleInboundHook`: `501 NOT_IMPLEMENTED`, naming which, never `ROUTE_NOT_FOUND`. A service that throws, or a request whose body a transport already read: a sanitised `500 INTERNAL_ERROR`, logged. + + Nothing changes on a self-hosted server: the trigger's raw-app mount still serves the path there and never reaches the dispatcher, and the dispatcher mounts nothing that answers it. +- 51f36e7: The action activation door switches an action no package ships, its row names no package, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts an action the environment authored** (`@objectstack/runtime`). `POST /api/v1/actions/_activation/:object/:action` declares that it does not require the action to be packaged. FROM: an object-less action saved through the metadata door (`PUT /api/v1/meta/action/:name`), or one bound to an object no package ships, was refused `503` with `{ code: 'SERVICE_UNAVAILABLE' }` and the store's sentence about an empty package id, and nothing was written. TO: it is switched like a packaged one, `200`, its row carries no package (`package_id` is `null`), and dispatch then refuses it `409 ACTION_DISABLED` like any switched-off action. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared action `404`, an ambiguous name `409`, an engine with no ledger `501`). + - **An action is credited only to the package that ships it** (`@objectstack/runtime`). FROM: an action with no package of its own was credited to its route object's package, so an action the environment saved against a packaged object wrote that object's package into its row. TO: the row names the action's own package; for an action declared inside its object's `actions`, the object's package; otherwise no package. A `sys_metadata` placeholder or a tenant-authored action's package binding is not a shipping package. Rows of shipped actions are unchanged, including the platform's own actions declared inside their objects. No reader decides anything from `package_id`, so nothing that is switched on or off changes. + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, an error that declares its own pair keeps it (no ledger attached: `503 SERVICE_UNAVAILABLE`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The in-memory activation store refuses what the durable store refuses** (`@objectstack/core`). FROM: `InMemoryMetadataActivationStore.setActive` (the base of `InMemoryActionActivationStore` and `InMemoryFlowActivationStore`) accepted `packageId: ''`, which `ObjectStoreMetadataActivationStore.setActive` refuses. TO: it refuses `packageId: ''` with the same `TypeError` and the same sentence, before it keeps anything. If a test passes `''`, omit the key instead. +- 20028df: fix(runtime)!: `callData` asks each nested-relation condition's related object its declared exposure before the engine reads it, on the query fallback and the aggregate arm (#22823) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + + **FROM.** The runtime dispatcher's data bridge, `callData`, judged only the object a call addresses against its `enable` block, and then handed the caller's `where` to the ObjectQL engine at two positions: + + - the `query` / `find` fallback, which serves when no protocol service resolves (an assembly that composes the engine plugin with `registerProtocol: false` and no `MetadataProtocolPlugin`); + - the `aggregate` arm, which never takes the protocol service, so on every assembly (the MCP `aggregate_records` tool over HTTP reaches it). + + At both, such a condition was evaluated whatever the related object's `enable` block declared, including a related object every data door refuses when it is addressed. + + **TO.** `callData` asks each condition's related object the exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is called. A related object the gate does not serve refuses the call with the data door's own exposure answer, the one the protocol path gives the same condition: + + - `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`. + - an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set in `allowed`. + + The refusal names the related object (`object`) and the condition's position (`param`, `where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. A related object with no `enable` block, or a whitelist that grants `list`, is served exactly as before. With the protocol service present the `query` arm is unchanged: the data door judges the condition, as it has since #22737. + + **Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + + **Bindings.** + + - **Refused, not withheld.** Dropping a condition would widen the result. + - **One gate, one walk.** The gate is the one `callData` already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door and the MCP stdio bridge use. + - **A system context is not asked**, as it is not asked for the addressed object: these flags govern API exposure, not the engine's own reads. A flow's or a hook's own query calls the engine directly and never passes `callData`. + - **No new error code.** `@objectstack/runtime` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too. + + **Measured producers.** Read on `origin/main` `75b6d6a048`: no shipped composition reaches the `query` fallback. Every non-test `ObjectQLPlugin` construction under `packages/` and `examples/` keeps the built-in protocol (`os serve`, the standalone stack, the dev plugin, the verify harness, the kernel factory), and `registerProtocol: false` appears only in tests. The `aggregate` arm is reached on every assembly that serves the MCP route over HTTP. Deployed compositions were NOT MEASURED. + +### Patch Changes + +- fec87e7: feat(spec)!: a package manifest's `permissions` no longer takes a flat list of permission strings — the structured `{ services, hooks, network, fs }` block is the only form (#13458) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). + + `ManifestPermissionsSchema` was a union of a flat `string[]` and the structured ADR-0025 §3.2 block. Nothing ever acted on the list: the loader registers the consented `grantedPermissions` set from the environment artifact with the permission enforcer, never the manifest's request, and the only code that met a list on a manifest was two reports saying it had been skipped. The marketplace install disclosure reads only the four lists, so a list was shown to an installer as "Requests no special permissions." (ADR-0049 enforce-or-remove). `ManifestPermissionsSchema` is now the structured block itself. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `permissions: ['system.user.read', 'system.data.write']` | `permissions: { services: [...], hooks: [...], network: [...], fs: [...] }`, naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches. | + | `permissions: []` | delete `permissions`: absence is the spelling for "requests nothing". | + | a list on an app manifest that ships no code | delete `permissions`. An app's record access is its permission SETS, in the stack's own top-level `permissions` collection. | + + **The one-line fix: replace every manifest `permissions` list with the structured block, or delete it.** A permission string has no mechanical mapping onto the four lists, so the translation is done by hand. `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + **What an author now sees.** Writing a list fails `tsc` (the key's type is the block), and `os validate`, `os build`, `os plugin build` and `defineStack` refuse it at `manifest.permissions` with the block's own answer: `Expected the plugin permission block { services?, hooks?, network?, fs? }, received a flat list.`, followed by the prescription. Every structured block that parsed before still parses, unchanged. + + ### The retirement kit + + - **Schema.** `ManifestPermissionsSchema` is `PluginPermissionsSchema`, by identity, and both export names stay. The block answers a list with its prescription on its own error map, the bare-array pattern `ListViewExportOptionsSchema` uses. The block also carries `EnvironmentArtifactSchema.grantedPermissions` values, so the answer is worded true there too, where a list was never legal. + - **D2 conversion `manifest-permissions-string-list-removed`** (step 18, retired from the load path): a lossless delete of an all-string list from the stack's `manifest` and every `packages[].manifest`. The notice carries the dropped strings. A built artifact replays it at the artifact door, so an artifact built while the list was legal still boots. It never touches the structured block, an array of objects, or the top-level ADR-0090 permission-set collection. + - **D3 entry `manifest-permissions-string-list-retired`** carries the judgement the delete cannot make: what each dropped string meant in services, hooks, hosts and paths. + - **Liveness.** `manifest.permissions` stays `live` on corrected evidence. Its consumer is the marketplace install disclosure, and it refuses nothing at load. The four keys are now drilled. + - **The two skip reports reworded.** `AppPlugin`'s security registrar and `@objectstack/plugin-security`'s audience-binding reconciler both report a manifest-stage `permissions` they cannot read as permission sets. They now name the flat list as the retired legacy form. They behave as before. + + **Measured producers: none outside tests.** On origin/main e67ba80049, no manifest in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes a list. The exceptions are five `@objectstack/spec` `manifest.test.ts` fixtures, re-triaged here, and the skip-report tests of `@objectstack/runtime` and `@objectstack/plugin-security`, which hand a list to an unparsed bundle on purpose and still pass. The same instrument finds those five fixtures, which is its control. At the objectui pin `a58626c88dc8`, nothing reads `manifest.permissions` (control: 91 `manifest.(id|name|version)` reads), and the install disclosure reads the structured block alone. Deployed and cloud-held manifests NOT MEASURED. +- 34dba5a: `AppPlugin` starts after the auth plugin when both are composed, so the Default Organization exists before the inline seed loads + + `AppPlugin` now declares the auth plugin (`com.objectstack.auth`) among its order-if-present dependencies. Under the `single` posture the auth plugin creates the Default Organization in its `start()` (ADR-0131 D3), and every seed row is stamped with it; the declaration makes the kernel start the auth plugin first instead of relying on the order plugins were registered in. A composition without the auth plugin is unaffected. +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- 9e9e6f9: feat(cli)!: `os meta resync` is deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + + In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. +- 240890c: The `/packages` refusals of a read-only package name the customisation that works today, instead of the retired organization overlay (ADR-0131 D6) + + Clause-②: no + + `DELETE`, `PATCH /disable`, `POST /publish` and `POST /revert` on a package provided by code or an installed app answer `422 WRITABLE_PACKAGE_REQUIRED`, and `POST /packages/:id/duplicate` on one answers `422 DUPLICATE_SOURCE_NOT_A_BASE`. Both refusals told the caller to customise the package "in place, with an ADR-0005 org overlay". ADR-0131 D6 retired the per-organization overlay axis: no metadata write lands organization-scoped any more, and the protocol refuses one that names an organization with `403 NOT_OVERRIDABLE`. The remedy named a mechanism that no longer exists. + + Both refusals now name the customisation that works: customise what the package provides beside it, as environment-level state. Saving an item under its own name overlays it where the item's type takes an environment overlay and the package's lock does not refuse it. Any other save is refused, and that refusal names the route the item's type has, or the lock. The duplicate refusal also names the editable copy of a whole package: install it unmanaged instead (`installMode: 'unmanaged'`), where its manifest's `installModes` permits that. + + Only the message text changes. Each refusal's `code`, status, `details`, condition and order are unchanged. +- 1fb274e: The runtime dispatcher's `GET /meta/datasource/:name/published` serves a code-defined datasource's code definition while a stored row under its name still exists + + Clause-②: no + + - This is the dispatcher twin of the `@objectstack/rest` published door, and it now answers the same way. For a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`), the door serves the layered read's `effective` layer, which is the code definition, instead of the leftover stored row. It asks the protocol's `declinesStoredRow` in place of `isShippedFlowName`. A shipped flow name is answered as before. + - Unchanged: a runtime datasource's stored row, and every stored row of every other type, is served as before. So is every row when the protocol does not provide `declinesStoredRow`. +- a543e24: `POST /packages/:id/revert` reverts a Studio-authored package instead of answering 404 "No metadata items found" + + Clause-②: no + + - The door asked only the metadata service, whose in-memory registry never holds a Studio package's stored rows. So a package with published items and a pending draft answered `404 RESOURCE_NOT_FOUND`, and the draft stayed. + - The door now asks the protocol's `revertStoredPackage` first, with the caller's active organization. A package with stored rows answers `200 { success: true }` with its drafts removed, or `409 RESOURCE_CONFLICT` when it has never been published. + - A package with no stored row is answered by the metadata service's `revertPackage`, exactly as before. That covers a code-shipped package and an unknown id (`404`). It is also what happens when the protocol does not provide `revertStoredPackage`. + - The request and the response shape are unchanged. +- d0bb78e: fix(runtime,metadata): publishing or reverting a read-only package is refused with `422 WRITABLE_PACKAGE_REQUIRED`, and package membership reads the `_packageId` stamp + + Clause-②: no + + ADR-0070 D2 makes a code or installed package read-only. `POST /api/v1/packages/:id/publish` and `POST /api/v1/packages/:id/revert` did not check this. These two answers change: + + - **Publish of a code or installed package: 200 → 422.** Before, the publish door answered 200. For a package whose items carry an authored `packageId`, for example `com.example.showcase`'s two capabilities, it answered `success: true` and wrote `publishedDefinition`, `state` and `version` onto those read-only items. For every other code package it answered `success: false`, "No metadata items found". It now answers `422 WRITABLE_PACKAGE_REQUIRED` before anything is written, the same refusal that `PATCH /packages/:id/disable` and `DELETE /packages/:id` already give. + - **Revert of a code or installed package: 404 or 409 → 422.** Before, the revert door answered `404 RESOURCE_NOT_FOUND` "No metadata items found" (for example `com.objectstack.setup` and the platform packages that ship objects) or `409 RESOURCE_CONFLICT` "Package '…' has never been published" (for example `com.example.showcase`). It now answers `422 WRITABLE_PACKAGE_REQUIRED`. The check runs after the protocol's stored-row answer, so a revert of a code package that has a stored row bound to it, such as an organization overlay draft, keeps its answer. + + To customise what a code package provides, use an ADR-0005 organization overlay. Overlay drafts publish through `POST /api/v1/packages/:id/publish-drafts` and the per-item publish door, and this change leaves both alone. + + **Unchanged:** a writable package's publish and revert, and an id that nothing carries (revert 404; publish 200 with `success: false`). + + `MetadataManager.publishPackage` and `revertPackage` now find a package's members by `packageId`, `package` or the private `_packageId` stamp. The artifact loader writes that stamp through `applyProtection`, and the ObjectQL object bridge copies it onto every object it registers. Before, an item that carried only the stamp was not a member. `MetadataManager` has no notion of package kind; the refusal of read-only packages lives at the two doors above. +- 3aa0c7b: The runtime dispatcher's `PUT /meta/:type/:name` honours `If-Match`, `If-None-Match: *` and `?mode=draft`, so a host that mounts the `@objectstack/hono` catch-all no longer overwrites a pinned save or publishes a draft save + + Clause-②: no + + - Behind `createHonoApp`'s `${prefix}/*` catch-all, this door handed `saveMetaItem` no `parentVersion` and no `mode`. A stale `If-Match` wrote (`200`, not `409`), `If-None-Match: *` over an existing row wrote, and a `?mode=draft` save landed on the ACTIVE row with a receipt saying `state: 'active'`, so every draft edit went live without a publish. The door now reads the request through `metaSaveRequestOptions` from `@objectstack/rest`, the same mapping `RestServer`'s `PUT` door reads it through. It answers as that door does: `409 METADATA_CONFLICT` for a stale token or for `*` over an existing row, a draft row that leaves the active row alone, and `400 VALIDATION_ERROR` for `If-Match` sent beside `If-None-Match` or for an `If-None-Match` other than `*`. Each refusal writes nothing. + - A host whose protocol has no `saveMetaItem` falls back to the metadata service's `saveItem(type, name, item)`, which cannot carry a precondition or a lifecycle. A save that asks for one there is now refused `501 NOT_IMPLEMENTED` and is not written. + - Unchanged: a save that sends neither header and no `?mode=draft` writes the active row, last writer wins, as before. The refusal envelope is this transport's own (`{ success: false, error: { code, message, httpStatus } }`). +- fbcbcf1: The runtime dispatcher's `/meta` domain reads `?package=all` as naming no package, as `RestServer` does, so a `PUT ?package=all` through the `@objectstack/hono` catch-all no longer binds the row to a package called `all` + + Clause-②: no + + - The dispatcher's `PUT /meta/:type/:name?package=all` stored the row with `package_id: 'all'`. It now stores it env-local (`package_id` null), as `RestServer`'s `PUT` does. + - Its layered read (`/meta/:type/:name/layers`), list (`/meta/:type`), book tree (`/meta/book/:name/tree`) and item read forwarded `all` to the store as a package id. The layered read answered `404` and the list `[]` for an item stored in a package. Each now answers `?package=all` as it answers the same request without `?package=`. + - Every branch reads `?package=` through `metaItemPackageBinding` from `@objectstack/rest`, the function `RestServer`'s doors read it through. + - Unchanged: a real package id still scopes each read and still binds a save. +- f4069c5: fix(metadata): the object read's `sortability` follows the caller's field permission for every caller class + + Clause-②: yes (widening) + + - **What was wrong.** ADR-0106 D4 serves a caller holding `manage_metadata`, `studio.access` or `setup.access` (a platform admin, and an organization admin through `setup.access`) the object schema UNMASKED, because Studio and Setup authoring need the whole definition. The `sortability` projection served beside it on `GET /meta/object/:name` (and the runtime dispatcher's `/metadata/object/:name`) was derived from that unmasked document, so it marked a field `sortable: true` that the same caller's field permission marks unreadable, while the data route refuses a sort or filter on that field with `403 PERMISSION_DENIED`. A client building its sort picker from the projection offered a click the server refuses. + - **What changes.** `sortability` is now derived from the caller's runtime view: the served document for every caller the mask projects (unchanged), and for a D4-exempt caller the fields their own field permission reads. The definition (`item.fields`) is unchanged for every class: whole for an exempt caller, masked for everyone else. The data route and `/auth/me/permissions` are unchanged. + - **Caching.** On the cached `GET /meta/object/:name` branch an exempt caller's ETag folds a fingerprint of the fields withheld from their projection, and the conditional request is judged after it is folded. An exempt caller whose permission withholds nothing keeps the byte-identical ETag. An exempt caller and a projected caller denied the same field never share a validator. + - **Failure posture.** The exempt caller's field permission is asked lazily, only by the item read that serves `sortability`, and never turns their definition read into a fault: if the security service throws or cannot answer, `sortability` is derived from the served document as before, with one `warn`. + - **New in `@objectstack/metadata-core`.** `resolveObjectSchemaRuntimeView(document, posture)` returns `{ document, withheld, fingerprint }`, the view the runtime projections beside an object schema are derived from. The types `ObjectSchemaRuntimeView` and `ObjectSchemaRuntimeFieldPosture` are exported with it, and the `passthrough` member of `ObjectSchemaMaskPosture` gains an optional `runtime` (set on an `exempt` posture for a capability-exempt caller in a deployment with a `security` service). + - **New in `@objectstack/rest`.** `translateMetaEnvelope` takes an optional fifth argument, `runtimeDocument` (default: the served document), and `MetaItemAnswerSources.translateEnvelope` an optional third. Nothing is removed, renamed or narrowed. +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- ca135dc: fix(runtime): the trigger door refuses a self-triggered flow declared to run as system to any caller but the system principal + + Clause-②: no + + A self-triggered flow declared to run as system could be started by any signed-in member through the trigger door. `POST /api/v1/automation/:name/trigger` (and the legacy `POST /api/v1/automation/trigger/:name`, and `@objectstack/verify`'s `flows.run`, which answer through the same function) asked only whether the caller was anonymous, so a flow meant to run on its own trigger, or as a sub-flow, also ran elevated for anyone who named it. + + **What changes.** A caller that is not the system principal now gets `403 PERMISSION_DENIED` (ADR-0112 envelope) when it starts a flow declared `runAs: 'system'` whose `type` is `autolaunched`, `record_change` or `schedule`. Nothing runs: no run record, no node, no side effect. The refusal names no part of the flow. This includes a platform admin's session, which is a signed-in user, not the system principal. + + **What stays as it was.** + + - `screen` and `api` flows, elevated or not: doors the author designed (ADR-0073 D2). + - Every flow that does not declare `runAs: 'system'`. + - A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through the door. + - The system principal (an in-process caller, such as a job), which starts every flow. + + **If a call now answers 403.** That flow runs on its own trigger. To start its work on a user's request, call it from a parent flow's `subflow` node, or, if it is meant to be a door, declare it `type: 'screen'` (or `type: 'api'` for a signed inbound hook) so the elevation is a reviewable choice. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- c572fe2: fix(runtime): the action door and the declared flow endpoint refuse a self-triggered flow declared to run as system to any caller but the system principal + + Clause-②: no + + The trigger door already refused this start, but two other doors that start a flow by name did not ask: an action of `type: 'flow'` (`POST /api/v1/actions/:object/:action`, and the MCP `run_action` tool, which reaches the same dispatch) and a declared endpoint of `type: 'flow'`. Any signed-in member who was refused at the trigger door could start the same elevated flow through either one, and its elevated write landed. + + **What changes.** All three doors now apply one rule from one place. A caller that is not the system principal gets `403 PERMISSION_DENIED` (ADR-0112 envelope) when it starts a flow declared `runAs: 'system'` whose `type` is `autolaunched`, `record_change` or `schedule`. Nothing runs: no run record, no node, no side effect. This includes a platform admin's session and the guest principal an `authRequired: false` endpoint admits. The refusal names no part of the flow, and its message is now the same at every door: it no longer names the trigger door. + + **What stays as it was.** + + - `screen` and `api` flows, elevated or not: entries the author designed (ADR-0073 D2). + - Every flow that does not declare `runAs: 'system'`. + - A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through a door. + - The system principal (an in-process caller, such as a job), which starts every flow. + - The action's own `requiredPermissions` gate (ADR-0066 D4) still answers first. Holding that capability does not open a self-triggered elevated target. + - A declared endpoint's policy chain: an anonymous caller at an `authRequired: true` endpoint still gets `401` first. + + **If an action or an endpoint now answers 403.** Its target flow runs on its own trigger. If members are meant to start that elevated work, declare the target `type: 'screen'` (or `type: 'api'` for a signed inbound hook), which states that it is an entry and keeps the elevation reviewable. Or keep it self-triggered and point the action or endpoint at such an entry whose `subflow` node calls it. +- 0fea05f: `os serve `: a multi-package config now serves the top-level metadata no package declares, under the stack's `manifest.id`, and the boot says so, as an artifact boot of the same project already did + + Clause-②: yes + + A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]` registers each package body and never the stack's top level. A top-level item that no package body declares was served by no door, and the boot said nothing. Three shapes reached it: flat `src/docs/*.md` pages when the stack's `manifest.id` names no `packages[]` entry, inline `docs` spread onto a composed stack's top level, and any other top-level collection (objects, views, …) that no package carries. `os build` of the same project, booted as an artifact, registered those items under the stack's `manifest.id` and warned. + + - **What changes.** The config boot now runs the same residual rule as the artifact boot, `MetadataPlugin.registerUnclaimedTopLevel`. Every top-level item no package body declares is registered under the stack's `manifest.id`, and the boot prints one `warn` line with the count: `config stack '' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy. `GET /api/v1/meta/` lists those items under that id, as on an artifact boot of the same project. This includes the flat `src/docs/` pages of a stack whose `manifest.id` names no package, which a config boot did not list before. This entry supersedes, in the same release, the note in the `os serve ` flat `src/docs/` entry that "A config boot of such a stack still does not list them": it now does, under the stack's `manifest.id`, with the warning. + - **A new refusal, in one corner.** A residual view container is judged like any other: a top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot with ``Invalid `views:` container from manifest '': the container's own `name` is …``. On the previous release that config booted, because its top level was never read; an artifact boot of the same project already refused it. `os validate` and `os build` refuse the same container first, in the same words, so the refusal is never a boot-time surprise. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to. + - **The remedy the line names.** Declare each such item inside the `packages[]` entry of the package that owns it. A flat `src/docs/` page moves under `src//docs/`, where `` is the package id or its last dot-separated segment. + - **What widens, in `@objectstack/metadata`.** Its root entry now exports `unclaimedTopLevel(stack)`, the pure residual decision both boot doors and `os validate` read, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call. + - **What stays the same.** An artifact boot registers the same items and prints the same line, byte for byte. A single-package config (no `packages[]`) has no residual and is unchanged. A composed stack whose top level repeats its package bodies, as `composeStacks([…], { manifest: 'preserve' })` writes it, registers nothing extra and prints nothing. + - **What the residual is, on both doors.** It is metadata: a residual object is listed on `GET /api/v1/meta/object` under the stack's `manifest.id`, and `GET /api/v1/data/` still answers `404`. That was already true on an artifact boot, and the config boot now gives the same answer. +- 26aa999: fix(runtime,hono): approval e-mail links work on a kernel with no raw HTTP app — the dispatcher serves `/approvals/act`, and the Hono catch-all no longer consumes request bodies + + Clause-②: no + + On a self-hosted server, `@objectstack/plugin-approvals` serves the ADR-0043 action page (the approve / reject link in an approval e-mail or IM message) by mounting `GET` and `POST /api/v1/approvals/act` on the raw Hono app. A hosted tenant kernel has no raw app, so on that shape the path answered `404 ROUTE_NOT_FOUND` and every action link was dead. + + **`@objectstack/runtime`.** `HttpDispatcher` now serves exactly `/approvals/act` (no sub-paths) for `GET`, `HEAD` and `POST`. It forwards the request, unread, to the request kernel's `approvals` service (`IApprovalService.handleActionPage`) and returns that `Response` as it is. + + - `HEAD` answers as the self-hosted mount does: the `GET` page's status and headers, with no body. It renders and never decides. + - No `approvals` service registered, or one without `handleActionPage`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`. + - A member that throws: a sanitised `500 INTERNAL_ERROR`, with the original error in the server log. The same answer, logged, when a transport hands over a `POST` whose body was already read, instead of an "invalid link" page for a live link. + - The token is the only credential, as on the self-hosted mount. The project-membership gate now skips the exact act path (unscoped or `/environments/:id`-scoped), so a signed-in caller who is not a member of the environment reaches the page like an anonymous one instead of getting `403 PROJECT_MEMBERSHIP_REQUIRED`. Every other path, `/approvals/act/...` and `/approvals/requests/...` included, is checked as before. + + **`@objectstack/hono`.** The `createHonoApp` catch-all (and the `/auth/*` fallback) read the JSON body with `c.req.json()`, which consumed the raw request for every `Content-Type`. A domain that forwarded the request got it empty. The body is now parsed from a clone, so `context.request` reaches the dispatcher unread, byte for byte. The parsed `body` the domains receive is unchanged for every input: JSON values as parsed, and `{}` for an empty, form, text or invalid body. The cost is that a request body is buffered twice for the life of the request. + + Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher. +- cb3bb93: fix: a hook's refusal reached through an action or another hook answers in the hook's own words, and `$top=0` reports the real `total` (#22588) + + Clause-②: no + + - **A nested sandboxed refusal keeps its sentence (`@objectstack/runtime`).** A script action whose body writes through `ctx.api` and is refused by a sandboxed hook used to answer with the hook's debug wrapper in front of the sentence: `POST /api/v1/actions/:object/:action/:id` → `400 VALIDATION_ERROR` "`hook 'guard' threw: Error: A finished task cannot be reopened.`". It now answers the hook's sentence alone, as `PATCH /api/v1/data/:object/:id` and a declarative `operation: 'update'` action already did. The same holds for a hook that writes another object through `ctx.api` and is refused there: `/data` answers the inner hook's sentence. Status and `code` do not change. The cause was the sandbox's VM hop: a host error crosses into a body's VM as `name` and `message` only, so the refused write's sentence was left behind and the wrapper became the outer error's caller-facing text. The sentence now travels beside the wrapper and is used when the body lets that error escape unchanged. A body that catches the error and throws its own (or rewrites its message first) is still answered in its own words, a nested crash is still a `500`, and the server log still names every frame. + - **The `/actions` door reads its sentence through `sandboxBusinessMessage`** (`@objectstack/types`), the same read `/data`, the bulk doors and `/analytics/dataset/query` make, instead of its own copy. The wire answer is unchanged. + - **`$top=0` reports the filtered `total` (`@objectstack/metadata-protocol`).** `limit: 0` asks for no rows, and `findData` treated it as "no limit", so the zero-length page was reported as the total: `GET /api/v1/data/:object?$top=0` answered `total: 0` while `$top=1` answered the real count. The Console reads its list footer's record count from exactly that `$top: 0` request. A zero-row request is now a paged request: it returns no rows and the same `total` as any other page size, filters included. `$count=false` still skips the count and now omits `total` for `$top=0` too, instead of reporting `0`. A `search` query still reports its page-local estimate, as it does at every page size. +- 5081090: fix(runtime): the `/meta/` legacy object exit asks the read gate before it serves the schema (#22639) + + Clause-②: no + + - The runtime dispatcher's one-segment `/meta/` exit, reached when a host's protocol has no type catalogue and no list for the segment, served the object schema through the field mask alone. It now asks the shared `/meta` read gate first, as every other object exit does, so an object's own `listViews` entries whose `requiredPermissions` the caller does not hold are not served there either. A caller who may edit the object still reads them all. +- 8558668: fix(runtime): the webhook redeliver button works on a kernel with no raw HTTP app — the dispatcher serves `POST /webhooks/redeliver` + + Clause-②: no + + On a self-hosted server, `@objectstack/plugin-webhooks` serves the operator's redeliver button by mounting `POST /api/v1/webhooks/redeliver` on the raw Hono app. A hosted tenant kernel has no raw app, so on that shape the path answered `404 ROUTE_NOT_FOUND`, even though the plugin had registered the `webhooks` service that can serve it. + + **The door that changes: `HttpDispatcher`, for a kernel with no raw app.** It now serves exactly `POST /webhooks/redeliver` (no sub-paths, no other method). It forwards the request, body unread, to the request kernel's `webhooks` service (`IWebhookService.handleRedeliver`), read on every request, and returns that `Response` as it is. So the hosted door answers what the self-hosted mount answers: `200` with the replayed row, and the same `401`, `400`, `404` and `409` refusals with the same bodies. An anonymous call is refused `401 UNAUTHENTICATED` there exactly as on the mount. + + - No `webhooks` service registered (plugin-webhooks absent, or present without its redelivery veto), or one without `handleRedeliver`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`. + - A member that throws: a sanitised `500 INTERNAL_ERROR`, with the original error in the server log. The same answer, logged, when a transport hands over a request whose body was already read, instead of a "not JSON" refusal for a well-formed request. + - Any method other than `POST` is not claimed: it answers the dispatcher's ordinary `404 ROUTE_NOT_FOUND` and never reaches the service. + - The dispatcher's two gates apply to this path unchanged: a session that still owes an authentication step, or a signed-in caller who is not a member of the request's environment, is refused `403` before the service runs. This door is therefore stricter than the self-hosted mount, never looser. + + Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher. +- 117817e: fix(runtime): on a `kernel-resolver` host, a dispatcher door resolves a service on the request's own environment kernel before the host kernel. `HttpDispatcher.resolveService` used to ask the host kernel's scoped lookup first, and the host kernel's singleton `protocol` / `metadata` / `objectql` answered it whatever the scope. So `POST /api/v1/actions/:object/:action` loaded its subject row through a protocol that registers none of the environment's objects, and it answered `404 RECORD_NOT_FOUND` for a row the caller can read. Hosts with no request kernel of their own (single-environment, SharedProjectPlugin) resolve exactly as before, and an id that names nothing is still refused with 404. + + Clause-②: no +- 0cbfb7c: A stack's declared `apis` endpoints are served on a config boot whose metadata service has an endpoint matcher, as they already were on an artifact boot + + Clause-②: no + + The endpoint matcher reads only the metadata service's own `api` items. The artifact door registers them there. A config boot (`os serve objectstack.config.ts` with no compiled artifact) handed them to the engine registry alone, so every declared path answered `404 ENDPOINT_NOT_FOUND`. The built artifact of the same stack matched the same path and answered `401 UNAUTHENTICATED` to a caller with no session. Measured on a stack with no plugin instances that declares one `authRequired` endpoint: `GET` of its path answered 404 from source and 401 after `os build`. + + `AppPlugin` now registers each declared `api` item into the metadata service's in-memory registry. It uses the route and the package stamp (`_packageId` / `_packageVersion` / `_provenance`) the code-defined datasources already take, and it takes the items from the boot's one forward-converted copy. The same boot now answers 401 from source. The stored item equals the artifact door's copy of it. On a boot that runs the artifact door (`os dev`, an artifact boot), the door stays the only registrar, so each item is registered once. + + One case is unchanged: a composition whose metadata service is the kernel's in-memory fallback has no endpoint matcher. A config whose `plugins` holds plugin instances, booted with `os serve`, is that composition. It now lists the items and still serves none. +- 7f2f081: `os serve` of a host config serves the stack's declared `apis` endpoints, and a composition that still cannot serve them says so once + + Clause-②: no + + A host config is a stack whose `plugins` holds plugin instances: `examples/app-showcase` and every `create-objectstack` scaffold. On `os serve objectstack.config.ts` (and on `os start`, whose `serve` child boots the same way) such a config never got `@objectstack/metadata`'s `MetadataPlugin`, so its `metadata` service was the kernel's in-memory fallback, which has no endpoint matcher. Every declared `apis` path answered `404 ENDPOINT_NOT_FOUND` there, while `os dev` of the same stack matched it. Measured on the showcase with no session: `GET /api/v1/apps/showcase/tasks` answered 404 and now answers `401 UNAUTHENTICATED`; an undeclared path under the same namespace still answers 404. + + `os serve` now composes a `MetadataPlugin` with no artifact on a host-config boot that has none (when `os dev`'s artifact door does not compose and `plugins` holds no `MetadataPlugin` of its own). What an upgrading host author meets on that boot, measured on the showcase before and after: + + - `GET /api/v1/meta/TYPE` answers byte-for-byte the same for every type it served before (22 types read, including `app`, `view`, `page`, `dashboard`, `flow`, `position` and `permission`). + - `GET /api/v1/openapi.json` now documents the declared endpoints the boot serves; before, it listed none of them. + - No new database table: the `sys_metadata` storage tables were already provisioned, and no row is written at boot. + - The door writes nothing into the project directory at boot. Its metadata repository is rooted at the served app's directory, not at the directory the command was run from. + - The door reads none of the stack and primes nothing from the filesystem (`bootstrap: 'lazy'`), so `positions` / `permissions` / `capabilities` / `sharingRules` keep exactly one registrar. + - The dev-only `GET /api/v1/dev/metadata-events` stays unmounted unless `NODE_ENV=development`. Under `os serve --dev` with no compiled artifact it is now mounted, as it is on every other config's dev boot. + - With a remote cluster driver, the metadata service's cross-node cache invalidation (`metadata.changed`) now attaches; the boot warning that it was disabled is gone. + - Boot time and memory did not measurably move (median of 7 boots each: 8.47 s → 8.41 s to listening, 461 MB → 459 MB resident). + + A composition that still has no endpoint matcher (for example a kernel composed without `MetadataPlugin`) now logs one warning at boot per app that declares `apis`, with the count, naming the remedy: compose `@objectstack/metadata`'s `MetadataPlugin`. +- 4267484: `GET /api/v1/meta/` now lists an artifact's items in their converted shape, the shape `GET /api/v1/meta//` serves + + Clause-②: no + + A compiled artifact built before a spec retirement still carries the retired key, and the ADR-0087 forward conversion rewrites it when the artifact boots. The artifact door converted it, so the item read served the converted copy. `AppPlugin` handed the RAW bundle to the engine registry, which is what the list read, `getMetaItemsForExecution` and the email template loader read. So the list served the retired key with `_diagnostics.valid: false`, and a reader of the list (the email loader is one) was handed an item the current schema refuses. Measured with `os dev -a dist/objectstack.json --fresh` on an artifact that declares no `engines.protocol` and carries a dashboard with `refreshInterval: 300`: the item read served `refreshIntervalSeconds: 300`, valid, and the list served `refreshInterval: 300`, invalid. + + `AppPlugin` now runs one forward conversion per boot, after the protocol handshake, over the same definition its security registrar already converted. The registry and that registrar both register from its result. An artifact authored in the current spellings registers exactly what it did before, because a conversion that changes nothing returns its input. + + The security seeders read the same registry copy first, so a boot with an engine now seeds what the conversion produced. Measured on `createStandaloneStack` with a `>=17.1.0` artifact: a sharing rule declared with the legacy recipient type `role` is now seeded as `position`, where it used to be skipped as an unmappable recipient. A seeded permission set no longer stores the retired `allowRestore` / `allowPurge` or the row-level-security `priority`. + + One case is unchanged: a bundle that carries `packages[]` still hands its package bodies over as authored. No conversion reaches those bodies, at the artifact door either. +- c5b43b6: fix(metadata-core, runtime): a multi-package artifact built before a retirement boots at `os dev -a` and `os start`. The ADR-0087 forward conversion walks every `packages[].manifest` body, and the runtime's artifact load converts the bundle once, before any reader sees it + + Clause-②: no + + **What an operator saw.** A multi-package release artifact (ADR-0130 D4: `os build` of a `composeStacks(…, { manifest: 'preserve' })` project) carries its collections in `packages[i].manifest`. Take such an artifact built before a retirement, for example with a dashboard still spelled `refreshInterval`. `os dev -a dist/objectstack.json` and `os start` exited at boot with "`packages[1]` is not a package entry (ADR-0130 D4): manifest.dashboards.0.refreshInterval: `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds`…". The metadata plugin's artifact door refused the same artifact at its strict parse. The ADR-0087 conversion converted that dashboard when it sat in the artifact's own `dashboards`, but it never read a package body, and the runtime read the bundle raw before any conversion ran. + + **What an operator sees now.** The same artifact boots at `os dev -a` and at `os start`. Each body's items are served converted (`refreshIntervalSeconds: 300`, valid), each owned by its own package. The boot log names each conversion once, with the body it found it in: `[MetadataPlugin] artifact '…' … converted 2 site(s) forward via ADR-0087 conversion 'dashboard-refresh-interval-to-refresh-interval-seconds' (first at packages[0].manifest.dashboards[0].refreshIntervalSeconds) …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. A body built after the retirement converts nothing and logs nothing. An artifact authored past every retirement is still refused with the tombstone inside its body. + + - **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` runs the same conversion over each package body as over the artifact's own collections. It uses the same registry and the same window, decided once from the artifact's own `engines.protocol`; a body's own `engines.protocol` is not read. A notice from a body is located from the artifact root (`packages[1].manifest.…`). + - **`@objectstack/runtime`.** `loadArtifactBundle` converts the loaded artifact once, before it merges the runtime module and before the stack, `AppPlugin` or the `manifest` service read it. The database resolver reads the artifact file itself, and it runs the same conversion on what it reads. So a multi-package artifact that declares its default datasource in a package body opens that database, not the unified default. + + **Unchanged.** An artifact without `packages[]` converts exactly as before, with the same notices, paths and messages, and it is served the same way. +- 6662e3f: A job's `timeout`, a turso datasource's `config.timeout` and a memory datasource's `config.persistence.autoSaveInterval`, authored by 17.x tooling, are honoured again when the bundle reaches `AppPlugin` raw + + Clause-②: no + + Protocol 18 renames these three duration keys so their unit is in their name: `jobs[].timeout` to `timeoutMs`, turso `config.timeout` to `config.timeoutMs`, memory `config.persistence.autoSaveInterval` to `autoSaveIntervalMs`. An artifact built earlier still carries the old spelling, and the boot's ADR-0087 forward conversion renames it. `AppPlugin` registered that converted copy with the `manifest` service and the security registrar, but its job scheduling and its datasource registration and auto-connect read the RAW bundle. So the scheduler and the drivers found no `*Ms` key and fell back to their defaults, with no notice. + + `loadArtifactBundle` converts at load, so an artifact booted with `os start --artifact` or `os dev -a` already arrived converted. A bundle in a host config's `plugins[]` array does not go through it: `os serve` and `os migrate` wrap it into `new AppPlugin(bundle)` as authored. Measured through that route on a bundle that declares no `engines.protocol`: the job was scheduled with no timeout at all, and both datasources were registered and handed to `connectDeclared` with the retired keys. + + Job scheduling, the code-defined datasource registration, its reserved-name check and `connectDeclared` now read the boot's one forward conversion, the copy every other registrar takes. A bundle authored in the current spellings is read exactly as before: a conversion that changes nothing returns its input, and the registered datasources carry the same `_packageId` / `_packageVersion` / `_provenance` stamps. A handler job's `ctx.bundle` is that converted copy as well. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- a3d47c2: fix(metadata-fs, runtime): metadata writes record the acting user's id or nobody, never the label `'fs'`, and a signed-in save through the runtime dispatcher records the user + + Clause-②: no + + - **What was wrong (filesystem repository).** `FileSystemRepository` named the label `'fs'` as the writer whenever no user was given. That covered every edit it saw on disk, every file it found that no recorded write produced, and an actor-less `put()`, which read back as `'fs'`. `MetadataPlugin` attaches the repository without naming a user, so this happened on every host that boots it. The contract for these fields (`PutOptions.actor`, `MetadataItem.authoredBy`, `MetadataEvent.actor`) is the acting user's id, or `null` for "nobody". + - **What it does now.** With no user named, the repository records `null`. A write that names a user records that user, and a write that records `null` reads back as `null`. The `fsActor` option keeps its type. When it is set, it is the user id reported for changes the repository did not write itself, and it no longer replaces a writer that a write recorded. Every event still names its origin on `source: 'fs'`, as before. + - **What was wrong (runtime dispatcher).** `PUT /api/v1/meta/:type/:name` served by the runtime dispatcher (the `@objectstack/hono` catch-all) saved without the caller's identity, so a signed-in author's save recorded `null` in `sys_metadata.updated_by` / `created_by` and `sys_metadata_history.recorded_by`. `RestServer`'s route recorded the user. + - **What an operator sees.** Filesystem-backed metadata history and events from this release on no longer name `'fs'` as the writer. A save through the runtime dispatcher records the signed-in user's id, as the REST route does, and a system caller with no user records `null`. Entries written before this release keep what they recorded. +- 21c8644: A hook's `timeout` authored by 17.x tooling bounds the hook again, and the three retired translation keys are no longer served, when the bundle reaches `AppPlugin` raw + + Clause-②: no + + Protocol 18 renames a hook's `timeout` to `timeoutMs`, and retires three translation keys: a per-app `settings` group, a page component's `submitLabel` and a dashboard widget's `subCaption`. An artifact built earlier still carries the old spellings, and the boot's ADR-0087 forward conversion rewrites them. `AppPlugin` bound its hooks and loaded its translation bundles from the RAW bundle, though. So the engine's hook wrapper found no `timeoutMs` and ran the hook with no time limit and no notice, and the i18n service served all three retired keys; the per-app `settings` group still filled the gaps in the platform's own settings copy. + + `loadArtifactBundle` converts at load, so an artifact booted with `os start --artifact` or `os dev -a` already arrived converted. A bundle in a host config's `plugins[]` array does not go through it: `os serve` and `os migrate` wrap it into `new AppPlugin(bundle)` as authored. Measured through that route on a bundle that declares no `engines.protocol`: the hook reached the engine as `{ timeout: 5000 }` and never timed out, and `getTranslations('zh-CN')` served all three retired keys. + + The hook binder and `loadTranslations` now read the boot's one forward conversion, the copy every other `AppPlugin` registrar takes. Which lists are read (the stack's own, a legacy `manifest.hooks` or `manifest.translations` list) is decided as before, and a list the conversion lifted from the manifest is read once, converted. A bundle authored in the current spellings is read exactly as before: a conversion that changes nothing returns its input, so the binder and the i18n service are handed the authored hooks and bundles themselves. + + One more behaviour change, measured on the same `plugins[]` route. A hook body or a script action's body that declares `capabilities: ['crypto.hash']`, reaching `AppPlugin` raw, was refused at bind: an `invalid hook.body shape` or `invalid action.body shape` warn, the hook never fired and the action was not registered. Read from the converted copy, where `hook-body-crypto-hash-removed` (the capability was retired at major 17) strips the token, it now binds and runs, as the `manifest` service already registered it. A body that calls `ctx.crypto.hash` throws when it runs, because the sandbox never installed that call. The remedy: drop the `ctx.crypto.hash` call. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [c28f317] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [8a399b2] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4049cac] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [35afb15] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [f2626c7] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [1884948] +- Updated dependencies [8a995b8] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [3ca71b6] +- Updated dependencies [94379f4] +- Updated dependencies [166a94f] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [3fdb92d] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [0511341] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [aa5bfdf] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/plugin-security@18.0.0-next.0 + - @objectstack/metadata-protocol@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/plugin-auth@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/driver-sql@18.0.0-next.0 + - @objectstack/rest@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/metadata@18.0.0-next.0 + - @objectstack/service-datasource@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + - @objectstack/driver-turso@18.0.0-next.0 + - @objectstack/driver-memory@18.0.0-next.0 + - @objectstack/driver-sqlite-wasm@18.0.0-next.0 + - @objectstack/observability@18.0.0-next.0 + - @objectstack/service-cluster@18.0.0-next.0 + - @objectstack/service-i18n@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c8df80222aa..5af476586df 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "ObjectStack Core Runtime & Query Engine", "type": "module", diff --git a/packages/sdui-parser/CHANGELOG.md b/packages/sdui-parser/CHANGELOG.md index 9a5a5eef7cf..90db724c50a 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,9 @@ # @objectstack/sdui-parser +## 18.0.0-next.0 + +No changes in this release. + ## 17.7.0 ### Minor Changes diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index a5cd02b541d..34641b72d50 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.", "main": "dist/index.js", diff --git a/packages/services/service-analytics/CHANGELOG.md b/packages/services/service-analytics/CHANGELOG.md index bc0987d760b..de992bd652d 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,341 @@ # Changelog — @objectstack/service-analytics +## 18.0.0-next.0 + +### Minor Changes + +- c565813: feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (`fields[].aggregate`) + + Clause-②: yes (widening) + + - **What a renderer can now read.** Each measure column of a dataset answer (`POST /analytics/dataset/query`) carries `fields[].aggregate`: the aggregate its dataset measure declares, in the closed `AggregationFunction` vocabulary (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`). It is there whether or not the author gave the measure a `label`. So a chart can tell a count from a sum, for example to draw whole-number axis ticks for a count instead of 0.75 / 1.5 / 2.25. + - **What was missing.** The only aggregate on the wire was `builtinAggregate`, and it is present only when the measure has no `label`. A labelled measure, such as a `count` named "Tasks", reached the wire as `{ name, type: 'number', label }`, with nothing to say what kind of number it was. + - **Where it is set.** `AnalyticsService` writes it in the one step that describes a dataset answer's columns from the dataset's own measures. That step runs for both the live query and the draft-data preview, so the two answers agree. A measure's `__compare` column carries the same aggregate. + - **Where it is absent.** Dimension columns. Derived measures, which combine other measures and have no single aggregate (a stray `aggregate` written beside `derived` is ignored when the dataset compiles, so it is not stated here either). And a cube query answer (`POST /analytics/query`), which does not run through the dataset column step. + - **Unchanged.** `builtinAggregate` keeps its meaning: present only on a label-less measure column, to mark a header that is the server's default. No authoring key is added; `aggregate` is a response member only. `AnalyticsResultResponseSchema` and the `AnalyticsResult` contract declare the member, and the REST route relays it as it does every other column key. +- 156ddfa: fix(service-analytics)!: the analytics door judges the two generic-exit declarations every other door judges — an object's `enable` block and a field's `internal: true` (#22634) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Every generic exit judges an object's `enable` block through the spec's one exposure decision, `apiExposureDenialReason`: the REST data routes, the runtime dispatcher, the MCP data tools, and the cross-object search for the off switch. Every generic exit also withholds a field declared `internal: true`: the data door omits it from rows, the write responses strip it, and the engine's `aggregate()` refuses it as a group key. The analytics door read neither declaration. + + **FROM.** On both strategies (native SQL and ObjectQL), and for a member holding read and an administrator alike: + + - The ad-hoc query, the SQL face, the configured-cube door and the dataset door served the rows of an object declaring `apiEnabled: false`, and of an object whose `apiMethods` whitelist does not grant `list`. + - On the native-SQL strategy, a field declared `internal: true` was served as a group key, as an aggregate input and as a filter operand. + - On the ObjectQL strategy, the engine refused the same field as a group key or an aggregate input with an undeclared `500`. It still admitted the field as a filter operand. + + **TO.** Ahead of strategy selection, the door asks the spec's decision for the `aggregate` operation over every object a query reads: the base object, declared joins and relationship hops. It answers with the data door's codes for the same declaration: + + - `404 OBJECT_API_DISABLED` for `apiEnabled: false`; + - `405 OBJECT_API_METHOD_NOT_ALLOWED` for a whitelist that does not grant the verb. + + On the ad-hoc doors this happens before any cube is inferred, so a refused request mints nothing. A member that reads a field declared `internal: true` is refused `400 INVALID_FIELD`, naming the object and the field. This holds in every position: dimension, measure input, filter or sort key, dataset filter, and relationship hop. + + - **Refused, not withheld.** On the data door, the row path omits such a field because a row is a projection. Every analytics member is evaluated instead: a group key, an aggregate input, a predicate. There is nothing to omit, and the data door's own aggregate face already refuses the field as a group key. + - **Every caller.** Neither declaration takes a user, so administrators are refused exactly as members are. There is no system carve-out. + - **Fails closed.** A declaration lookup that throws refuses the query with `403 PERMISSION_DENIED`, logged at `error`. `AnalyticsServicePlugin` wires the lookup from the data engine and refuses when no engine can answer. A host constructing `AnalyticsService` by hand with no `getObjectDeclaration` hook gets no gate, and is told so once. + - **Unchanged.** An ordinary object and an ordinary field are served as before. An object that merely has an `internal` field is still served for every other field, and so is a bare count over it. + + **Measured producers.** On `origin/main` `f368b7e980`, no authored cube, dataset, dashboard or example in this repository names an `apiEnabled: false` object, an object whose whitelist omits `list`, or an `internal` field. The platform's System Overview datasets count `sys_user`, `sys_organization` and `sys_session`, and group `sys_audit_log` by two ordinary fields. Each of those objects grants `list`, and none of those members is `internal`. Deployed and cloud-held analytics definitions were NOT MEASURED. +- 3b5475a: fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the TARGET object its declared exposure — the data door's `$expand` at every level, and the dataset door's dimension-label passes (#22661) + + Clause-②: no + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The data routes judge the ADDRESSED object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`): `enable.apiEnabled: false` answers `404 OBJECT_API_DISABLED`, and an `enable.apiMethods` whitelist that does not grant the verb answers `405 OBJECT_API_METHOD_NOT_ALLOWED`. A read that FOLLOWS a lookup reaches a second object nobody addressed, and two such reads never asked it. + + **FROM.** For an administrator and a member alike: + + - **`$expand`** — `GET /api/v1/data/:object?$expand=`, `GET /api/v1/data/:object/:id?expand=`, the relation map on `POST /api/v1/data/:object/query` (every level of the tree), and the export door (which expands every lookup to name it) served the row fields of a lookup's target whose declaration refuses it on every data route. + - **Dataset dimension labels** — `POST /api/v1/analytics/dataset/query` rendered a reference-class dimension's grouped id as the target record's display name, and sorted an `order` on that dimension by those names, for the same targets. + + **TO.** Each read asks the spec's one decision of the TARGET object, for `get` — the read both perform is "turn an id the caller holds into the record it names", the read `GET /api/v1/data/:target/:id` performs: + + - An `$expand` entry whose target the decision does not serve is withheld at the generic data door (`@objectstack/metadata-protocol`), at every level: the field answers as an UNEXPANDED lookup, its stored id — the answer the door already gives for a related record the caller may not read. Nothing below a withheld entry is read. + - A dimension label is not read for such a target (`@objectstack/service-analytics`): the stored id renders, and an `order` on the dimension sorts by it — the answer a target the reader's row scope hides already gets. A declaration that cannot be read withholds the labels too (fail-closed, at `warn`); the query itself still answers. + + Concretely, a target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']` or `['create']`) is withheld from both; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. + + **Bindings.** + + - **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same tree and the same labels. + - **The engine's privileged callers are untouched.** A flow's `config.expand` and a hook's own reads call the engine directly and never pass the generic data door, the same split the write results and the `internal: true` evaluate positions keep. + - **No new error code.** Both reads withhold rather than refuse, so no response status changes. + + **Measured producers.** Read on `origin/main` `eae3368a` over every `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false`. No example declares `enable.apiEnabled` or `enable.apiMethods`. So no shipped object's served answer changes. Deployed and cloud-held object definitions were NOT MEASURED. +- 7399080: fix(service-analytics)!: a configured cube, or a dataset `registerDataset` registers, over an object the API does not serve is refused at registration, and `GET /analytics/meta` no longer lists it (#22663) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Since the analytics door started judging an object's `enable` block, every query over an object whose `enable` the spec's `apiExposureDenialReason` denies for the `aggregate` operation is refused: `404 OBJECT_API_DISABLED` for `apiEnabled: false`, `405 OBJECT_API_METHOD_NOT_ALLOWED` for an `apiMethods` whitelist that does not grant it. The registration doors did not ask the same question. + + **FROM.** A configured cube (`analyticsCubes`, or `AnalyticsServicePlugin({ cubes })`), or a dataset registered through `registerDataset` (or the service's `datasets` option), whose base object or declared join is such an object registered silently. `GET /analytics/meta` listed it, and every query of it then answered 404 or 405. Nothing told the author at registration time. + + **TO.** Registration asks the same decision, for the same operation, over the same objects the query door's first check reads: the base object and the declared joins. + + - **A configured cube** over such an object is not registered. The service logs one warning per cube that names the cube, the object and the declaration (`enable.apiEnabled: false`, or the `enable.apiMethods` whitelist and the operations it grants). Every other cube still registers, and the boot continues. This is the channel a pre-registered dataset that cannot compile already takes. + - **`registerDataset`** throws, registering nothing. The error names the dataset, the object and the declaration, and carries the query door's code and status for that declaration (`OBJECT_API_DISABLED` / 404 or `OBJECT_API_METHOD_NOT_ALLOWED` / 405) with the object. + - **A cube written straight to the service's public `cubeRegistry`** is refused by the registry's own admission in the same words. + - **`GET /analytics/meta`** never lists a refused definition. + + **Unchanged.** + + - A cube or dataset over an exposed object registers and lists exactly as before. + - A host that wires no `getObjectDeclaration` hook registers every definition as before. `AnalyticsServicePlugin` always wires it. + - Registration judges only what is known when it runs, and does not block otherwise. An object that is not registered yet when the analytics plugin's `init()` registers the configured cubes is not refused, and neither is one whose lookup throws. Objects declared by a plugin that inits later, or in a `start()`, fall in this class. Such a cube registers and lists as before, and the query door still refuses every query of it. + - A relationship hop that one member walks is judged at query time, per member, and does not refuse the whole cube. + + **Measured producers.** On `origin/main` `ee3ae0360d`, this repository has one authored cube, `showcase_delivery` (`showcase_task` joined to `showcase_project`), and no `registerDataset` producer outside tests. Neither object declares an `enable` block. The eleven example and platform datasets (six in `examples/**`, five in `platform-objects`) are queried through the request-scoped dataset door and never enter the registry. Deployed and cloud-held definitions were NOT MEASURED. + +### Patch Changes + +- 036bf1d: fix(service-analytics): a default boot no longer warns that no "security" service is registered; the warning moves to the first query that finds none + + Clause-②: no + + - **What changed.** `AnalyticsServicePlugin` used to log a WARN at init, `[Analytics] No admitObjectRead configured and no "security" service registered at init …`, on every default boot. `@objectstack/plugin-security` registers the `security` service in its `start()`, after every plugin's `init()`, and the object-level read bridge resolves that service per query. So the warning described a consequence that did not apply to the running app. Init now logs the same situation at `info`, as the sibling row-scope bridge already did: `[Analytics] admitObjectRead bridged to the "security" service; that service is not registered yet at init and will be resolved per query`. + - **Where the warning went.** When an analytics query needs the object-level gate and the plugin context returns no `security` service, the bridge logs one WARN: `[Analytics] No admitObjectRead configured and no "security" service registered when an analytics query needed one (first: "OBJECT") …`. It is logged once per plugin instance, not once per query. + - **Unchanged.** What the bridge enforces. When the plugin context answers a lookup for `security` with nothing, the query is admitted, as before. A lookup that throws, or a service that answers neither `canReadObject` nor `explain`, still denies the query fail-closed and logs an `error`, as before. The in-repo kernels (`ObjectKernel`, `LiteKernel`) throw on a lookup for a service nobody registered, so on those kernels a query with no security service ever registered is still denied, with an `error` per query, as before. A deployment that supplies `admitObjectRead` sees no change. +- 0e9371f: fix(service-analytics): a picklist-bound select dimension now renders its category labels in the request's locale + + Clause-②: no + + A select field that references a shared list (`picklist: ''`) is served with the list's options and with `picklist` kept, and its option labels translate under `picklists..options.`. The analytics label pass translated a dimension's options through a synthetic field that carried `options` only, so the translator never looked at the `picklists` address. Every chart and report category on such a dimension showed the authored (usually English) labels under every `Accept-Language`, while `GET /api/v1/meta/object/:name` showed the same field translated. + + The synthetic field now carries the dimension field's `picklist`. `POST /api/v1/analytics/dataset/query` answers the same labels the object-metadata endpoint serves for that field: a field-level entry (`objects..fields..options.`) wins when one exists, and otherwise the list's label applies. A dimension with inline `options` renders exactly as before. + + An `order` on a select dimension now sorts by the label the row renders in the request's locale, and a `limit` keeps the first rows in that order. The sort used the authored labels, so under `Accept-Language: zh-CN` or `ja-JP` the order followed the English text, for inline `options` and, once translated, for picklist-bound dimensions too. With no locale, or no translation for it, the order is unchanged. + + Nothing you author changes: no key, export or parameter is added or removed. +- e175479: docs(service-analytics): the shipped API docs no longer say a deployment with no security service keeps analytics reads open + + Clause-②: no + + The doc comments on `AnalyticsServiceConfig.admitObjectRead` and `AnalyticsServiceConfig.getReadableFields`, and on the service's read gates, said that a missing hook means "the deployment has no security service" and that such a deployment keeps its analytics behaviour. Both parts were wrong. `AnalyticsServicePlugin` always wires both hooks. On `ObjectKernel` and `LiteKernel`, looking up a `security` service that was never registered throws, so the bridges refuse the query, fail-closed: `PERMISSION_DENIED` / 403 naming the object, plus an `error` line. The docs now say so. A missing hook now means only a host that constructs `AnalyticsService` itself without one. + + Only comments change. No behaviour, export or type moves. The text changes in the published `dist/index.d.ts` and `dist/index.d.cts`, and in the JSDoc kept in `dist/index.js` and `dist/index.cjs`. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 1373265dd30..2394e25b83a 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-analytics", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Analytics Service for ObjectStack — implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)", "type": "module", diff --git a/packages/services/service-automation/CHANGELOG.md b/packages/services/service-automation/CHANGELOG.md index 8967a751769..11915356721 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,1008 @@ # @objectstack/service-automation +## 18.0.0-next.0 + +### Major Changes + +- f66fdc7: The maps a flow node hands to a CALLEE are value slots now: a `subflow` node's `input`, a `map` node's `input` (evaluated once per item) and a `script` node's `inputs`. Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, evaluated in the calling flow's scope and handed over as the value it computes, or a literal written as it is. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. These three maps are computed values: each one becomes the callee's input. Until now each executor handed its map to the single-brace interpolator, so a `{token}` resolved there, and a CEL envelope written there reached the callee as the object `{ dialect: 'cel', source: '…' }` it spells, with the run reporting success. + + **What changes for a value that may be absent.** Where a whole token resolved to nothing, the template handed the callee nothing. A child flow then seeded its input variable from the variable's `defaultValue`. Under CEL an absent variable or key fails the run, and the guarded form `has(vars.x) ? vars.x : null` hands `null`. A `null` the caller supplies is a value: it wins over the child's `defaultValue`. To keep the default for an absent value, write it in the guard, `has(vars.x) ? vars.x : 'standard'`. Leave the key out where the value is never meant to be supplied. A `script` function is handed `null` where the template handed `undefined`. The refusal at those positions says this. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `input: { ownerId: '{record.owner}' }` on a `subflow` | `input: { ownerId: { dialect: 'cel', source: 'record.owner' } }` | an absent `owner` fails the run; guarded, it hands `null`, which wins over the child's `defaultValue` | + | `input: { ownerId: '{x}' }` where the child's default should apply when `x` is absent | `input: { ownerId: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'standard'" } }` | the default is written in the guard, because a supplied `null` is not absent | + | `input: { row: '{item}' }` on a `map` | `input: { row: { dialect: 'cel', source: 'item' } }` | evaluated once per item, with the item variable bound; a list or a record is handed over with its type | + | `inputs: { lines: '{lines}' }` on a `script` | `inputs: { lines: { dialect: 'cel', source: 'lines' } }` | the function receives the list; guarded, an absent value is `null` where it was `undefined` | + | `input: { message: 'Task "{record.title}" is done.' }` | `input: { message: { dialect: 'cel', source: "'Task \"' + record.title + '\" is done.'" } }` | one CEL concatenation; wrap a hole that may be null in `coalesce(…, '')` | + + **The one-line fix: write each value of a `subflow` / `map` `input` and a `script`'s `inputs` as a CEL value envelope, and write a child's default into the guard where it should apply.** + + **Who is affected, measured.** In this repository: 5 values in 3 nodes of the showcase example (`showcase_task_completed`'s `script`, and the `subflow` nodes of `showcase_task_done_notify_owner` and `showcase_project_closure`), 3 values in the todo example's `task_completion` `script`, 2 docs pages, the service-automation README, 2 dogfood fixtures and the test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. + + **Still accepted, unchanged.** A CEL value envelope and every literal. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `SubflowConfigSchema.input`, `ScriptConfigSchema.inputs` and `MapConfigSchema.input` take `FlowValueSlotSchema` per value (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `subflow.input.*`, `map.input.*` and `script.inputs.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `MapConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three maps. + - **The executor.** One per-key resolver, shared with the CRUD `fields` map, evaluates an envelope through `AutomationEngine.evaluateValueEnvelope` in the parent's scope and run context. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 9fdb127: A `loop`'s (with a `body`) and a `map`'s `collection` is a value slot now: the list the node iterates is a CEL value envelope, `{ dialect: 'cel', source: 'tasks' }`, that the executor evaluates in the run's scope, or the items written as an inline array, iterated as written. A string there is refused at `objectstack validate`, at `registerFlow` and by the executor: a `{…}` template token, naming its CEL spelling, and a variable's bare name, naming that variable in an envelope. The envelope is newly accepted there; the contract refused it before this change. + + Clause-②: yes (narrowing: a string is refused at loop.collection and map.collection, a {…} token and a variable's bare name alike; widening: both accept a CEL value envelope they refused, their published JSON Schema and inferred types gain the envelope arm, and FlowCollectionSlotSchema is a new export) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (both slots accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. The list a `loop` or `map` iterates is a computed value. Until now the executors ran a string `collection` through the single-brace interpolator. The contract also declared a variable's bare name there (`collection: 'tasks'`), but the structured `loop` and `map` never read one: the interpolator handed the name back unchanged, the value was no list, and every run failed at the node ("collection 'tasks' did not resolve to an array"), although `registerFlow` and `objectstack validate` accepted the flow. That arm retires with the token, and the refusal names the envelope that iterates. + + **What changes where the list may be absent.** The envelope must evaluate to a list, and anything else fails the node, `null` included, as a token that resolved to nothing did. One difference remains on an absent variable: the template failed the node with a returned failure (the step's `NODE_FAILURE`), and CEL fails it with an evaluation fault naming the variable (`EXECUTION_ERROR`). A `fault` edge routes both. To iterate nothing when the list may be absent, guard it to answer `[]`: `has(vars.tasks) ? vars.tasks : []`. + + **What does not change.** An inline array is iterated as written, and its elements are data: a `{…}` inside one was never interpolated, and is not judged now. A `loop` with no `body` is the legacy flat-graph form: it still reads `collection` as a bare variable name and sets `$loopItems` and `$loopIndex`, and no door judges it. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `collection: '{tasks}'` | `collection: { dialect: 'cel', source: 'tasks' }` | an absent `tasks` fails the node with an evaluation fault instead of a returned failure | + | `collection: '{record.items}'` | `collection: { dialect: 'cel', source: 'record.items' }` | as above | + | `collection: '{tasks}'` where an absent list should iterate nothing | `collection: { dialect: 'cel', source: 'has(vars.tasks) ? vars.tasks : []' }` | the node iterates nothing where the template failed it | + | `collection: 'tasks'` (a bare name, on a `loop` with a `body` or a `map`) | `collection: { dialect: 'cel', source: 'tasks' }` | the node iterates; it failed every run before, so decide whether it should | + | `collection: ['a', 'b']` | unchanged | an inline array is the list | + | `collection: 'tasks'` on a `loop` with no `body` | unchanged | the legacy flat-graph loop is untouched | + + **The one-line fix: write a `loop`'s or a `map`'s `collection` as a CEL value envelope naming the list, `{ dialect: 'cel', source: 'tasks' }`, or as an inline array.** + + **Who is affected, measured.** In this repository: the showcase example (two `loop` collections and one `map` collection), the todo example (two `loop` collections), the flows guide, the service-automation README, and test fixtures; all are migrated in this change. hotcrm writes a `{…}` collection on eleven structured loops in nine flow files (and one test fixture) and no bare name; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `loop.collection` and `map.collection`: the published JSON Schema for both gains the envelope arm (a list, or the envelope object), and so do the inferred `LoopConfig['collection']` and `MapConfig['collection']`, which lose `string`. `FlowCollectionSlotSchema` (`@objectstack/spec/automation`) is the contract both take. + + **Still accepted, unchanged.** An inline array at both, and on a body-less `loop` any `collection` its legacy form reads. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `LoopConfigSchema.collection` and `MapConfigSchema.collection` take `FlowCollectionSlotSchema`: a list or a CEL value envelope, so the executor's contract parse refuses a string as a guard, in the words above. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS`' `loop.collection` and `map.collection` rows take the `value` role, and `loop.collection` is read only on a `loop` with a `body` (`onlyWithKey`). The `loop` and `map` descriptors declare the slot with `xExpression: 'value'`. The single-brace `flow-template` role, which recorded these two slots and no other, is gone from `FlowNodeExpressionRole`, so a descriptor marker `'template'` fails the reconciliation ratchet as unknown. + - **The executors.** The `loop` and `map` nodes evaluate an envelope through `AutomationEngine.evaluateValueEnvelope`, as every value slot does, and iterate an inline array as written. A value that is not a list fails the node, naming what it evaluated to and its source. + - **The lint.** `validate-flow-template-paths` reads a loop iterator's object off an envelope `collection` that names a variable, as it read the token before. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 615c696: Each operand of a `get_record`, `update_record` or `delete_record` node's `filter` is a value slot now. A field's value, an operator's comparand and each element of a list comparand take a CEL value envelope, `{ id: { dialect: 'cel', source: 'record.id' } }`, which the executor evaluates in the run's scope before the query, or a literal handed to the query as written. A query placeholder — `{current_user_id}`, `{current_org_id}`, `{today}`, `{30_days_ago}` and the rest of the date macros — is such a literal: the query engine resolves it, and it passes untouched, as before. Every other single-brace `{…}` token at an operand is refused with its envelope: at `objectstack validate`, at `registerFlow`, at the metadata save door and by the executor's parse. An operand envelope that evaluates to `null` refuses the node, as a token that resolved to nothing did. + + Clause-②: yes (narrowing: a flow {…} token at a get_record, update_record or delete_record filter operand is refused, a whole-string query placeholder excepted, an object naming a string dialect at an operand must be a valid CEL value envelope, an operand envelope that evaluates to null refuses the node, and @objectstack/lint no longer exports validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN or FlowFilterTokenFinding and retires the flow-filter-token-unknown rule id; widening: a filter operand gains the CEL value envelope arm, the exported FlowNodeExpressionPath type gains the optional filterOperands field, ValueSlotTemplateOptions gains filterOperand, FLOW_NODE_EXPRESSION_PATHS gains the three filter rows, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains get_record and delete_record, the published GetRecordConfig, UpdateRecordConfig and DeleteRecordConfig JSON Schemas mark filter as a value slot, and flowFilterOperands, FlowFilterOperand, classifyFlowFilterOperandString and FlowFilterOperandStringKind are new exports) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL — its contract table names "filter values" — and Decision 3 deletes the single brace. A CRUD filter was the last value position that read the single-brace dialect: the executor interpolated the whole filter, resolving a flow token against the run's variables and handing a token no variable resolved on to the query engine when it was a query placeholder. Two `{…}` vocabularies met in one string. The query placeholders are the query-filter surface, which ADR-0032 puts out of its scope, so they stay; the flow token goes. + + **One classifier.** `classifyFlowFilterOperandString` (`@objectstack/spec/automation`) draws the line: a string that is one whole wrapped token the query engine resolves on the server (`isKnownFilterToken`) is a placeholder and passes; any other string carrying a `{…}` token is refused; the rest is a literal. The node contracts, `registerFlow`, `objectstack validate` and the executor's parse all read it, through the value-slot judge. + + **What an operand is.** The operands are the positions a value sits at, read the way the filter grammar reads a `FilterCondition` (`flowFilterOperands`): a field's value, each entry of an operator map (`$gt`, `$in`, `$between`, …), each element of a list comparand, inside `$and`, `$or` and `$not` and inside a nested-relation condition. An envelope there is one operand, and may evaluate to a list (`{ id: { $in: { dialect: 'cel', source: 'record.ids' } } }`). The filter's keys, operators and combinators are its structure, never evaluated. The top-level `filter` is never an operand. + + **What changes where a value may be absent.** The template erased a condition whose token resolved to nothing, and the node was refused. Under CEL an absent variable or key fails the run before any query, naming it, and an envelope that evaluates to `null` — a guarded form, a field that holds no value — refuses the node with the guard's message: a condition whose comparand is `null` or nothing does not narrow a query, it widens it. So route around the node with a `decision` where the value may be absent, rather than guarding it to `null`. To match an empty field, write the literal `null` (or `{ $null: true }`), never a computed one. + + **A flow variable named like a placeholder no longer shadows it.** Under the template a flow variable named `today` or `current_user_id` won over the placeholder of the same name. No flow token is read at an operand now, so the placeholder is always the query engine's; read the variable with an envelope, `{ dialect: 'cel', source: 'today' }`. No flow, variable or output variable in this repository is named like a placeholder. + + **A related field named `dialect`.** An object at a field's value that names a string `dialect` is read as an envelope. A nested-relation condition on a related object's field named `dialect` is written through an operator map, `{ rel: { dialect: { $eq: 'x' } } }`. No object in this repository declares such a field. + + **The lint rule `flow-filter-token-unknown` retires.** It reported a `{FUNC()}` token neither the flow template dialect nor the query placeholders define at a flow filter. Every token it reported is refused now by the value-slot judge at the same position and severity, with the envelope that replaces it, so the rule, its `os explain` entry and its exports (`validateFlowFilterTokens`, `FLOW_FILTER_TOKEN_UNKNOWN`, `FlowFilterTokenFinding`) are removed rather than left reporting the same defect a second time with the retired dialect's remedy. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `filter: { id: '{record.id}' }` | `filter: { id: { dialect: 'cel', source: 'record.id' } }` | an absent `record.id` fails the run before the query instead of refusing the node | + | `filter: { amount: { $gt: '{record.cap}' } }` | `filter: { amount: { $gt: { dialect: 'cel', source: 'record.cap' } } }` | a `null` cap refuses the node, as before | + | `filter: { id: { $in: ['{a}', '{b}'] } }` | `filter: { id: { $in: [{ dialect: 'cel', source: 'a' }, { dialect: 'cel', source: 'b' }] } }`, or one envelope evaluating to the list | — | + | `filter: { owner: '{$User.Id}' }` | `filter: { owner: { dialect: 'cel', source: 'current_user.id' } }` | in a run with no user `current_user` is `null` and the read fails: skip the node with a `decision` on `current_user != null` | + | `filter: { due: { $lt: '{TODAY() - 3}' } }` | `filter: { due: { $lt: { dialect: 'cel', source: 'isoDate(daysAgo(3))' } } }` | none for a whole number of days | + | `filter: { owner: '{current_user_id}' }`, `{ due: { $lt: '{today}' } }` | unchanged | a query placeholder stays the query engine's | + | `filter: { owner: '{today}' }` meaning a flow variable named `today` | `filter: { owner: { dialect: 'cel', source: 'today' } }` | the placeholder no longer shadows the variable | + + **The one-line fix: write each filter operand the flow computes as a CEL value envelope, and keep the query placeholders as they are.** + + **Who is affected, measured.** In this repository, at the base: seven flow tokens at a filter operand in the shipped examples (`app-crm` 2, `app-showcase` 3, `app-todo` 2 — `app-todo`'s two query placeholders stay), five in the docs (`getting-started/common-patterns`, `kernel/runtime-services/examples` 2, the `service-automation` README 2), the flows guide's and the spec's own remedy texts (a `$User.Email` read named `filter: { id: '{uid}' }`; a `recordId` refusal named `filter: { id: '{record.id}' }`) and the `FlowSchema` example; all are migrated in this change. The value-slot retirement's run-user changeset on this line names `filter: { id: '{uid}' }` for a `$User.Email` read; this one supersedes that line: read the user record with `filter: { id: { dialect: 'cel', source: 'current_user.id' } }`. hotcrm was not measured. + + **Still accepted, unchanged.** Every literal operand, every query placeholder, a filter's keys, operators and combinators, an empty filter, and a filter on `create_record`, which has none. The `filters` alias still converts to `filter` at load. + + ### The kit + + - **The contract.** `GetRecordConfigSchema`, `UpdateRecordConfigSchema` and `DeleteRecordConfigSchema` judge each filter operand: an envelope must be a valid CEL value envelope, and a `{…}` token in a literal is refused by the value-slot judge in its filter mode, naming the operand's envelope and what `null` does there. Each `filter`'s `describe()` and TSDoc is restated, and so are the `recordId` guidance and the run-user remedy, which named a single-brace filter value. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains a `filter` row per node, with the new `filterOperands` flag: both walks emit each operand at its own path (`filter.amount.$gt`, `filter.id.$in[0]`), never the filter itself. `GetRecordConfigSchema` and `DeleteRecordConfigSchema` join `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`. + - **The executor.** The CRUD executors parse their config as authored, evaluate each operand envelope through `AutomationEngine.evaluateValueEnvelope` before the query, hand every other operand on as written, and refuse the node — as a guard a `fault` edge cannot route — when an operand evaluates to `null`. The single-brace `interpolateFilter` is deleted. + - **The lint.** `objectstack validate` refuses a token at each operand position and judges an operand envelope as CEL through the shared judges; the wrong-syntax hints at a filter operand name its envelope; `flow-filter-token-unknown` retires. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 968acc8: An `http` node reads each config key by what it is now. Its `url` and every `headers` value are text slots, like a notify `title`: they render `{{ }}` holes, `'https://api.example.com/v1/orders/{{ record.id }}'`. Its `body` is a value slot: a CEL value envelope, `{ dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }`, that the executor evaluates in the run's scope to the payload, or a literal sent as written. Its `method`, `durable`, `timeoutMs` and `signingSecret` are literals, read as written. A single-brace `{…}` token at any of them is refused, naming its `{{ }}` spelling at a text slot, its CEL spelling in the body, and at a literal the remedy to write the value: at `objectstack validate`, at `registerFlow` and by the executor, except at `signingSecret`, which the build doors leave to the run because the flow credential store may hold the secret it signs with, and which the executor's parse refuses there. + + Clause-②: yes (narrowing: a {…} token is refused at http url, each headers value, any string of a literal body, method, durable, timeoutMs and signingSecret, and as a whole headers map, and a body value that names a dialect must be a valid CEL value envelope; widening: the exported FlowNodeTextSlot type gains the optional eachValue field, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains http, and the published HttpConfig JSON Schema marks body as a value slot) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a url and a header are text. Until now the executor handed its whole config to the single-brace interpolator and parsed the result, so every key read the retired dialect, a whole token in a typed key (`timeoutMs: '{t}'`) resolved to its value's type, and a CEL envelope written as the body was sent to the endpoint as the object it spells. + + **The request is rendered once.** The executor parses the config as authored — after the flow credential store's `signingSecret`, unchanged — then renders the url and the headers and evaluates the body, before either mode. The durable outbox enqueues exactly the request the inline call would send, and nothing renders at delivery. + + **What changes where a value may be absent.** A url that renders to no text refuses the node, as an empty one did. A header that renders to no text is not sent, where the template sent it empty. In the body an absent variable or key fails the run under CEL, naming it, where the template wrote `null` for a whole token and nothing inside text; guard a value that may be unset, `has(record.owner) ? record.owner : null`. CEL holds a map's values to one type, so in a CEL map literal a literal or a text beside a field read is wrapped in `dyn(…)`. + + **The literals.** `method`, `durable`, `timeoutMs` and `signingSecret` passed through the interpolator too, but no shipped flow computed one: the census found no `{…}` token at any of them in this repository's examples, docs or dogfood fixtures, only in the tests that pinned the interpolation itself. So no value slot is built there. They are read as written, and a `{…}` token in one is refused with the literal's remedy; an envelope there is refused too, since nothing evaluates it. A token written as the whole `headers` map, which the template resolved to a map, is refused the same way. A signing secret that arrived through a template (`signingSecret: '{signing_key}'`) is refused: write the secret itself, which a flow saved through the metadata API keeps in the write-only flow credential store, or `''` to send unsigned on purpose. + + **What does not change.** Every literal is sent as written. The method still defaults to `GET` inline and `POST` durable. An authored `signingSecret: ''` still sends unsigned, and a secret the credential store holds still takes the literal's place. A url or header with no token, and a body with no token, behave as before. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `url: 'https://api.example.com/v1/orders/{record.id}'` | `url: 'https://api.example.com/v1/orders/{{ record.id }}'` | a `Date` in the url renders as its ISO text, not JSON-quoted | + | `headers: { 'X-Ref': 'r-{record.id}' }` | `headers: { 'X-Ref': 'r-{{ record.id }}' }` | a header that renders to no text is not sent | + | `body: { id: '{record.id}', status: 'done' }` | `body: { dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }` | an absent `id` fails the run instead of sending `null` | + | `body: { text: 'Done: {record.title}' }` | `body: { dialect: 'cel', source: "{'text': 'Done: ' + record.title}" }` | an absent `title` fails the run instead of sending `'Done: '` | + | `body: '{payload}'` | `body: { dialect: 'cel', source: 'payload' }` | an absent `payload` fails the run instead of sending no body | + | `timeoutMs: '{timeout}'`, `durable: '{flag}'`, `method: '{verb}'` | the value itself: `timeoutMs: 5000`, `durable: true`, `method: 'POST'` | the key is a literal now; nothing computes it | + | `headers: '{headers}'` | the map itself: `headers: { 'X-Ref': '{{ record.id }}' }` | a map is not computed into `headers` | + | `signingSecret: '{signing_key}'` | the secret itself, or `''` to send unsigned | a secret read from a flow variable is refused at the run | + + **The one-line fix: double the braces in `url` and the `headers` values, write `body` as one CEL value envelope that builds the payload, and write `method`, `durable`, `timeoutMs` and `signingSecret` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (three bodies holding four tokens) and the flows guide (one body); both are migrated in this change. No url, header, method, `durable`, `timeoutMs` or `signingSecret` in the examples, docs or dogfood fixtures carries a token. hotcrm was not measured at these keys. + + **Newly refused in the body.** An object naming a `dialect` was data before and was sent as written; it is an expression now, evaluated per run, and a malformed one is refused. An envelope nested inside a literal body is still data, sent as written. + + **Still accepted, unchanged.** Every literal at these keys, and the published shapes of `url` (a string), `headers` (a map of strings), `method`, `durable`, `timeoutMs` and `signingSecret`. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `HttpConfigSchema`: `url` and each `headers` value refuse a single-brace token through the text-slot judge; `body` takes `FlowValueSlotSchema`; `method`, `durable`, `timeoutMs` and `signingSecret` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. The token refusals at `method`, `durable`, `timeoutMs` and a whole `headers` map are marked read-as-written, so the build doors' value arm reports them at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains the `http` body row. `FLOW_NODE_TEXT_SLOTS` gains `http.url` and `http.headers`, whose new `eachValue` flag reads each value of the map at its own path (`headers.X-Ref`). `HttpConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the body's marker to the reconciliation ratchet. The build doors' carve-out for a config parsed after interpolation is gone with the interpolation. + - **The executor.** The `http` node parses its config as authored, renders the url and headers through the text renderer, evaluates the body through the shared value-slot resolver, and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names the http url and headers among the text slots, and at an http literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused`, `flow-text-slot-unbound-dollar-root-refused` and `flow-builtin-node-config-values-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 111ffff: A `notify` node's computed values are value slots now: `recipients`, `sourceId` and the values of `templateData` take a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, that the executor evaluates in the run's scope, or a literal written as it is. `recipients` holds one recipient or a list of them, each entry a string or an envelope, and an entry that evaluates to a list adds each of its recipients. The node's link, `actionUrl`, is a text slot like `title`: it renders `{{ }}` holes, `'/crm_case/{{ record.id }}'`. Its `sourceObject`, `actorId` and `payload` are literals, read as written: no flow computed one, so nothing is evaluated there. A single-brace `{…}` token at any of these positions is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling at a value slot, its `{{ }}` spelling at `actionUrl`, and, at a literal, the remedy to write the value. + + Clause-②: yes (narrowing: a {…} token is refused at notify recipients (the value or a list entry), templateData values, sourceId, actionUrl, sourceObject, actorId and any string of a payload value, a templateData value that names a dialect must be a valid CEL value envelope, and a CEL value envelope written as a payload value is refused; widening: recipients and sourceId accept a CEL value envelope they refused, a recipients list accepts envelope entries, and their published JSON Schema and inferred types gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (four positions accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a link with holes is text. Until now the executor ran each of these positions through the single-brace interpolator, and one of its answers reached nobody: a whole token holding a list, written as an entry of a `recipients` list — `recipients: ['{owners}']` with `owners = ['u1', 'u2']` — was handed to the messaging service as ONE recipient, `'u1,u2'`, and the run reported success. A token holding a record there became the recipient `'[object Object]'`, and so did one in a link or a `sourceId`. + + **What changes where a value may be absent.** A recipient envelope that evaluates to `null` or a blank string adds no recipient, as a token that resolved to nothing did, and a node left with none fails, naming the expressions that evaluated to nothing. An absent variable or key fails the run under CEL, naming it, where the template dropped the recipient; guard a field that may be unset, `has(record.assignee) ? record.assignee : null`. A relation hop on a scalar id (`record.account.owner` without the start node's `expand`) fails the evaluation itself, naming the key it could not read, where the template resolved it to nothing. `sourceId` must evaluate to a string; `null` leaves the key unset, and a half-specified click-through pair is still dropped. A recipient, or a `sourceId`, that evaluates to anything else — a map, a number — fails the node, naming what it evaluated to. + + **The literals.** `sourceObject`, `actorId` and the values of `payload` passed through the single-brace interpolator too, but no flow computed one: the census found no `{…}` token at any of them, in this repository or in hotcrm. So no value slot is built there. They are read as written, and a `{…}` token in one — in any string of a `payload` value, at any depth — is refused with the literal's remedy: a single-brace token is not resolved here; write the value. An envelope written there is refused too, since nothing evaluates it (a deeper one inside a `payload` value is data, as it always was). + + **What does not change.** A literal at every value slot is written as it is: a recipient string (`'team:ops'`, a user id, an email address) is one recipient, as before, and is not split. `template`, `topic`, `channels` and `severity` are read raw, as before. An explicit `actionUrl` still wins over the link synthesized from `sourceObject` / `sourceId`, and a link that renders no text yields to it, as an empty one did. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `recipients: '{record.owner}'` | `recipients: { dialect: 'cel', source: 'record.owner' }` | an absent `owner` fails the run instead of leaving the node without a recipient | + | `recipients: ['{record.assignee}']` where it may be unset | `recipients: [{ dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }]` | none: the `null` is dropped, as the unresolved token was | + | `recipients: ['{owners}']` (a list variable) | `recipients: [{ dialect: 'cel', source: 'owners' }]` | each of its recipients is notified, where the template sent one joined string | + | `recipients: ['{$User.Id}']` | `recipients: [{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }]` | none: a run with no user adds no recipient | + | `sourceId: '{record.id}'` | `sourceId: { dialect: 'cel', source: 'record.id' }` | an absent id fails the run instead of dropping the link | + | `sourceObject: '{record.object_name}'`, `actorId: '{record.owner}'`, `payload: { who: '{record.owner}' }` | the value itself: `sourceObject: 'crm_case'` | the key is a literal now; nothing computes it | + | `templateData: { name: '{record.name}' }` | `templateData: { name: { dialect: 'cel', source: 'record.name' } }` | the template gets `null` (a guard's answer) where the key was absent | + | `actionUrl: '/crm_case/{record.id}'` | `actionUrl: '/crm_case/{{ record.id }}'` | a `Date` in the link renders as its ISO text, not JSON-quoted | + + **The one-line fix: write each `notify` value position as a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, double the braces in `actionUrl`, and write `sourceObject`, `actorId` and `payload` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (16 recipient strings, 7 `sourceId`s and 4 links), the todo example (2 recipients and 2 `sourceId`s), the flows guide, the email-templates and common-patterns guides, two dogfood fixtures and test fixtures; all are migrated in this change, and a recipient field that may be unset is guarded. hotcrm at `1d7148bf2d` writes 19 notify nodes in 15 flow files, each with one `{…}` recipient (2 of them `{$User.Id}`), 35 `{…}` `templateData` values and a link with holes; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `recipients` (the value, or each entry of a list) and `sourceId`: their published JSON Schema gains the envelope arm, and so do the inferred `NotifyConfig` types. At a `templateData` key an object naming a `dialect` was data before; it is an expression now, evaluated per run, and a malformed one is refused. `sourceObject`, `actorId` and `payload` keep their published shapes (a string, a string, a map of any values), with no envelope arm. + + **Newly refused at the literals.** A `{…}` token in `sourceObject`, in `actorId` or in any string of a `payload` value, and a CEL value envelope written as a `payload` value, which was handed on as the object it spells. + + **Still accepted, unchanged.** Every literal at these positions. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `NotifyConfigSchema`: `recipients` takes a string, a list of strings and envelopes, or one envelope; `templateData` takes `FlowValueSlotSchema` per key; `sourceId` takes a string or an envelope; `actionUrl` refuses a single-brace token through the text-slot judge; `sourceObject`, `actorId` and `payload` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. That refusal is marked read-as-written, so the build doors' value arm reports it at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains three `notify` value rows. `recipients` carries `eachElement`: a list is read entry by entry, at its index (`recipients[1]`). `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`. `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the markers to the reconciliation ratchet. + - **The executor.** The `notify` node evaluates each envelope through the shared value-slot resolver, renders `actionUrl` through the text renderer and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names `actionUrl` among the text slots, and at a notify literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 12b9daf: A `screen` node's three positions that hand a value to the client are value slots now: an object form's `defaults` (each field's prefill), a field's `defaultValue`, and an object form's `recordId` (the record an `edit` form opens). Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, that the executor evaluates in the run's scope before the screen goes on the wire, so the client receives the value it computes, never the envelope; or a literal, served as written. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. `recordId` gains the envelope arm, which it refused before this change, and its literal stays a string. + + Clause-②: yes (narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId; widening: screen.recordId accepts a CEL value envelope it refused, and its published JSON Schema and inferred type gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (`recordId` accepts a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. A screen's prefill and its record id are computed values handed to the client. Until now the executor ran each through the single-brace interpolator: a `{token}` resolved there, an envelope written in `defaults` or a `defaultValue` went to the client as the object `{ dialect: 'cel', source: '…' }` it spells, and `recordId` was the text of whatever the token found, so a token that found a record served the id `[object Object]`. + + **What changes where a value may be absent.** Where a whole token resolved to nothing, the template served nothing there. Under CEL an absent variable or key fails the run at the screen, with its source, and the guarded form `has(vars.x) ? vars.x : null` serves `null`: + + - In an object form's `defaults`, the form keeps a `null` prefill as a value and opens the field blank, over the object field's own `defaultValue`, which it applied when the prefill left the field out. To keep that default for an absent value, write it in the guard: `has(vars.x) ? vars.x : 'prospecting'`. + - A field's `defaultValue` seeds the field with `null`. + - `recordId` must evaluate to the record's id, a non-blank string. Anything else, `null` included, fails the run at the screen. The template served such a screen with no record id, and its `edit` form opened with no record and could not save. Where the record may be absent, route around the screen with a `decision` on the value instead of guarding the id. + + The refusal at each position says what it serves. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `defaults: { account: '{account_id}' }` | `defaults: { account: { dialect: 'cel', source: 'account_id' } }` | an absent `account_id` fails the run; guarded, it prefills `null` and the field opens blank over the object field's `defaultValue` | + | `defaults: { stage: '{x}' }` where the object field's default should apply when `x` is absent | `defaults: { stage: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'prospecting'" } }` | the default is written in the guard, because a `null` prefill is a value | + | `defaults: { parent: '{rec}' }` | `defaults: { parent: { dialect: 'cel', source: 'rec' } }` | the record is served as a map, a list as a list | + | `fields: [{ name: 'email', defaultValue: '{lead.email}' }]` | `fields: [{ name: 'email', defaultValue: { dialect: 'cel', source: 'lead.email' } }]` | guarded, it prefills `null` | + | `recordId: '{record.id}'` | `recordId: { dialect: 'cel', source: 'record.id' }` | the envelope must evaluate to a non-blank string id: `null`, a map or a list fails the run at the screen | + | `recordId: 'acc_1'` | unchanged | a string literal is the id | + + **The one-line fix: write each value of a screen's `defaults`, each field's `defaultValue` and an object form's `recordId` as a CEL value envelope; write the object field's default into the guard where it should apply, and route around a screen whose record may be absent.** + + **Who is affected, measured.** In this repository: the CRM example's lead conversion (`crm_convert_lead_wizard`: 3 `defaults` values in its two object-form steps), the object-form example in the flows guide, and test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. + + **Newly accepted.** A CEL value envelope at `recordId`, which `z.string()` refused before this change: the published JSON Schema for `recordId` gains the envelope arm (a string, or the envelope object), and so does the inferred `ScreenConfig['recordId']`. + + **Still accepted, unchanged.** A CEL value envelope at `defaults` and at a field's `defaultValue`, and every literal at all three, a string `recordId` included. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `ScreenConfigSchema.defaults` and a field's `defaultValue` take `FlowValueSlotSchema`, and `recordId` takes a string or a CEL value envelope (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `screen.defaults.*`, `screen.fields[].defaultValue` and `screen.recordId` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `ScreenConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three positions. The build doors keep refusing a `recordId` literal that is not a string, as before. + - **The executor.** The `screen` node resolves each position through the value-slot resolver the CRUD `fields` map and a callee's input share: an envelope is evaluated by `AutomationEngine.evaluateValueEnvelope` in the run's scope, before the screen is served. A screen that passes through without pausing goes on no wire, and evaluates nothing. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- d85615d: A flow VALUE slot now refuses the date macros as well — `{TODAY()}`, `{NOW()}`, with an optional `± N` day offset — so it reads no single-brace `{…}` token at all. Each is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form: `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())`, `isoDatetime(addDays(now(), N))`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots: one dialect per slot, with a remedy for every spelling. The date macros were the last spelling kept, because CEL had no string form for a timestamp: `today()` and `now()` reach the data engine as a `Date` object, not the ISO text the macros wrote. The CEL stdlib now has one — `isoDate(t)` (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC calendar the macros rendered on — so the macros can be refused with a remedy that writes the same text. + + **What is refused.** In every value slot — `create_record` / `update_record` `fields`, the `assignment` node's `assignments` map, and both legacy `assignment` shapes — a string carrying a date macro, alone or beside other text and tokens. A string that mixed a date macro with another token used to be kept whole, the run-user token included (`'Due {TODAY()} by {$User.Id}'`); it is refused now, with a remedy for every token. + + **Where the remedy parts from the template.** For no offset or a whole number of days the CEL form writes the template's text byte for byte, at every instant (measured live through both engines over DST transitions, month and year ends and a leap day). The refusal names the three places it does not: + + - a fractional offset: the template added it to the day of the month and truncated the sum, `addDays` truncates the offset itself — going back, a day apart on most days of a month — and `daysFromNow(1.5)` / `daysAgo(1.5)` are refused at build; + - a variable offset (`{TODAY() + days}`): the template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number; CEL reads the path, and fails the run on an absent variable or a value that is not a number; + - an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days. + + **Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged. + + **The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing | + | `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`, `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | nothing for a whole number of days; a fraction truncates differently, and `daysFromNow` / `daysAgo` refuse one at build | + | `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing | + | `'{NOW() + 2}'`, `'{NOW() - 1}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }`, `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }` | nothing — `addDays` keeps the time of day, where `daysFromNow` would land on midnight | + | `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 | + | `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one | + | `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object | + | the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run | + | a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | nothing for a whole number of days | + + **The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.** + + **Who is affected, measured.** This repository's examples carry no date macro in a value slot; its own test fixtures and docs that did are migrated in this change. The census taken for this retirement's first change measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`); they migrate there. Deployed metadata and other repositories were not measured here. + + **Still accepted, unchanged.** A CEL value envelope and every literal. The single-brace dialect, date macros included, keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms. +- 6a06e3b: A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots, one dialect per slot, with a remedy for every spelling. The run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. + + **What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. + + **The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. + + **Still accepted, unchanged.** A CEL value envelope and every literal. `{$User.*}` keeps resolving where the single-brace dialect still lives, a CRUD `filter` value. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | + | `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | + | `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | an `assignment` of `uid: { dialect: 'cel', source: 'current_user.id' }`, a `get_record` on `sys_user` with `filter: { id: '{uid}' }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | + | `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | + | a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | nothing when the run has a user; where the flow can run without one, compute `current_user != null ? current_user.id : null`, which renders nothing where the template did | + + **The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** + + **Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}`, a `notify` `recipients` entry, is migrated to `current_user` with the `notify` value slots on this line. Other repositories and deployed metadata were not measured here. + + ### The kit + + - **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. + - **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, the hint at a node's literal names the value itself, and every other position keeps the single brace. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope and `current_user`. +- 0e9e7b7: A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2). + + **What is refused.** In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare `{ : }` config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve. One judge says it everywhere (`flowNodeValueTemplateRefusals` / `valueSlotTemplateRefusals` in `@objectstack/spec/automation`): `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema`, `UpdateRecordConfigSchema` and `AssignmentConfigSchema` refuse it at the value's path; `objectstack validate` reports it as `expression-invalid` at `error`, located at the node and `config.`, which also refuses it at the metadata save door; `registerFlow` refuses the flow (a stored flow carrying one is skipped at boot with a warn naming it); and the `create_record` / `update_record` / `assignment` executors refuse the node before writing anything. Each refusal leads with `VALUE_SLOT_TEMPLATE_REFUSAL` and then names the CEL spelling of the string's tokens. + + **Not converted.** No D2 conversion rewrites any spelling: every token spelling authored in flows answers differently under CEL for some input (measured through both engines over the same variables, 13 of 25 probes the same, 12 different), so a rewrite would change what some flow writes. Where a value may be absent, whether the field should take nothing, `null` or a default was decided silently by the template; it is now the author's decision. + + **What is still accepted, unchanged.** A CEL value envelope and every literal (a token-free string, numbers, booleans, `null`, arrays, objects). A `filter` value keeps the template dialect; a text slot (`notify` `title` / `message`, a screen `description`, …) reads `{{ }}` holes and refuses a single-brace token. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | + | `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run | + | `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` | + | `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` | + | `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | + | braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal | + + **The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.** + + **Who is affected, measured.** A TypeScript-AST census of every `create_record` / `update_record` `fields` value and `assignment` value (all three shapes, same-file spreads included): this repository at `959c209d5` carried 21 authored sites (`examples/**` and `packages/verify/src`) — 20 refused by this change (9 bare references, 10 dotted paths, 1 `$error` path, all migrated in this change) and 1 `{$User.Id}` (`examples/app-todo`), which the run-user refusal on this line refuses and migrates; hotcrm at `c529de2` carries 91 (2 of them through a same-file spread) — 71 refused by this change (31 bare references, 36 dotted paths, 4 text with holes) and 20 that the date-macro and run-user refusals on this line refuse (15 date macros, 5 `{$User.Id}`). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `automation/flow-value-slot-template.ts` (`VALUE_SLOT_TEMPLATE_REFUSAL`, `valueSlotTemplateRefusals`, `flowNodeValueTemplateRefusals`), composed into the value-slot contracts in `automation/builtin-node-config.zod.ts`; one new dropped-refinement site (`automation/AssignmentConfig` `out.catchall`, the bare legacy shape's values). + - **The doors.** `AutomationEngine.registerFlow` and the `create_record` / `update_record` / `assignment` executors (`@objectstack/service-automation`); `validateStackExpressions` (`@objectstack/lint`), whose `warning` hint pointing a template expression at the envelope this refusal replaces. + - **The ledger.** The D3 semantic entry `flow-value-slot-template-dialect-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: no authored spelling maps losslessly. +- 2b61f2d: A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. + + **What changes.** + + - **The renderer.** The text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. + - **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. + - **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. + - **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. + - **Unchanged:** a CRUD `filter` value keeps the single-brace dialect, and the value slots are CEL's. + + **Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | + | `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | + | `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | + | `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node's CEL value envelope — `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, by: { dialect: 'cel', source: 'current_user.id' } }` — then `'Due {{ due }}'`, `'By {{ by }}'` | + + **The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** + + **Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. + +### Minor Changes + +- 67b669e: The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. + - **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. + - **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. + - **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. +- b389e43: feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) + + Clause-②: yes (narrowing) + + + + **BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. + + **What changes.** + + - Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. + - The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. + - The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. + - `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. + - A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | + | `organizationId` on any other protocol write verb's request | drop it, same refusal | + | `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | + | `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | + | `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | + | `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | + | `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | + | a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | + + **What a deployment observes.** + + - Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. + - An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. + + **What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. +- 1920cf3: feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`. + + With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces. + + **What moves for consumers.** + + - **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables. + - **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`. + - **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables. + + **Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one. + + **Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- b2549a8: feat(spec): `ConnectorProviderContext.resolvePackagePath` — a connector provider factory can resolve a path against the declaring app's root + + Clause-②: yes (widening) + + - **What is new.** `ConnectorProviderContext` gains one optional, host-provided member, `resolvePackagePath(relativePath): Promise`, beside `loadPackageFile`. It resolves a relative path against the root of the stack or package that declared the connector entry and returns the absolute path. `'.'` returns the root itself. It refuses (throws on) an empty path, an absolute path, and a path that escapes the root after resolution, such as `../x` or `a/../../x`. That is the same rule `loadPackageFile` follows. It reads nothing and does not check that the path exists. + - **Who hands it.** The connector materializer in `@objectstack/service-automation` hands it to every provider factory. It is anchored at the plugin's `packageRoot` option, the same root `loadPackageFile` reads under, and falls back to `process.cwd()` the same way. The two members now share one confinement check, so they cannot disagree about what is inside the root. `loadPackageFile`'s behaviour and error messages are unchanged. + - **Who reads it.** Nothing yet. `@objectstack/connector-mcp` is next: a declarative stdio transport will use it as the launched process's working directory, so a relative command or argument resolves against the app's root instead of the directory the server was started from. + - **What does not change.** Which commands a declarative stdio transport may launch is untouched. A factory that does not read the member behaves as before. + - **Nothing to migrate.** A host that builds its own `ConnectorProviderContext` may leave the member out. A factory must then keep its existing behaviour, or fail with a clear message if it needs the root. +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- d25b187: feat(automation): a screen's heading and body text render in the run's locale + + Clause-②: yes (widening) + + - **What is new.** The `screen` executor serves a screen's `title` and `description` in the language of the person who started the run. For each, it asks the engine for the translated template at `flows..screens..title` / `.description` through the `i18n` service, in `AutomationContext.locale`, and fills the `{{ }}` holes of that template. This is the same pick a refusing `end` node's message already makes, through the same channel (`setI18nServiceSource`). + - **When the authored text renders.** With no locale on the run (a record-change, schedule or webhook trigger), no `i18n` service, no entry or an empty one, or a translation that does not compile, the authored template renders as before. A translation that does not compile also logs a `warn` naming the key. A resumed leg renders in the locale the run was started in. + - **The heading key covers the node label.** A screen with no `config.title` shows its node label, and a translated `title` replaces whichever heading the screen shows. The body text is translated only where the screen authors one. + - **`AutomationEngine.renderFlowTextSlot(slot, variables, context)`** is the one translated-template pick, now shared by the refusing `end` node and the `screen` executor. Its argument type, `FlowTextSlotTranslation`, is exported. +- 243dd3c: fix(service-automation)!: in flow CEL, `record` is the record the run was handed, or unbound — never the run's variables + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + **What changed.** A flow CEL expression — a node's or an edge's `condition`, a `decision` branch `expression`, a screen field's `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — evaluates in the scope `AutomationEngine.celScope` builds. That scope used to bind `record` to the run's variables map whenever the run held no record, so `record.assignee` read a flow variable named `assignee` instead of failing. Now `record` is bound only when: + + - an entrance handed the run a record: a record-change trigger's row, a time-relative sweep's row, the inbound hook's request body, a `type: 'flow'` action's record (the loaded row, or an empty record carrying at most the id it was given), a `subflow` or `map` parent's record, or a `map` item that carries a string `id`; + - or the flow binds a variable named `record` itself (a declared variable with a value, or an assignment target). + + With neither — a run started through the REST trigger route or a declared endpoint, a cron schedule, or a child of such a run — `record.X` fails the run with `Unknown variable: record` and the expression's source, as every other unbound root does. + + **What does not change.** A run that was handed a record reads `record.X` from that record, as before, over any variable of the same name. A bare variable name (`assignee`) and the `vars` namespace (`vars.assignee`) resolve as before. `$record`, `previous`, `vars` and `current_user` are untouched. + + ## Migration: FROM → TO + + In a flow whose run holds no record, read a variable by its name: + + | you wrote | write instead | + |:--|:--| + | `record.assignee` (a flow variable `assignee`) | `assignee`, or `vars.assignee` | + | `has(record.assignee) ? record.assignee : null` | `has(vars.assignee) ? vars.assignee : null` | + + **The one-line fix: read a flow variable by its name or through `vars`; `record` is only ever the record the run was handed.** A flow that is also launched with a record (an action on a row, a record trigger) keeps reading that record's fields as `record.X`. + + **Who is affected, measured.** Read statically over every flow CEL slot — 0 flows read `record` with no record entrance: + + - this repository at `0ec4268972` (`examples/**`, `packages/platform-objects`, `packages/qa/dogfood`): 64 flows, 51 CEL slots, no flow in `packages/platform-objects`; the 2 flows that read `record` are an `api` hook flow and a record-change flow; + - `objectstack-ai/hotcrm` at `f0afcbda07` (`src/`, `test/`): 45 flows, 57 CEL slots; the 12 flows that read `record` are all record-change flows. + + Deployed metadata and other repositories were not measured. `objectstack validate` refuses such a `record` read too, naming the root (see `@objectstack/lint`'s note). + +### Patch Changes + +- 7de0265: feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire + + Clause-②: yes (narrowing) + + + + **BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1. + + **What changes.** + + - **Templates resolve from the registry.** `sendTemplate` and `renderTemplate` read `email_template` metadata by `(name, locale)` at every send: the protocol's layered list (`GET /meta/email_template`'s items — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is unchanged: the named locale exactly, then `en-US`, then — only for a call that named none — the bundle's lowest locale tag. + - **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered at plugin init, so they now appear in Studio's email-template list. An app that declares the same `(name, locale)` wins over the built-in; the built-in still serves the locales the app leaves out; a Studio save overrides either at environment level. Every built-in renders byte-identical to the row it used to be seeded into, in every locale (pinned on the rendered subject, HTML and text). + - **A Studio save is live on the next send**, with no projection and no restart. The `PUT /meta/email_template/:name` response no longer carries `projectionApplied`, because nothing is projected. + - **No `sys_email_template` row is written at boot or read at send.** The table and its rows stay; the organization door stays closed. + - **A malformed template fails its send loudly** with the schema's own message, instead of falling through to the built-in copy of the same slot. + + **What moves for consumers.** + + - FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`. + - FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call. + - FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`. + - FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing. + - FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option. + - FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option. + + **What a deployment observes.** A `sys_email_template` row an organization customized before its door closed is no longer what sends: the template's code declaration (or an environment-level Studio save) is. The v18 migration ceremony promotes those rows to environment-level templates; to make such an edit live before it runs, re-save the template in Studio. + + **`@objectstack/spec`** (patch): the `email_template` liveness ledger is re-anchored to the registry loader, and `category` and `isSystem` are graded `dead` — their only consumer was the retired row projection. The `notify` node's `template` description and refusal messages, and the `IEmailService` contract's documentation, name the email templates rather than `sys_email_template` rows. No schema shape changes. + + **`@objectstack/service-automation`** (patch): the notify node's `template` help text served at `GET /api/v1/automation/actions` names the email templates rather than `sys_email_template` rows. +- 2f70c22: A key a builtin flow node's executor contract does not declare is refused at parse, with a location, on every builtin but `try_catch`: a `notify` `bogusKey`, a screen field's `visibleIf`, a `create_record` `fieldValues`, an `http` `outputVariable` and the like no longer pass `objectstack validate` and `objectstack compile` and then get the whole flow refused at registration. + + Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** Each of these executors parses the node's `config` against a strict contract before it acts. The build doors' executor-contract arm judged required keys and present values on these types, but held key membership back, because `registerFlow`'s undeclared-key walk judged it against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied the key into `dist/objectstack.json`), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. + + **What is refused.** A `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node, at any depth, whose config carries a key its executor contract does not declare: at the config itself, on a `screen` field, or on one of a field's `options`. That includes a body-less legacy `loop`, which is judged on key membership alone. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). It comes from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share. Its message carries the contract's own sentence, with the contract's prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss), and closes with the remedy: rename the key to one the contract declares there, or remove it. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the save door. `script` and `subflow` keys were already refused this way. The new `builtinNodeConfigKeysJudged(nodeType)` export names exactly the builtins the spec judges. + + **`@objectstack/service-automation`.** `registerFlow`'s descriptor walk (`validateNodeConfigKeys`) stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. Before the move, the declared key sets were measured equal: on each of these types the descriptor's declared keys, at every position the walk descends to, equal the keys the contract accepts there. So registration refuses exactly what it refused before. The refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. + + **`@objectstack/metadata-protocol`.** The save door judges a flow in its canonical spelling on every path. When no flow canonicalizer resolved (a host with no automation service), or the canonicalizer threw (a draft with a temporary cycle), the schema gate and the runtime authoring gate now judge the body with the ADR-0087 D2 conversions applied (`applyConversionsToFlow`), for the verdict only. The stored body stays the raw request body, exactly as before. A D2 spelling the load path still rewrites (`filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`; `functionName` and `input` on a `script`; `flow` on a `subflow`) is therefore accepted at that door, as at every other one, and an undeclared key that no conversion rewrites is refused there, located. This widens the fallback path back for the `script` and `subflow` aliases the previous key arm had begun refusing there. `duplicatePackage` re-saves through the same gate. + + **What stays as it was.** + + - `try_catch`: its undeclared keys stay registration's, judged by the walk. Its contract's `retry` is the shared `RetryPolicySchema`, which strips an unknown key, while its descriptor closes `retry` to `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. Moving the judge would have widened registration. + - Every plugin node type (the spec does not declare its contract): judged by the walk against its descriptor's `configSchema`, with the walk's own prescriptions. + - A key at or under a region slot (a `loop` body or a `parallel` branch, and their nodes and edges): judged at registration by the region check, as before. + - A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged. + - `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names | + | `fieldValues` on `create_record` / `update_record` | `fields` | + | `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` | + | `visibleIf` on a screen field | `visibleWhen` | + | `itemVariable` on a `loop` | `iteratorVariable` | + | `outputVariable` on an `http` node | delete it: the http executor binds no output variable | + | a key nothing reads | delete it | + + **The one-line fix: rename or delete the key the refusal names.** Registration already refused such a flow, so the fix changes nothing a running flow does. + + **Who is affected, measured.** At `fbcbcf124`, every builtin flow node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (95 nodes) carries only keys its contract declares. The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys whenever the engine publishes the node's descriptor. Its fallback form (while the descriptor list is loading, or when it cannot be read) offers `outputVariable` on an `http` node and the `url` alias on a `notify` node. Registration already refused the first and the conversion rewrites the second; the designer fix is tracked in objectui. hotcrm, deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, over every builtin in `getBuiltinNodeConfigContracts()` but `try_catch`, named by the new export `builtinNodeConfigKeysJudged`; no new code joins `FLOW_SLOT_REFUSAL_CODES`. + - **The ledger.** The D3 semantic entry `flow-builtin-node-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- d4680d2: feat(spec)!: `NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots, and a blank bare string is now refused there + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing at two authorable keys (`automation/NotifyConfig:title`, `automation/NotifyConfig:message`), shipped as `minor` under the repo's launch-window convention for breaking changes. It is the grade `TemplateExpressionInputSchema`'s blank-string rule shipped with when it reached the first twelve typed keys. + + - **What widens.** Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`). It now parses and runs. + - **What narrows.** A blank bare string (`''` or whitespace-only) at either key is newly refused, by the shared template input's non-blank rule (`invalid_union`, with the `TYPED_EXPRESSION_SOURCE_REQUIRED.template` sentence). Before, every blank value parsed: + - `title: ''` then failed every run at the executor's guard ("notify: title is required"), so it fails either way, now earlier; + - a whitespace-only `title` passed that guard and was delivered as the notification title, and it is now refused; + - `message: ''` or a whitespace-only `message` was delivered as an empty or blank body, and it is now refused. + + The fix is to write the text, or to delete the key (`message` is optional). + - **Parse output.** `NotifyConfigSchema.parse(...).title` and `.message` go from `string` to `{ dialect: 'template', source }`, for both spellings, because the parse normalizes a bare string to that envelope. The exported `NotifyConfigParsed` type changes with them. Code that reads parse output reads `.source`. The `notify` executor, the one reader in this repo, now does, so both spellings of one text deliver the same `payload.title` and `payload.body`, and a bare string renders exactly what it rendered before. + - **Still refused, with a new sentence.** A value that is neither a string nor a template envelope (a number, an array, a `cel` envelope) was refused before (`invalid_type`). It is refused now as `invalid_union`, with the `TYPED_EXPRESSION_DIALECT_ONLY.template` sentence. + - **New, notify-only.** A template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`). An envelope never parsed at these keys before, so this refuses nothing that used to parse. + - **Placeholder spelling.** These two slots render `{{ }}` holes (`{{ record.name }}`), for a bare string and an envelope alike, and a single-brace `{token}` in them is refused. The `.describe()` on both keys now says so, and no longer says the text is "sent verbatim". + - `@objectstack/service-automation`: the `notify` executor reads `source` from the two template slots, and the descriptor's `title` / `message` descriptions state the `{{ }}` holes in place of "sent verbatim". +- 51290bc: Form help and refusals an author reads no longer carry service-interface names, ruling dates or another product's ids + + Clause-②: no + + Wording only: no schema, key, type, export or error-code change. + + - The notify node's Template help (the `NotifyConfigSchema.template` describe and the Studio + inspector's copy in `@objectstack/service-automation`) names the deployment's default locale in + product words instead of `II18nService.getDefaultLocale()`, and drops its ruling date. + - `MANIFEST_ID_EXAMPLES` is now `com.acme.crm` and `org.example.help-desk` (was `com.steedos.crm` + and `org.apache.superset`). The package-id refusal opens with the headline + "Invalid package id 'VALUE'." and names the key in the sentence after it, so the headline alone + carries no JSON path; the rule, the examples and the suggestion follow unchanged in substance. A + caller that matched the old "on KEY. Expected reverse-domain notation" wording matches the + headline, or compares against `manifestIdRefusal()` by reference, instead. + - Ruling dates leave the describes Studio renders as form help: field `required` and `multiple`, + form-view field and section `visibleWhen`, section `collapsible` / `collapsed`, the redirect + arm's `submitBehavior.url`, and page `kind` / `source` (the ADR citations stay). They also + leave the refusals for padded grouping field names, `submitBehavior.url`, `features.*` in a + form-view predicate, and the four filter comparand refusals (null ordering comparand, + `{ $field }` in a list position, null list member, blank `$between` bound). Each sentence still + states the rule, why it exists and the repair. + - The email-template form's Identity section help says how senders address a template instead of + naming `IEmailService.sendTemplate`, in all four shipped locales. + - The action `description` help no longer ends in a dangling dash left behind by an earlier + strip: "(one dialog, not two —)" now reads "(one dialog, not two)". +- 43fc500: fix(service-automation): a `get_record` whose `fields` leave out `id` answers the row's id as its `id` output on the SQL drivers + + Clause-②: no + + - **What was wrong.** On its single-row branch (no `limit` above 1) a `get_record` node answers `output: { record, id, object }`, and a later node reads that `id` as `{read.id}` (for a node whose id is `read`). The node handed the author's `fields` to the engine unchanged, and the SQL drivers return exactly the columns a projection names. So a `get_record` with `fields: ['title']` read a row with no `id` column, and `{read.id}` was empty with no error. A later `update_record` filtering on it was refused for a filter condition that resolved to nothing, and any other reference read nothing. + - **What it does now.** That branch names `id` in the projection it hands the engine, so `{read.id}` is the row's id whatever `fields` names, on every driver. The record the node serves, and binds to its `outputVariable`, carries that `id` beside the columns the author named, so the variable's `id` reads the same value. The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) are read the same way: the served row is the named columns plus `id`. + - **Unchanged.** A `get_record` with no `fields`, or an empty list, still reads the whole row. A projection that already names `id` is handed over as written. The multi-row branch (`limit` above 1) answers `records` only and is untouched. +- fdfdd7e: fix(service-automation, objectql): the `runAs: 'user'` refusals and the run-setup warning say what a principal-less operation meets on both kernels + + Clause-②: no + + Three author-facing texts told a flow or hook author that a `runAs: 'user'` data operation with no trigger user "would execute UNSCOPED (elevated, RLS-bypassing)" if it were not refused. That holds only on a kernel with no security plugin. Since ADR-0096 D5, a kernel with `@objectstack/plugin-security` refuses an operation that carries no principal (`403 PERMISSION_DENIED`). Each text now says both: without a user the operation would carry no principal, refused by the security plugin where one is composed, unscoped where none is. + + - `@objectstack/service-automation`: the `UnscopedRunDataAccessError` message, and the `[runAs]` warning logged at run setup for a flow whose trigger resolved no user. + - `@objectstack/objectql`: the `HookUnscopedDataAccessError` message. + + Only the wording changes. The refusals, their order, their codes (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`, `HOOK_UNSCOPED_DATA_ACCESS` with its `403` status) and their remedy are unchanged: declare `runAs: 'system'` to make the elevation explicit and intended, or arrange for the trigger to supply a user. +- 1b99388: fix(service-automation): the `sys_automation_run` row's highlight set now shows `unmeasured_count` beside `selected_count` and `acted_count`. That puts all three operands of the broken-sweep first filter (`selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0`) on the run row itself. The field's description now also lists a notification queued for delivery as an uncountable effect. + + Why: on a stack composed as `serve` composes it, reliable delivery is on, so a `notify` step's in-app message is enqueued. The outbox dispatcher writes it after the run settles, and the node reports `unmeasuredEffect`. A notify sweep that did its job therefore reads `acted 0, unmeasured 1`. With only `selected` and `acted` on the row, it read the same as a sweep with nothing to do. No counter changes: an enqueued delivery stays uncountable, never a fabricated `acted`. + + For operators: read the triple (`selected`, `acted`, `unmeasured`), never `acted` alone. `acted 0` with `unmeasured` above 0 means "cannot tell", not "did nothing". +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [35afb15] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [440bed6] +- Updated dependencies [3ca71b6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [aa5bfdf] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/metadata-protocol@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/formula@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 1369c798edf..ec63b893416 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-automation", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Automation Service for ObjectStack — implements IAutomationService with plugin-based DAG flow execution engine", "type": "module", diff --git a/packages/services/service-cache/CHANGELOG.md b/packages/services/service-cache/CHANGELOG.md index cbe8f348734..7f3a13b58e7 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,200 @@ # @objectstack/service-cache +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/observability@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index eba700989e3..1027e2ca7f0 100644 --- a/packages/services/service-cache/package.json +++ b/packages/services/service-cache/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cache", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Cache Service for ObjectStack — implements ICacheService with in-memory and Redis adapters", "type": "module", diff --git a/packages/services/service-cluster-redis/CHANGELOG.md b/packages/services/service-cluster-redis/CHANGELOG.md index 98e248f13e3..99b69860f81 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,178 @@ # @objectstack/service-cluster-redis +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/service-cluster@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index dfbe7126f7e..07cb2cc7205 100644 --- a/packages/services/service-cluster-redis/package.json +++ b/packages/services/service-cluster-redis/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster-redis", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Redis cluster driver for ObjectStack — implements IPubSub/ILock/IKV/ICounter against Redis using ioredis.", "type": "module", diff --git a/packages/services/service-cluster/CHANGELOG.md b/packages/services/service-cluster/CHANGELOG.md index c23179a26b3..b4849eac69d 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,199 @@ # @objectstack/service-cluster +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index 3a463b604b5..613a94c8da4 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Cluster Service for ObjectStack — pluggable PubSub/Lock/KV/Counter primitives. Memory driver included; postgres/redis drivers ship separately.", "type": "module", diff --git a/packages/services/service-datasource/CHANGELOG.md b/packages/services/service-datasource/CHANGELOG.md index 6a5b3285d63..39763b475e8 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,273 @@ # @objectstack/service-external-datasource +## 18.0.0-next.0 + +### Minor Changes + +- 8532783: feat(service-datasource,metadata-core)!: the datasource-admin plugin reads the environment's stored `datasource` rows only; a legacy organization-scoped row is neither loaded at boot nor served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** Graded `minor` on the v18 prerelease line, as the read narrowing it completes. + + **What changes.** `DatasourceAdminServicePlugin` reads stored `datasource` rows in five places — the boot restore (`restoreRuntimeDatasources`), the by-name read behind cluster convergence and the `/meta` door's datasource projection, and the existence probes behind its own create, update and remove. Each now reads the environment's rows only, judged by the same row-value test the metadata reads apply. A row stored organization-scoped by an earlier release: + + - is no longer registered at boot, so the datasource-admin door and the metadata read door no longer serve it, and no live pool is opened from it; + - is never updated or deleted by an admin create, update or remove under its name — those write and remove an environment row of their own; + - stays at rest, and the boot report `[metadata_org_scoped_unserved]` keeps naming it. Nothing is deleted or rewritten; the v18 migration ceremony (`os migrate`, ADR-0131 D10) carries it. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | a legacy organization-scoped runtime datasource a deployment relied on | re-create it through the datasource-admin door or save it through the `/meta` door to make it environment-wide now, or wait for the v18 migration ceremony | + + `@objectstack/metadata-core` exports `isLegacyOrganizationRow`, the one row-value test for such a row; `@objectstack/objectql`'s authored hook and action binders now read it from there, with no change in what they bind. + +### Patch Changes + +- 8a399b2: The datasource admin door and the metadata door agree on a runtime datasource: a datasource saved through `/api/v1/meta/datasource/:name` is listed and editable through `/api/v1/datasources` in the same boot and after a restart, and one created through the admin door can be edited and removed through the metadata door (#21923) + + Clause-②: no + + `DatasourceSchema.origin` publishes `runtime` as "created via the Studio wizard, persisted in the runtime metadata store, environment-scoped, editable", and says `origin` is never accepted from client input. Three things broke that for a datasource the metadata door wrote, or one the admin door wrote: + + - **The admin door read `origin` from the record.** It served `origin ?? 'code'`, so after a restart a datasource saved through the metadata door (whose body carries no `origin`, or asserts `origin: 'code'`) was served as code-defined, and `PATCH /api/v1/datasources/:name` answered `400 DATASOURCE_ADMIN_ERROR` ("… is code-defined and cannot be edited at runtime."). It now serves `code` only for a name the host registers from code (the host's code-datasource set, the one the boot restore and the metadata door's refusal read), and `runtime` for every other name, whatever the record says. Boot pool rehydration follows the served origin, so such a datasource also gets its live pool after a restart. + - **A metadata-door write never reached the admin door until a restart.** The metadata door persisted the row but never registered it where the admin door lists, so in the same boot `GET /api/v1/datasources` omitted it and `PATCH` answered "not found". The datasource-admin plugin now registers the protocol's awaited `datasource` mutation projector: after a metadata-door save, publish, revert, rollback or delete, the admin door's registry follows the stored row and the live pool converges on it (opened on a create, rebuilt on a connectivity change, evicted on a delete) before the metadata door answers. A write under a name the host defines in code changes nothing here, so the metadata door's `DELETE` of a leftover row under such a name still leaves the code definition served. + - **An admin-created row could not be edited or removed through the metadata door.** The admin door stored its `sys_metadata` row with no `checksum`, the column the metadata door's optimistic lock compares, so `PUT` and `DELETE /api/v1/meta/datasource/:name` answered `409 METADATA_CONFLICT` for every admin-created datasource. The admin door now stamps the checksum the metadata door's repository stamps (`hashSpec` from `@objectstack/metadata-core`, which becomes a runtime dependency of this package). A row stored before this release has no checksum until the admin door next writes it: editing that datasource through the admin door once makes it editable through the metadata door. + + Cluster convergence (a peer replica's signal after an admin-door write) decides "code" from the same set, so a stored row under a code-defined name never opens a pool there, and every other stored row is pooled as runtime. + + **Unchanged.** A code-defined datasource stays read-only through both doors: the admin door's `PATCH` and `DELETE` still answer `400 DATASOURCE_ADMIN_ERROR`, and the metadata door's still answer `403 NOT_OVERRIDABLE`. A host that composes no code-datasource producer (neither `AppPlugin` nor `DefaultDatasourcePlugin`) registers no set, and the admin door then serves every datasource as runtime, as its boot restore already treats every stored row. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- 6a6a4d3: fix: a refused on-demand schema sync (`syncObjectSchema`) is logged at `error`, naming what was not created and how to fix it (#22985) + + Three places create an object's storage after the boot's own schema sync has run. When the driver refused that DDL, each of them logged a `warn` and carried on, so the deployment looked healthy while a table did not exist. Each now logs one `error` line that says what was not created, that nothing else will look wrong, and what fixes it. The driver's refusal is kept out of the line itself. Nothing else changes: the boot, the connect and the publish still complete as before. + + - **`@objectstack/service-messaging`**: boot-time provisioning of the eight notification tables. A refused table logs `[messaging] could not provision
storage — its storage was NOT created. …` at `error`, once per table per boot, with the refusal in the record's meta. The fix it names: resolve the refusal, then restart or run `os migrate apply`. This used to be a `warn`. Under `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` the provisioning still issues no DDL, so this line cannot appear. + - **`@objectstack/service-datasource`**: connecting a declared datasource re-runs the schema sync of every object bound to it. A refused object logs `datasource '': object '' has NO working storage here — …` at `error`, once per object per connect, with the refusal in meta. On a logger that has no `error`, the same line is logged at `warn`. The fix it names: resolve the refusal, then restart. This used to be a `warn` reading `syncObjectSchema('') failed`. + - **`@objectstack/metadata-protocol`**: publishing an object whose storage sync is refused logs `[Protocol] object '' was published but its storage was NOT created or altered — …` with `console.error`. The driver's refusal is the second argument. The fix it names: resolve the refusal, then publish the object again or restart. The publish still answers success. This replaces the `console.warn` line `[Protocol] table sync failed for object '': …`, so a log search for that text now finds nothing. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/driver-sql@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/driver-turso@18.0.0-next.0 + - @objectstack/driver-memory@18.0.0-next.0 + - @objectstack/driver-mongodb@18.0.0-next.0 + - @objectstack/driver-sqlite-wasm@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 9b21e553722..33e9e315057 100644 --- a/packages/services/service-datasource/package.json +++ b/packages/services/service-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-datasource", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "The datasource service (ADR-0015): external-table federation (introspect/draft/import/validate) + runtime UI datasource lifecycle (list/test/create/update/remove + REST routes). Open-source mechanism; the tier line falls on which ICryptoProvider / driver factory a host injects.", "type": "module", diff --git a/packages/services/service-i18n/CHANGELOG.md b/packages/services/service-i18n/CHANGELOG.md index d4c57f2d715..d355255f812 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,204 @@ # @objectstack/service-i18n +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 6e60680c01d..0b4d151f066 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-i18n", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading", "type": "module", diff --git a/packages/services/service-job/CHANGELOG.md b/packages/services/service-job/CHANGELOG.md index 078e408e492..b013c4b4137 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,213 @@ # @objectstack/service-job +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index bd280aea479..e294a94ec50 100644 --- a/packages/services/service-job/package.json +++ b/packages/services/service-job/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-job", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Job Service for ObjectStack — implements IJobService with setInterval and cron scheduling", "type": "module", diff --git a/packages/services/service-knowledge/CHANGELOG.md b/packages/services/service-knowledge/CHANGELOG.md index b26f06d15e6..432bef06d4f 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,199 @@ # @objectstack/service-knowledge +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index c86802c013c..7931924f664 100644 --- a/packages/services/service-knowledge/package.json +++ b/packages/services/service-knowledge/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-knowledge", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Knowledge Service for ObjectStack — orchestrator implementing IKnowledgeService over pluggable IKnowledgeAdapter backends (RAGFlow, LlamaIndex, Dify, in-memory).", "type": "module", diff --git a/packages/services/service-mailbox/CHANGELOG.md b/packages/services/service-mailbox/CHANGELOG.md new file mode 100644 index 00000000000..4468baa8592 --- /dev/null +++ b/packages/services/service-mailbox/CHANGELOG.md @@ -0,0 +1,228 @@ +# @objectstack/service-mailbox + +## 18.0.0-next.0 + +### Minor Changes + +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 diff --git a/packages/services/service-mailbox/package.json b/packages/services/service-mailbox/package.json index 683d0eaf92d..f8c4c3395a7 100644 --- a/packages/services/service-mailbox/package.json +++ b/packages/services/service-mailbox/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-mailbox", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Mailbox Service for ObjectStack \u2014 inbound mail: the mirrored message (sys_mail_message), its record associations and their read gates.", "type": "module", diff --git a/packages/services/service-messaging/CHANGELOG.md b/packages/services/service-messaging/CHANGELOG.md index 07a403f9d45..6dc4d86f00b 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,316 @@ # @objectstack/service-messaging +## 18.0.0-next.0 + +### Minor Changes + +- 0db5ad5: fix(service-messaging)!: mark-read writes a read receipt only for a notification delivered to that user + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention: `MessagingService.markRead` — the method behind the notifications mark-read door, and behind `markReadAsCaller` — writes a `read` receipt only for a notification that was delivered to that user. A read receipt belongs to a recipient (ADR-0030 keys it by recipient). + + - **Delivered** means a receipt keyed on that user already exists (it is flipped to `read` in place, as before), or the user's inbox holds a message for that notification. The inbox message is enough on its own, because the inbox channel's `delivered` receipt is best-effort. + - **Any other id** writes no receipt, is not counted in `readCount`, and its notification's organization is not read. The response shape `{ success, readCount }` is unchanged. + - **`markAllRead`** is unchanged: every id it sweeps comes from the user's own inbox. + + What changes for you: nothing in what you write. A `readCount` lower than the number of ids sent means some of them were not notifications delivered to that user, and nothing was written for those. + +### Patch Changes + +- a7a48b7: The storage store's by-id methods and the HTTP outbox's `redeliver` now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-storage.** `StorageMetadataStore.getFile`, `updateFile`, `deleteFile`, `getSession`, `updateSession` and `deleteSession` take the opt-in inside the store. Access stays by id, and the reads stay unscoped by organization, as before. On update and delete the acting organization still reaches the driver beside the opt-in, so a row stamped for another organization is still out of reach of these doors. The doors keep the authorization they already ran. + - **service-storage, the update payload.** `updateFile` and `updateSession` now send the caller's patch alone, where they used to send the whole row read back merged with it. The engine's read-only strip, which does not run for a system write, used to take `organization_id` and the four audit columns out of that row; now the store never sends them. The stored row is the same as before, and a column another writer changed between the read and the write is no longer reverted by it. + - **service-messaging.** `SqlHttpOutbox.redeliver` takes the opt-in on both of its reads and on its reset write. The caller's `tenantId` stays on every call as the driver-level scope, so a delivery in another organization is still not found. The reset write states `bypassTenantAudit: false`, so a redelivery from a caller with no organization is still reported by the driver's tenant audit. + - None of the gates the security middleware runs before its hand-off applies to these calls. ⛔ No new export on either package entry, and no new elevation API. +- b88c356: The remaining platform producers in these four packages now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-messaging, the inbox read state.** `listInbox` (and its unread total), the receipt read behind it, and mark-read / mark-all-read take the opt-in inside the service. Their scope is unchanged: every read of a user's rows is keyed on the user id the door derived from the session, the receipt a mark-read inserts is stamped with it, and the receipt it updates is one a user-keyed read returned. + - **service-messaging, `owner_of:` audiences.** The record read takes the opt-in, the same posture as the email lookup beside it. It reads only `id` and the owner fields, and only the owner id leaves the resolver. An `owner_of:` audience on an object whose sharing model is `private` now resolves its owner; before, it resolved to nobody. + - **service-messaging, the rest of the fan-out and the outboxes.** The `role:` and `team:` membership reads, the email and SMS recipient reads, the notification template read, the dedup lookup in `emit()`, and both outboxes' enqueue, ack and list. + - **service-storage.** `StorageMetadataStore.createFile` and `createSession` insert under the opt-in. The organization still reaches the driver beside it, so the stored organization is unchanged, and the file's `owner_id` is still the uploading user. + - **service-settings.** The `sys_secret` store the plugin builds (insert, get, update), and the read that verifies a rotation before the old secret is reaped. A store `update` now writes the `ciphertext` it is given; without a context the engine's read-only strip dropped it. No caller in this repository uses `update`. + - **metadata-protocol.** `SysMetadataRepository.getByHash`, `list`, `history` and the history replay of `watch()`. + - None of the gates the middleware runs before its hand-off applies to these calls. ⛔ No new export on any package entry, and no new elevation API. +- d294951: Boot-time system-table provisioning honours `OS_SKIP_SCHEMA_SYNC` / `skipSchemaSync` + + Clause-②: no + + `OS_SKIP_SCHEMA_SYNC=1`, or `ObjectQLPlugin`'s `skipSchemaSync` option, declares that a deployment's DDL is managed out-of-band ("Use after running migrations manually"). The engine's boot honoured it, but `AuditPlugin` and `MessagingServicePlugin` still created their system tables at `kernel:ready`. A deployment that set the flag got tables created behind its back while its boot log said no DDL ran, and a database role without DDL rights got a DDL failure it never asked for. + + - **What changes.** With the flag set, `AuditPlugin` (`sys_audit_log`, `sys_activity`, `sys_comment`, `sys_comment_reaction`) and `MessagingServicePlugin` (`sys_notification`, `sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_subscription`, `sys_notification_template`, `sys_http_delivery`) issue no DDL and no existence probe at boot. Each logs one `info` line naming the objects it did not provision and the flag, and pointing at `os migrate apply`, which creates any that are missing (`os migrate plan` lists them as `create_table`). That path was measured to create all twelve tables, on one database, with the telemetry sibling database, and with the flag set in the migrate run's own environment. + - **Which value is read.** The one `ObjectQLPlugin` resolved: the option, else the environment variable. An explicit `skipSchemaSync: false` therefore still provisions under `OS_SKIP_SCHEMA_SYNC=1`. `ObjectQLPlugin` publishes that value on the engine instance it registers. It is a property of the instance, not a declared member, so no type or export changes. + - **What is unchanged.** With the flag unset, provisioning is exactly as before. `syncObjectSchema` is not gated, so on a flag deployment a runtime publish still gives a new object its table. +- 6a6a4d3: fix: a refused on-demand schema sync (`syncObjectSchema`) is logged at `error`, naming what was not created and how to fix it (#22985) + + Three places create an object's storage after the boot's own schema sync has run. When the driver refused that DDL, each of them logged a `warn` and carried on, so the deployment looked healthy while a table did not exist. Each now logs one `error` line that says what was not created, that nothing else will look wrong, and what fixes it. The driver's refusal is kept out of the line itself. Nothing else changes: the boot, the connect and the publish still complete as before. + + - **`@objectstack/service-messaging`**: boot-time provisioning of the eight notification tables. A refused table logs `[messaging] could not provision
storage — its storage was NOT created. …` at `error`, once per table per boot, with the refusal in the record's meta. The fix it names: resolve the refusal, then restart or run `os migrate apply`. This used to be a `warn`. Under `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` the provisioning still issues no DDL, so this line cannot appear. + - **`@objectstack/service-datasource`**: connecting a declared datasource re-runs the schema sync of every object bound to it. A refused object logs `datasource '': object '' has NO working storage here — …` at `error`, once per object per connect, with the refusal in meta. On a logger that has no `error`, the same line is logged at `warn`. The fix it names: resolve the refusal, then restart. This used to be a `warn` reading `syncObjectSchema('') failed`. + - **`@objectstack/metadata-protocol`**: publishing an object whose storage sync is refused logs `[Protocol] object '' was published but its storage was NOT created or altered — …` with `console.error`. The driver's refusal is the second argument. The fix it names: resolve the refusal, then publish the object again or restart. The publish still answers success. This replaces the `console.warn` line `[Protocol] table sync failed for object '': …`, so a log search for that text now finds nothing. +- 5d63c37: The notification and HTTP outboxes read their claim credential back from the store, so `ack()` lands on a Postgres table whose epoch-ms columns are still `real` + + Clause-②: no + + Tables created while the `number` field type still mapped to `real` keep those columns as IEEE-754 binary32. Newer tables get an exact numeric column, and existing columns are not migrated. A binary32 `claimed_at` stores a rounding of the claim's `now`, to the nearest 131 072 ms at today's magnitude, and reads back a nearby decimal. `SqlNotificationOutbox` and `SqlHttpOutbox` handed out the in-memory `now` as the claim credential, so it never equalled the stored value. Every `ack()` was refused with `DELIVERY_NOT_ELIGIBLE` as a lost claim, and the dispatcher absorbed the refusal. The row stayed `in_flight` with `attempts = 0`, and every reap handed it to the next claim. The notification was re-delivered about every 131 s, with no end. + + - **What changes.** `claim()` and `claimDigest()` return `claimedAt` as the `claimed_at` value the store returned after the claiming update. The value written is unchanged. The rows a claim took are identified by candidate id, holder and status. `claimed_at` is no longer compared with the client's `now` in that read. `ack()`'s ownership check and its compare-and-set are unchanged. They now compare that stored value with the stored value, which is exact on any numeric column. + - **A stuck row heals by itself.** A row left `in_flight` by a refused ack is reaped by the visibility timeout, claimed again, sent once and acked. No operator act is needed. On a `real` column the reap still runs at binary32 granularity, up to about 131 s after `claimTtlMs`. + - **What is unchanged.** A genuinely lost claim is still refused. If another node, or this node's own later claim, reaped and re-claimed the row while a send ran, the late ack gets `DELIVERY_NOT_ELIGIBLE` and writes nothing. The `ClaimedDeliveryRecord` and `HttpClaimCredential` types are unchanged. Existing `real` columns are not migrated, and duplicate inbox rows that were already delivered stay as they are. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 78297685516..0c7eacffb54 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-messaging", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Messaging Service for ObjectStack — outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.", "type": "module", diff --git a/packages/services/service-package/CHANGELOG.md b/packages/services/service-package/CHANGELOG.md index ad56f341a12..88351a90a40 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,216 @@ # @objectstack/service-package +## 18.0.0-next.0 + +### Patch Changes + +- 84c4fff: `os migrate plan` / `apply` cover `sys_packages`, and the boot honours `OS_SKIP_SCHEMA_SYNC` for it + + Clause-②: no + + `sys_packages`, the package registry's table, is created by `PackageServicePlugin.start()` with its own DDL, because it keys on `(id, version)` and a declared object cannot carry that key. So it was never on the managed migration path: `os migrate plan` did not list it and `os migrate apply` did not create it. A deployment that sets `OS_SKIP_SCHEMA_SYNC=1` ("Use after running migrations manually") still had it created at boot, and had no documented way to create it otherwise. + + - **The migration path covers it.** `PackageServicePlugin.init()` declares the table as its `package.storage` kernel service: a read-only catalog probe (SQLite, PostgreSQL and MySQL each ask their own catalog) and the boot's own create statements. `os migrate plan` lists `sys_packages` as `create_table` when it is absent, and `os migrate apply` creates it, with the same statements the boot runs, so the table and its `idx_packages_latest` index come out exactly as a boot creates them. Whether the flag is set does not matter to either command. Where the table's presence cannot be read (a driver that names no SQL dialect), the run says it did not examine the table, and neither lists nor creates it. + - **Then the boot honours the flag.** With the flag set, `PackageServicePlugin` issues no DDL and no existence probe at boot, and logs one `info` line naming `sys_packages` and the flag, and pointing at `os migrate apply`. The `package` service is still registered. The value read is the one `ObjectQLPlugin` resolved (the `skipSchemaSync` option, else the environment variable), as `plugin-audit` and `service-messaging` read it. On a flag deployment, run `os migrate apply` before the first boot that needs the table. + - **What is unchanged.** With the flag unset, the boot creates the table exactly as before. An existing `sys_packages` table is not altered by either command; the plan lists nothing for it. No type or export changes. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 55a21150c43..cb4afda6f27 100644 --- a/packages/services/service-package/package.json +++ b/packages/services/service-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-package", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Package management service for ObjectStack — publish, install, and manage packages", "type": "module", diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md index b7ade7b2233..b666221a7c9 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,213 @@ # @objectstack/service-queue +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index ba925bba3ed..edbb09de8a4 100644 --- a/packages/services/service-queue/package.json +++ b/packages/services/service-queue/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-queue", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Queue Service for ObjectStack — implements IQueueService with in-memory and durable DB-backed (sys_job_queue) adapters", "type": "module", diff --git a/packages/services/service-realtime/CHANGELOG.md b/packages/services/service-realtime/CHANGELOG.md index 3c2d77eeeb5..3f8d16a485a 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,275 @@ # @objectstack/service-realtime +## 18.0.0-next.0 + +### Minor Changes + +- 1920cf3: feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`. + + With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces. + + **What moves for consumers.** + + - **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables. + - **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`. + - **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables. + + **Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one. + + **Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names. + +### Patch Changes + +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 1fcbeee339d..60516c3516c 100644 --- a/packages/services/service-realtime/package.json +++ b/packages/services/service-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-realtime", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Realtime Service for ObjectStack — implements IRealtimeService with WebSocket and in-memory pub/sub", "type": "module", diff --git a/packages/services/service-settings/CHANGELOG.md b/packages/services/service-settings/CHANGELOG.md index 41a4657bfdd..3cf3d05238d 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,339 @@ # @objectstack/service-settings +## 18.0.0-next.0 + +### Minor Changes + +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- c52bfb4: feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. + + - **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. + - **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. + - **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. + - **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. + + **What moves for consumers.** + + - **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. + - **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. + - **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. + - **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. +- 7518ee3: fix(service-settings)!: the user rung answers only its owner, and a write of a user-scoped key must name its user + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A settings key declared `scope: 'user'` belongs to one user. `SettingsService` now holds that on both its read and its write side. + + - **Reads.** The user rung answers only a caller whose `SettingsContext.userId` is present and equals the row's owner. A resolve whose context names no user (no `userId`, or an empty one) has no user rung: it answers from the tenant rung, then global, then the manifest default. This holds on every read path that reaches the cascade: `get`, `getMany`, `getNamespace`, `createClient`, `runAction` and `resetNamespace`. + - **Refused now.** `set` and `setMany` refuse a user-scoped key when the context names no user, a `null` reset of one included. The refusal is a `SettingsValidationError` (`code: 'SETTINGS_VALIDATION'`) with one `fields` entry per such key (`code: 'invalid_value'`, `constraint: { scope: 'user' }`). It refuses the whole batch, before anything is written. + - **Unchanged.** A caller with a user id reads and writes only their own rows, as before. Keys declared at `scope: 'tenant'` or `'global'` resolve and write the same with or without a user id. The settings HTTP routes resolve a signed-in user before they read or write any row, so they behave as before. + + What changes for you: when your code calls the settings service for a user-scoped key, pass the user id of the person the value belongs to. A value meant for everyone belongs on a key declared at `scope: 'tenant'` or `'global'`. None of the settings namespaces this repository ships declares a user-scoped key. +- 79c35d4: fix(service-settings)!: tenant- and user-scope settings rows carry the caller's organization, and the data API read of the settings stores applies each namespace's readPermission + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `sys_setting` declares its row identity as `(organization_id, namespace, key, scope, user_id)`. `SettingsService` now carries the organization in that identity itself, on every read and write of a `tenant` or `user` row, because it reads and writes the store under its own system context, which no driver tenant scope or organization wall reaches. + + - **Writes.** A `tenant` or `user` row is written with the caller's organization (`SettingsContext.tenantId`) in its key and in its stored `organization_id`. A write by one organization updates only that organization's row. + - **Reads.** The tenant and user rungs draw on the caller organization's rows and on rows stored with no organization, and take the caller organization's own row when it has one. A row stored with no organization stays the fallback for every organization until that organization writes its own. A caller that names no organization reads only the rows stored with no organization under a walled posture (`group`, `isolated`), and every row under `single`. The global rung (`sys_platform_setting`) is unchanged and read by every organization. + - **Locks.** The lock check on a write reads the same upper rows the caller's cascade reads, so a lock on one organization's tenant row locks nothing for another organization. + - **Refused now.** Under a walled posture, `set` and `setMany` refuse a key declared `scope: 'tenant'` when the context names no organization, a `null` reset of one included. The refusal is a `SettingsValidationError` (`code: 'SETTINGS_VALIDATION'`, HTTP 400 at the settings routes) with one `fields` entry per such key (`code: 'invalid_value'`, `constraint: { scope: 'tenant' }`). It refuses the whole batch, before anything is written. The posture is the one the `tenancy` service reports; `SettingsServicePlugin` supplies it through `bindEngine`. + - **Generic read door.** `SettingsServicePlugin` registers an engine middleware on `sys_setting`, `sys_setting_audit` and `sys_platform_setting`: every non-system read (`find`, `findOne`, `count`, `aggregate`) is narrowed to the namespaces whose `readPermission` the principal holds, by the same rule `GET /api/settings/:namespace` applies. A namespace with no registered manifest reads at the default capability, `setup.access`. + - **Unchanged.** Under `single`, a caller in the default organization reads every value it read before, and a process-wide reader that names no organization reads the organization's current value. Keys declared at `scope: 'global'` resolve and write the same for every caller. + + What changes for you: write a tenant-scope setting from inside the organization it belongs to (an active organization on the session, or `SettingsContext.tenantId` in process). Rows already stored with no organization are not rewritten. +- 20949e7: fix(service-settings)!: the settings REST family refuses an anonymous caller with the shared 401 + + Clause-②: no — it narrows the anonymous accept set to the ADR-0056 D2 floor; no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `registerSettingsRoutes` mounts the settings REST family. Until now it ran no anonymous floor: a caller with no session was answered by the family's own capability logic, with a status other than `401`. ADR-0056 D2 denies anonymous callers by default, and ADR-0138 D2 says a surface outside its door classes answers `401`. + + - **Refused now:** every route of the family answers a caller with no user `401` with code `UNAUTHENTICATED`, in the declared error envelope, before the settings service is consulted. The decision is the platform's shared one (`shouldDenyAnonymous`), mounted once for the whole family. + - **Unchanged:** a signed-in caller is served exactly as before, and the per-namespace read and write capabilities still decide what it may see and change. An authorization-store outage still answers its `503`. + + What changes for you. Sign in before calling the settings routes. If you mount `registerSettingsRoutes` yourself with your own `contextFromRequest`, it must put the caller's `userId` on the context it returns: a context without one is anonymous now, whatever capabilities it carries. + +### Patch Changes + +- b88c356: The remaining platform producers in these four packages now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-messaging, the inbox read state.** `listInbox` (and its unread total), the receipt read behind it, and mark-read / mark-all-read take the opt-in inside the service. Their scope is unchanged: every read of a user's rows is keyed on the user id the door derived from the session, the receipt a mark-read inserts is stamped with it, and the receipt it updates is one a user-keyed read returned. + - **service-messaging, `owner_of:` audiences.** The record read takes the opt-in, the same posture as the email lookup beside it. It reads only `id` and the owner fields, and only the owner id leaves the resolver. An `owner_of:` audience on an object whose sharing model is `private` now resolves its owner; before, it resolved to nobody. + - **service-messaging, the rest of the fan-out and the outboxes.** The `role:` and `team:` membership reads, the email and SMS recipient reads, the notification template read, the dedup lookup in `emit()`, and both outboxes' enqueue, ack and list. + - **service-storage.** `StorageMetadataStore.createFile` and `createSession` insert under the opt-in. The organization still reaches the driver beside it, so the stored organization is unchanged, and the file's `owner_id` is still the uploading user. + - **service-settings.** The `sys_secret` store the plugin builds (insert, get, update), and the read that verifies a rotation before the old secret is reaped. A store `update` now writes the `ciphertext` it is given; without a context the engine's read-only strip dropped it. No caller in this repository uses `update`. + - **metadata-protocol.** `SysMetadataRepository.getByHash`, `list`, `history` and the history replay of `watch()`. + - None of the gates the middleware runs before its hand-off applies to these calls. ⛔ No new export on any package entry, and no new elevation API. +- 98998ca: fix(service-settings): the Default timezone help speaks to administrators, not in formula-function names + + Clause-②: no + + The help under Settings → Localization → Default timezone (also shown in the first-run "set the workspace timezone" prompt) read "IANA zone for today()/daysFromNow, analytics date buckets, and rendered datetimes." It now says what the setting does: "Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris)." The same meaning ships in every built-in locale (en, zh-CN, ja-JP, es-ES) and in the manifest's own `description`, which `GET /api/settings/localization` serves and the console falls back to. + + Wording only: the `timezone` key, its default (`UTC`), its accepted values (any IANA zone) and what it drives are unchanged. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index daf7abcc816..2b9885e7863 100644 --- a/packages/services/service-settings/package.json +++ b/packages/services/service-settings/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-settings", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Settings service for ObjectStack — manifest registry + K/V resolver (OS_* env > Tenant > User > Default) + REST routes. See ADR-0007.", "type": "module", diff --git a/packages/services/service-sms/CHANGELOG.md b/packages/services/service-sms/CHANGELOG.md index ecd29b40f57..e88da69a817 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,252 @@ # @objectstack/service-sms +## 18.0.0-next.0 + +### Minor Changes + +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. + +### Patch Changes + +- f9c7588: A configured SMS provider delivers through its credentials in the environment or in Settings, without `OS_SMS_PROVIDER` + + Clause-②: no + + A configuration that named `provider: 'twilio'` (or `'aliyun'`) and kept the provider's keys in the environment, but did not also set `OS_SMS_PROVIDER`, booted on `LogSmsTransport` with `isConfigured() === false`, through `os serve` and `@objectstack/verify`'s `bootStack` alike. The boot logged "SMS will NOT be sent". Two readers missed the credentials: + + - **`resolveSmsCapabilityArg`** now merges the selected provider's credentials into `providerOptions`, env over `config.sms.providerOptions`, key by key, as the mail resolver does. It reads the variables the `sms` settings namespace is env-locked with: `OS_SMS_TWILIO_ACCOUNT_SID`, `_AUTH_TOKEN`, `_FROM_NUMBER` and `_MESSAGING_SERVICE_SID`, and `OS_SMS_ALIYUN_ACCESS_KEY_ID`, `_ACCESS_KEY_SECRET`, `_SIGN_NAME` and `_TEMPLATE_CODE`. + - **`SmsServicePlugin`** honours its constructor's `provider` when the `sms` settings namespace names none, and reads the namespace for that provider's credentials. The namespace's credentials overlay the constructor's `providerOptions`, so a constructor-only option such as `baseUrl` is kept. A provider named in Settings, or by `OS_SMS_PROVIDER`, still wins. + + Unchanged: a configured provider whose credentials are missing everywhere still boots onto the log transport with a warning and `isConfigured() === false`. The resolver still refuses only a provider tag no transport can deliver through. Signatures and exports are unchanged. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [4f4c4ed] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [8a995b8] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [f9ebb7d] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/plugin-auth@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 76c360fd2d4..ebabb385d5a 100644 --- a/packages/services/service-sms/package.json +++ b/packages/services/service-sms/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-sms", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "SMS service for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).", "main": "dist/index.js", diff --git a/packages/services/service-storage/CHANGELOG.md b/packages/services/service-storage/CHANGELOG.md index a1fa78acdfd..9bc68ba8141 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,553 @@ # @objectstack/service-storage +## 18.0.0-next.0 + +### Minor Changes + +- 29678f2: fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the user who started the upload + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `POST /api/v1/storage/upload/complete`, `POST /api/v1/storage/upload/chunked/:uploadId/complete` and `GET /api/v1/storage/upload/chunked/:uploadId/progress` now act only for the user who started the upload. The rule is declared once and each of the three doors consults it: the signed-in user must be the file's `owner_id`, which both upload-start doors already stamp from the session. A chunked upload reaches its uploader through its file. + + - **Refused now:** every other caller, with `403 PERMISSION_DENIED`. The answer is the same whichever organization the caller acts in, and it comes before anything is written or returned. There is no administrator exception. A file with no recorded uploader, or a chunked upload whose file is gone, is refused the same way rather than given a guessed owner. + - **Unchanged:** the uploader's own calls, who may start an upload, who may download a file (the download doors' own authorization), the organization the doors stamp and scope by, and the not-found answers. A deployment with no `auth` service, whose upload routes run without a session resolver, keeps them open as before. + + What changes for you: complete and poll an upload as the same signed-in user who started it. An upload left pending with no recorded uploader cannot be committed; start it again. +- 59d993c: fix(service-storage)!: the storage settings' Limits group (`max_upload_mb`, `presigned_ttl`, `session_ttl`) is enforced at the upload doors + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Setup's File Storage page has always rendered a "Limits" group — "Max upload size (MB)", "Presigned URL TTL (seconds)" and "Upload session TTL (seconds)" — and saved it, but nothing read it. `StorageServicePlugin` now reads the group on every settings pass and its upload doors honour it, on the plugin's own mount and on a host's `mountStorageRoutes` mount alike. + + **What stops being accepted.** Where the plugin is bound to the `storage` settings namespace (its default, `bindToSettings: true`, with a settings service present), an upload larger than the resolved `max_upload_mb` is refused with `413` and the registered code `PAYLOAD_TOO_LARGE`, in the usual error envelope, with a message naming the limit and the setting. Nothing is stored: no `sys_file` or `sys_upload_session` row is written and no byte reaches the adapter. The doors that judge it: + + - `POST /storage/upload/presigned` — the declared `size`; + - `POST /storage/upload/chunked` — the declared `totalSize`; + - `PUT /storage/_local/raw/:token` (the local adapter's presigned upload) — the body, by its `content-length` before it is read and by the bytes actually read; + - `PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex` — the bytes the session has received so far plus this chunk, the same two ways. + + With nothing saved, the limit is the setting's declared default, **100 MB** (counted as MiB: 104,857,600 bytes). Uploads above it used to be stored and are now refused. The largest value the setting accepts is 10240 MB. + + **What else changes.** A saved `presigned_ttl` is now the lifetime of every presigned upload URL and of every non-gated download URL; a saved `session_ttl` is the lifetime of every new chunked upload session. URLs and sessions issued before a save keep the lifetime they were issued with. With nothing saved both are unchanged — 3600 s and 86400 s, the declared defaults and the built-in ones being the same numbers. A save reaches the doors without a restart. + + **Precedence, per key.** A saved value (an admin save or an env override) wins over the host's `presignedTtl` / `sessionTtl` option (the plugin constructor's, or `mountStorageRoutes`'), and the host's option wins over the namespace's declared default — the precedence the adapter keys already had. + + **What stays accepted.** + + - A mount with no settings namespace bound — no settings service, `bindToSettings: false`, or a `mountStorageRoutes` kernel whose `storage` service is not this plugin's — keeps no size limit and its option TTLs. + - On the S3 adapter, a presigned upload is judged by its declared size only: the bytes go straight to the bucket, so the platform never sees them. + - The gated download URL keeps its own short lifetime (`downloadTtl`, 300 s); `presigned_ttl` does not apply to it. + + **What changes for you.** If your deployment accepts uploads larger than 100 MB, set "Max upload size (MB)" in the File Storage settings to the size you need before upgrading; a larger upload receives the `413` above. + + **New API (additive).** `StorageRoutesOptions.limitsSnapshot`, the `StorageLimitsSnapshot` and `StorageLimitReading` types, and `SwappableStorageService.setLimitsSnapshot()` / `getLimitsSnapshot()` — the Limits group as the plugin last read it, which the route composition hands to the doors. +- e36ee53: fix(service-storage)!: a chunked upload completes with the file it was sent — the completion door assembles the parts the upload holds, and a re-sent chunk is counted once + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What was wrong.** `POST /storage/upload/chunked/:uploadId/complete` assembled the parts the *request* listed. The SDK's `resumeUpload` lists only the chunks its own pass sent, so an upload whose first chunks were stored before an interruption completed with `200` and the declared `size`, while the stored file held only the resumed chunks. And `PUT …/chunk/:chunkIndex` added a re-sent chunk to the session's progress a second time, so `GET …/progress` overstated `uploadedSize`, `uploadedChunks` and `percentComplete` — the counts `resumeUpload` resumes from. + + **What the completion door does now.** It assembles the upload's own record of the chunks it holds — every chunk the chunk door stored, with the eTag the backend answered for it — and checks the request's `parts` against that record. A resumed upload therefore completes with the whole file, whatever subset of parts the client lists, and the SDK's `resumeUpload` needs no change. + + **What stops being accepted.** The door refuses with `409` and the standard code `RESOURCE_CONFLICT`, before anything is assembled, when: + + - the upload does not hold every chunk it declared (chunk indexes `0` to `totalChunks - 1`), or holds one beyond them; + - the chunks it holds do not add up to the declared `totalSize`; + - the request lists a chunk the upload does not hold, or lists one with an eTag other than the one the upload holds for it. + + `error.details` carries `missingChunks`, `unexpectedChunks`, `unheldListedChunks` and `mismatchedChunks` (zero-based chunk indexes), with `totalChunks`, `totalSize` and `heldBytes`. The session stays `in_progress` and the file stays `pending`: upload the missing chunks with `PUT …/chunk/:chunkIndex`, then complete again. A `parts` value that is not a list of `{ chunkIndex, eTag }` is refused `400 INVALID_REQUEST`; an absent `parts` is still an empty list. + + **What changes for you.** A client that completed an upload before sending all of its bytes — including a completion with no chunks at all, which used to commit an empty file — now receives the `409` above. Send every chunk first. + + **Progress.** A chunk index sent again replaces its slot, as it replaces its bytes in the backend: `uploadedChunks` counts distinct chunks and `uploadedSize` is the sum of each chunk's latest size. A session started before chunk sizes were recorded keeps its running byte total until it ends, and is completed only when that total reaches `totalSize`. +- dd986d8: fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The storage download routes, both the one that answers a signed URL and the stable one that redirects to the bytes, now require a signed-in caller for a file that has neither an attachments scope nor a field owner: an upload no record has claimed. That is the class an avatar or an organization logo stored as a URL belongs to, and so is a picked file not yet saved to its record. ADR-0104 made the anonymous capability URL an opt-in, `acl: 'public_read'`; this was the one class still served anonymously by default. + + - **Refused now:** a caller with no session, with `401 AUTH_REQUIRED`, the answer the upload routes and the attachments gate already give an unauthenticated caller. No signed URL is minted for the refused caller. + - **Unchanged:** a signed-in caller is served exactly as before, including the signed URL's lifetime. A browser's `` and `` send the session cookie the sign-in set, so a signed-in page keeps rendering these files. A file marked `acl: 'public_read'` stays anonymous. Attachments-scope and field-owned files keep their parent-record verdicts. A deployment with no `auth` service, whose storage routes run without a session resolver, keeps these downloads open as before and says so once in its log. + + What changes for you. Before this release, anyone holding such a file's id could download it; now, sign in first. A file that must render before sign-in (on a sign-in page, in an email, on a public page) needs `acl: 'public_read'` on its `sys_file` row. +- ee8751d: feat(storage)!: the storage scope `public` is retired — no scope ever made a file publicly readable, and `acl: 'public_read'` stays the one opt-in for anonymous download (#22443) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on two published surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. + + `StorageScopeSchema` described `public` as "publicly accessible static assets", and the upload doors stored a caller's `scope: 'public'` on the new file. Neither ever made a file public. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope `public` and the default acl was stored private, needed a signed-in caller, and nobody was told (ADR-0049 enforce-or-remove). ADR-0104 makes `acl: 'public_read'` the one opt-in for anonymous download, so the scope is retired, not enforced: enforcing it would have let any uploader make a file anonymous at upload. + + ### What now refuses `public` + + - **The presigned upload door and the chunked upload door** (`@objectstack/service-storage`) answer an upload naming `scope: 'public'` with `400 INVALID_REQUEST`, before any file row, session row, upload URL or backend upload exists. The message names the remedy. Every other scope, and an omitted one, is taken exactly as before. + - **`StorageScopeSchema`** (`@objectstack/spec`): `public` left the enum. Writing it fails `tsc`, and parsing it, on its own or as `ObjectStorageConfig.scope`, fails with the prescription instead of zod's generic enum message. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload with `scope: 'public'` | another scope, or no `scope` for the default `user`; then set `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in | + | `ObjectStorageConfig` with `scope: 'public'` | another scope, or no `scope` for the default `global` | + + **The one-line fix: stop naming scope `public`, and mark each file that must render before sign-in `acl: 'public_read'` on its stored file record.** The upload request carries no `acl`: every upload is stored `acl: 'private'`, so adding an `acl` to the upload request changes nothing. + + **Files already stored with scope `public`** download exactly as they did: a signed-in caller gets them, an anonymous one gets `401`, unless the file is `acl: 'public_read'`. Their stored file records are rewritten to scope `user` by the one-time operator sweep `@objectstack/service-storage` exports, which changes no access: the storage key and the bytes stay as they are. + + ### The retirement kit + + - **Schema.** `StorageScopeSchema` retires the member with `enumWithRetiredValues`. No D2 conversion: no metadata type carries this schema or the upload request, so `os migrate meta` has no authored source to rewrite. + - **D3 entry `storage-scope-public-retired`** carries the judgement no rewrite can make: whether a file that was uploaded as `public` must really be readable before sign-in. + - **Upload request contract.** The `scope` description on the presigned and chunked request schemas no longer offers `public` as an example, and says what the scope is and is not. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries `StorageScope`. + + **Measured producers: none.** At origin/main da159f74e6, nothing in `packages/`, `examples/`, `apps/`, `skills/` or `content/docs/` uploads with scope `public` or declares an `ObjectStorageConfig` with it. The one hit was a `@objectstack/spec` request-schema test fixture, changed to `tenant` here. The same search finds 29 `scope: 'attachments'` writes, which is its control. At the `.objectui-sha` pin f0268ad784 and at objectui main 2063f7a, the upload adapter forwards a caller's scope and no caller names `public`: the console passes none, and the record attachments panel passes `attachments` (the control). Deployed callers and stored rows NOT MEASURED. +- 96e4be4: feat(service-storage)!: the `sys_file` scope option `public` is retired, and rows already stored with it are rewritten to `user` by a one-time operator sweep (#22443) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on the `sys_file` object's `scope` select, shipped as `minor` under the launch-window convention for accept-set narrowings. It completes the retirement registered as `storage-scope-public-retired`: `StorageScopeSchema` and both upload doors already refuse `public`, and now the stored vocabulary does too. + + ### What changes + + - **`sys_file.scope` no longer lists `public`.** Its options are `user`, `tenant`, `private`, `temp` and `attachments`. The engine refuses a write of `public` to the column as it refuses any undeclared option: `VALIDATION_FAILED`, `invalid_option` on `scope`. No access behaviour changes: the only scope value any code reads is `attachments`, and whether a file can be read before sign-in is decided by `acl: 'public_read'` alone. + - **Rows already stored with scope `public` are rewritten to `user`**, by a one-time sweep the operator runs (below), never at boot. `user` is the scope the upload doors and the field-reference copy path default to, and no reader tells it apart from `public`. One column moves: the storage key keeps its `public/` prefix, the acl and ownership columns are untouched, and no byte moves in the storage backend. + + ### FROM → TO + + | before | write instead | + | --- | --- | + | a `sys_file` row written with `scope: 'public'` | `scope: 'user'`, or no scope; and `acl: 'public_read'` on the row if the file must be readable before sign-in | + | rows already stored with `scope: 'public'` | nothing by hand: the sweep below rewrites them to `user` | + + **The one-line fix: stop writing scope `public` on `sys_file`, and run the sweep once on each deployment that stored it.** + + ### The operator step: run the sweep once after upgrading + + Until the sweep has run on a deployment that stored `public` rows, a record write that names a `public` file another field already owns is refused. The field-reference copy path copies the file into a new row with the source row's scope, the engine refuses `public` there, and the write fails with `ERR_FILE_REFERENCE_COPY`. The data REST doors answer that `500 INTERNAL_ERROR` ("Internal server error"); the server log carries the full sentence, which ends `Scope must be one of: user, tenant, private, temp, attachments`. Reads, downloads and updates that do not write `scope` are unaffected. + + The sweep ships in `@objectstack/service-storage` itself: `planSysFilePublicScopeBackfill`, `applySysFilePublicScopeBackfill`, `runSysFilePublicScopeBackfill` and `formatSysFilePublicScopeBackfillReport` are exported from the package, with their report types. It has the shape of the `sys_file` organization backfill: an operator step, never a boot hook. Import it from the package you upgraded to and run it server-side, from a context that holds the engine. It is a dry run first, and by default: + + ```ts + import { + planSysFilePublicScopeBackfill, + applySysFilePublicScopeBackfill, + formatSysFilePublicScopeBackfillReport, + } from '@objectstack/service-storage'; + + const plan = await planSysFilePublicScopeBackfill(engine); // counts, writes nothing + console.log(formatSysFilePublicScopeBackfillReport(plan)); + const applied = await applySysFilePublicScopeBackfill(engine, plan); // one scope write per row + console.log(formatSysFilePublicScopeBackfillReport(applied)); // keep it: it is the rollback list + ``` + + It counts before it writes (`scanned`), writes nothing where the count is zero, and is idempotent: every write moves its row out of `scope = 'public'`, so a second run scans and writes zero. A row whose write fails is reported, never retried, and is picked up by the next run. + + ### Rollback + + The inverse is `user` back to `public` on exactly the ids the applied report lists. This release cannot write it through the engine, which refuses `public` like any undeclared option. So the inverse goes with a code rollback: on the previous release, which still declares the option, write `scope = 'public'` back to those ids, through the engine there or as one raw driver statement against `sys_file` filtered to them. + + ### The step-18 entry says so too (`@objectstack/spec`) + + The D3 entry `storage-scope-public-retired`, which the protocol upgrade guide is built from, said that files already stored with scope `public` are not touched. That no longer holds for the `scope` column: after the sweep they read `user`. Its reason now names the `sys_file` option's retirement and the operator sweep above, and says that the rewrite changes no access: the storage key and the bytes stay as they are, and those files still download exactly as before. Its acceptance criteria add the sweep's end state: a dry run on each deployment scans zero `sys_file` records with scope `public`. +- ce3d0ad: fix(service-storage,plugin-audit)!: the `sys_attachment` and `sys_comment` parent gates judge a `controlled_by_parent` parent through its master, so a member can no longer attach files to, or delete other people's files or comments from, a child record they may not edit + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. It follows ADR-0055. + + **What stops being accepted.** + + - A `controlled_by_parent` record takes its access from its MASTER. Record sharing does not enforce on it, so the sharing service's edit verdict abstains on every such record, and both gates used to take that abstention as permission. A member holding the `sys_attachment` create or delete bit, or the `sys_comment` delete bit, could therefore attach files to, and delete other people's files and comments from, every master-detail child record of the organization. That included records whose own `PATCH` refuses the member, and records the member cannot read. + - Both gates now ask the sharing service's tri-state `checkEdit`. `allow` admits and `deny` refuses, as before. On an abstention they ask the security service's master-detail write check (`ISecurityService.checkControlledByParentWrite`), which is the answer a by-id update of the parent gets. Its `allow` and `not_applicable` admit. Its `deny` refuses, and so does an `unresolvable` outcome: a broken master-detail declaration, a record that does not exist, or an empty master reference. A datasource fault propagates with its `503`. + - The refusals keep today's envelopes. An attach or a re-point answers `403 ATTACHMENT_PARENT_ACCESS`. A delete of another user's file answers `403 ATTACHMENT_DELETE_DENIED`, and an update of it answers `403 RECORD_NOT_ACCESSIBLE`. A delete or update of another user's comment answers `403 RECORD_NOT_ACCESSIBLE`. A caller who cannot read the parent still gets the not-visible `403 PERMISSION_DENIED`. + + **What is unchanged.** An uploader still deletes their own file, and an author still deletes their own comment. Posting a comment still needs only read access to the record. A parent that is not `controlled_by_parent` is judged exactly as before, and a `public_read_write` parent still admits every member. A kernel whose security service does not serve the master-detail write check composes no master check, and there the gates admit on the abstention as before, which is also what the parent's own update gets. + + **What changes for you.** A member who was attaching or moderating on a child record through this gap now gets the refusal above. To give them that access, grant them edit access on the MASTER record: ownership of it, an `edit`-level record share, the `modifyAllRecords` bypass on the master object, or an app-authored `update` row-level security policy on the master that admits the row. Then their `PATCH` of the child and their attachment and moderation rights on it agree. The port types `AttachmentSharingLike` and `CommentSharingLike` now pick `checkEdit` rather than `canEdit`. The registered sharing service serves both, so a caller passing it is unaffected. `installAttachmentAccessHooks` and `installCommentAccessHooks` take an optional trailing resolver for the security service (new port types `AttachmentSecurityLike` and `CommentSecurityLike`). The two plugins pass it themselves. +- 1bb1da1: A reader refused `sys_file` read now sees the name, size and type of a file the record they are reading owns, whenever the download door would serve them its bytes + + Clause-②: yes + + The download door already judges a field-owned file by the record that owns it (ADR-0104 D3): a declared `fileAccessDelegate` decides where the owner object names one, otherwise the caller's read of that record. Record file-field hydration judged the same file by `sys_file` object read instead, which the default member grants do not include. So one reader could download a contract's signed PDF but saw the field as `{ id, metadataRefused: true }`: the bytes yes, the name no. + + - **From.** A reader with read on `contract` and no `sys_file` read gets `{ "signed_pdf": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }` for every file id the record holds. + - **To.** For a file whose `ref_object` / `ref_id` name the record being read, that reader gets the hydrated `{ "id": "f_7cQx2Lm90a", "name": "signed.pdf", "size": 2048, "mimeType": "application/pdf", "url": "/api/v1/storage/files/f_7cQx2Lm90a" }`, exactly as a `sys_file` reader does, when the door's field-owned verdict allows it. A delegate-governed object (for example `sys_approval_action`, delegate `approvals`) gets its delegate's answer, not the raw record read. + - **Unchanged.** Every other id keeps `{ id, metadataRefused: true }`: an id copied in from another record, an attachment-only or unclaimed file, a `public_read` file the record does not own, and an owned file the delegate or the record read refuses. `sys_file`'s own read rule is unchanged, so a direct `sys_file` query by the same reader is still refused. A reader with `sys_file` read is served by their own read, as before. The download door's verdict for every request is unchanged. + + **One verdict, two callers.** `@objectstack/service-storage` moves the download door's field-owned arm into one module-private function (not exported) and hands it to the engine through a new seam, `ObjectQL.registerFieldOwnedFileReadAuthorizer` (type `FieldOwnedFileReadAuthorizer`, exported from `@objectstack/objectql`), the same handover `registerHeldFileResolver` already makes. The engine never re-derives it. An engine with nothing registered keeps the refused marker for every id. On a refused hydration the engine makes one more `sys_file` read under the caller's tenant (it reads only ids whose ownership pair names a record in the result) and asks the verdict once per read. + + **For consumers.** Nothing to change. A refused reader may now receive the full file object where it used to receive the marker, and code that already handles all three `expanded` read forms handles this. + +### Patch Changes + +- a7a48b7: The storage store's by-id methods and the HTTP outbox's `redeliver` now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-storage.** `StorageMetadataStore.getFile`, `updateFile`, `deleteFile`, `getSession`, `updateSession` and `deleteSession` take the opt-in inside the store. Access stays by id, and the reads stay unscoped by organization, as before. On update and delete the acting organization still reaches the driver beside the opt-in, so a row stamped for another organization is still out of reach of these doors. The doors keep the authorization they already ran. + - **service-storage, the update payload.** `updateFile` and `updateSession` now send the caller's patch alone, where they used to send the whole row read back merged with it. The engine's read-only strip, which does not run for a system write, used to take `organization_id` and the four audit columns out of that row; now the store never sends them. The stored row is the same as before, and a column another writer changed between the read and the write is no longer reverted by it. + - **service-messaging.** `SqlHttpOutbox.redeliver` takes the opt-in on both of its reads and on its reset write. The caller's `tenantId` stays on every call as the driver-level scope, so a delivery in another organization is still not found. The reset write states `bypassTenantAudit: false`, so a redelivery from a caller with no organization is still reported by the driver's tenant audit. + - None of the gates the security middleware runs before its hand-off applies to these calls. ⛔ No new export on either package entry, and no new elevation API. +- b88c356: The remaining platform producers in these four packages now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-messaging, the inbox read state.** `listInbox` (and its unread total), the receipt read behind it, and mark-read / mark-all-read take the opt-in inside the service. Their scope is unchanged: every read of a user's rows is keyed on the user id the door derived from the session, the receipt a mark-read inserts is stamped with it, and the receipt it updates is one a user-keyed read returned. + - **service-messaging, `owner_of:` audiences.** The record read takes the opt-in, the same posture as the email lookup beside it. It reads only `id` and the owner fields, and only the owner id leaves the resolver. An `owner_of:` audience on an object whose sharing model is `private` now resolves its owner; before, it resolved to nobody. + - **service-messaging, the rest of the fan-out and the outboxes.** The `role:` and `team:` membership reads, the email and SMS recipient reads, the notification template read, the dedup lookup in `emit()`, and both outboxes' enqueue, ack and list. + - **service-storage.** `StorageMetadataStore.createFile` and `createSession` insert under the opt-in. The organization still reaches the driver beside it, so the stored organization is unchanged, and the file's `owner_id` is still the uploading user. + - **service-settings.** The `sys_secret` store the plugin builds (insert, get, update), and the read that verifies a rotation before the old secret is reaped. A store `update` now writes the `ciphertext` it is given; without a context the engine's read-only strip dropped it. No caller in this repository uses `update`. + - **metadata-protocol.** `SysMetadataRepository.getByHash`, `list`, `history` and the history replay of `watch()`. + - None of the gates the middleware runs before its hand-off applies to these calls. ⛔ No new export on any package entry, and no new elevation API. +- 7ebbc01: fix(service-storage): finishing an upload after switching active organization answers `409 RESOURCE_CONFLICT` naming the change, not a `500` that reads as a data-engine outage + + Clause-②: no + + `POST /api/v1/storage/upload/complete` and `POST /api/v1/storage/upload/chunked/:uploadId/complete` write by id under the caller's active organization, and that write is scoped to it. When the uploader's active organization is no longer the one the upload was started in, the write cannot reach the upload's row. Both doors used to answer that with `500 INTERNAL` and a message diagnosing a data-engine outage ("Restore the data engine …"). + + - **Now:** before writing anything, each door answers `409 RESOURCE_CONFLICT` in the standard error envelope. The message says the upload was started in a different organization than the active one, and that switching back finishes it. The server log carries one warning naming the upload, the organization it was started in and the active one. + - **Unchanged:** which organization an upload belongs to (switching back finishes it there); a commit or completion from the starting organization; a real data-engine failure, which still answers `500` with its consequence and fix; the uploader-only rule, which runs first, so a caller who did not start the upload is still refused `403 PERMISSION_DENIED` and learns nothing about organizations; uploads stamped with no organization; routes with no session resolver; a store with no data engine wired; and the progress door. + + What changes for you: when either door answers `409 RESOURCE_CONFLICT`, switch the active organization back to the one the upload was started in and retry the same call. +- 3513ac7: fix(service-storage): sending a chunk, or polling an expired upload's progress, after switching active organization answers `409 RESOURCE_CONFLICT` naming the change, not a `500` that reads as a data-engine outage + + Clause-②: no + + `PUT /api/v1/storage/upload/chunked/:uploadId/chunk/:chunkIndex` records each chunk on the upload session, and `GET /api/v1/storage/upload/chunked/:uploadId/progress` stamps a session past its `expires_at` as `expired`. Both writes go by id under the caller's active organization, and they are scoped to it. When the uploader's active organization is no longer the one the upload was started in, the write cannot reach the session row. Both doors used to answer that with `500 INTERNAL` and a message diagnosing a data-engine outage ("Restore the data engine …"). + + - **Now:** each door answers `409 RESOURCE_CONFLICT` in the standard error envelope, with the same message the commit and chunked-completion doors give: the upload was started in a different organization than the active one, and switching back finishes it. The chunk door answers it before the chunk reaches the storage backend and before any write. The progress door answers it only when the expiry stamp is due, before stamping. The server log carries one warning naming the door, the upload, the organization it was started in and the active one. + - **Unchanged:** which organization an upload belongs to (switching back finishes it there); a chunk or a progress read from the starting organization; the progress of a live session, or of one already `expired`, which is a read and still answers `200` from any organization; a real data-engine failure, which still answers `500` with its consequence and fix; the resume-token check on the chunk door and the uploader-only rule on the progress door, which run first, so a caller who cannot act on the upload learns nothing about organizations; sessions stamped with no organization; routes with no session resolver; and a store with no data engine wired. + + What changes for you: when either door answers `409 RESOURCE_CONFLICT`, switch the active organization back to the one the upload was started in and retry the same call. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- 54c3ce1: fix(service-storage): chunks sent in parallel to one chunked upload each record their part + + Clause-②: no + + `PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex` merges its chunk into the upload session's record of the chunks it holds (`parts`, `uploaded_chunks`, `uploaded_size` on `sys_upload_session`). It read that record, merged in memory and wrote the whole record back, so two chunk PUTs to one upload at the same time both answered `200` while the record kept only one of them: `GET …/progress` undercounted, and the completion was refused `409 RESOURCE_CONFLICT` naming the chunk the record had lost until the client sent it again. + + The record is now written with a compare-and-set: the write lands only while the row still holds the progress the chunk door read, and when another chunk's write landed first the door reads the record again and merges again. On a wired data engine this is the engine's own conditional update, evaluated in the same statement that writes, so it holds across server processes. Every chunk sent in parallel is recorded, and a parallel upload completes on its first completion. A sequential upload is unchanged. + + A chunk whose record write loses to another write on 16 attempts in a row is refused `409 RESOURCE_CONFLICT`, with `error.details` `{ chunkIndex, attempts }`: its bytes are stored, but the upload does not hold it. Send that chunk again. +- f782f17: fix(service-storage): an attach the attachment gate refuses no longer leaves the uploaded file stored forever — the caller's own never-attached file is tombstoned and reclaimed by the sweep + + Clause-②: no + + Attaching a file is two writes: the upload commits a `sys_file` (scope `attachments`), and then a `sys_attachment` insert attaches it to a record. When the attachment gate refused that insert with `403 ATTACHMENT_PARENT_ACCESS`, the file stayed `committed` with no attachment pointing at it. Nothing ever reclaimed it: files were tombstoned only when they lost their last attachment, and this one never had one. + + - **Now:** when the attachment gate refuses an attach, the file is tombstoned (`status: 'deleted'`, `deleted_at` set), the same way a file is tombstoned when its last attachment is removed. This covers every way the gate refuses: sharing denies edit on the parent, the parent's master record denies it (`controlled_by_parent`), or the parent cannot be read. From there the file follows the existing path. The lifecycle sweep reclaims the row and its bytes 30 days after `deleted_at`. At sweep time it checks again that nothing holds the file. + - **Only the caller's own unheld upload:** the file must be an `attachments`-scope, `committed` file. The refused caller must be its uploader (`sys_file.owner_id`). It must have no attachment and no field owner (`ref_*`). A refused attach that names someone else's file, a file another record still holds, or a field file changes nothing. + - **It survives a rollback:** the tombstone is written after the refusal, outside the refused write's transaction. If the attach ran inside a caller's own transaction, an `atomic` batch for example, rolling that transaction back does not undo the tombstone. + - **Unchanged:** the refusal itself (status, code, message), which does not depend on the file and says nothing about it; an admitted attach; and a retry. Retrying the same `file_id` within the 30 days is admitted or refused like any attach. An admitted retry attaches the file and brings it back to `committed`, as re-attaching a detached file always has. + - **Not covered:** an attach refused before the attachment gate runs still leaves the uploaded file `committed`. That is an attach with no `sys_attachment` create grant (`403 PERMISSION_DENIED`), or one to a parent with `enable.files` off (`403 FILES_DISABLED`). This is tracked in #22547. + + What changes for you: nothing to do. When the attachment gate refuses an attach, the uploaded file no longer uses storage indefinitely. +- 6a3fe25: feat(spec,service-storage,client)!: one upload-scope list — the upload requests' `scope` and the SDK's `storage.upload` close to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published request contract and on the SDK method that sends it, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. + + The presigned and chunked upload requests declared `scope` an open string, while the stored file record (`sys_file.scope`, a closed select) only ever took `user`, `tenant`, `private`, `temp` and `attachments`. Any other value reached the `sys_file` insert, the data engine refused it as an invalid option, and the upload door answered that caller error as `500 INTERNAL`, with a message telling the operator to restore the data engine. + + ### What changes + + - **`UploadScopeSchema` / `UploadScope`** (`@objectstack/spec`, new, exported from `@objectstack/spec/api`): the upload-scope vocabulary, declared once — `user`, `tenant`, `private`, `temp`, `attachments`. It is a different list from `StorageScopeSchema`, which classifies a storage configuration and is not read by any upload. + - **`GetPresignedUrlRequestSchema.scope` and `InitiateChunkedUploadRequestSchema.scope`** read it. The default stays `user`. A literal outside the list fails `tsc`, and a parse refuses it on the `scope` key. + - **`client.storage.upload(file, scope)`** (`@objectstack/client`): the `scope` parameter is typed `UploadScope` instead of `string`, default `user` unchanged, so a scope outside the list fails `tsc` at the SDK call. `client.storage.getPresignedUrl` and `client.storage.initChunkedUpload` take the request types above and narrow with them. + - **The `sys_file` scope select** (`@objectstack/service-storage`) takes its options from the enum, in its order, with the same labels. The stored values do not change. + - **The presigned upload door and the chunked upload door** answer a scope outside the list — any string, a case variant, `null`, a number — with `400 INVALID_REQUEST`, naming the allowed values, before a file record, a session record, an upload URL or a backend upload exists. `public` is refused by the same gate and keeps its remedy (`acl: 'public_read'` on the stored file record). An omitted scope is the default `user`, as before. A real data-engine fault still answers `500`. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload naming a scope outside the list (a key prefix such as `avatars`, a folder, a record path) | one of `user`, `tenant`, `private`, `temp`, `attachments`, or no `scope` for the default `user` | + | `client.storage.upload(file, scope)` called with a `scope` typed `string` (`@objectstack/client`) | pass one of the five, or type the value `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | + | a caller passing a scope typed `string` into either upload request | type it `UploadScope` | + + **The one-line fix: send one of the five upload scopes, or none.** `attachments` is for a file whose referrers are record attachment rows (it is what orphan tombstoning reads); `temp` for a scratch file; otherwise `user` or `tenant`. + + **No stored file record moves**: no upload naming another scope ever succeeded, so no stored record carries one. + + ### The kit + + - **D3 entry `upload-request-scope-closed`** carries the judgement no rewrite can make: which scope a file that was sent under a free name really belongs under. No D2 conversion: no metadata type carries the upload request, so `os migrate meta` has no authored source to rewrite. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries the upload request. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- 3d6392f: fix(service-storage): a presigned token the local adapter refuses answers `401 INVALID_TOKEN` on both raw byte routes, never a `500` + + Clause-②: no + + `PUT` and `GET /api/v1/storage/_local/raw/:token` are the local adapter's presigned byte routes. Each now answers every token it will not honour with `401 INVALID_TOKEN`, the status, code and message (`Invalid or expired token`) the error catalog already published for this code. The four cases are a malformed token, a forged one, one minted for the other verb, and an expired one. + + - **Before:** a malformed token, or one minted for the other verb, answered `500 INTERNAL`, which reads as a server fault. A forged or an expired token answered `403`, with a message naming which check failed. A genuine fault whose text happened to contain "expired" or "signature" was answered as a refused token. + - **Now:** every refusal gets the same status, code and message, so the answer never tells a forger whether the signature or the expiry failed. Any other failure behind a valid token is still `500 INTERNAL`, whatever its text says. + - **A 5xx withholds its message text.** Per the platform rule, a 5xx on the raw byte routes answers with the route's generic text and not the fault's own; the original error stays in the server log. + - **Token signatures are compared in constant time.** + - **Unchanged:** a valid token's upload or download, the `501 NOT_IMPLEMENTED` answer when no adapter can verify tokens, and every other storage route. + + What changes for you: a stale or tampered signed URL now answers `401` where it answered `403` or `500`. To recover, request a fresh URL (`GET /api/v1/storage/files/:fileId/url`, or a new presigned upload). The SDK's `storage.upload` and the console's upload adapter only check that the response is OK, so neither needs a change. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/observability@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index 66dfc997f46..5659769384e 100644 --- a/packages/services/service-storage/package.json +++ b/packages/services/service-storage/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-storage", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Storage Service for ObjectStack — implements IStorageService with local filesystem and S3 adapter skeleton", "type": "module", diff --git a/packages/skills/CHANGELOG.md b/packages/skills/CHANGELOG.md new file mode 100644 index 00000000000..4f38e4e3281 --- /dev/null +++ b/packages/skills/CHANGELOG.md @@ -0,0 +1,38 @@ +# @objectstack/skills + +## 18.0.0-next.0 + +### Minor Changes + +- 165587e: New published package `@objectstack/skills`: the ObjectStack skills catalog (`skills/**` of the repository), shipped in the changeset `fixed` group so it always carries the version of the `@objectstack/*` packages it teaches. Its build copies the catalog into `dist/skills//…` byte for byte (the layout the skills CLI's `experimental_sync` reads from `node_modules`), and `files` lists only that tree. The repository's `skills/**` stays the one source of truth and the documented `next` channel; nothing a consumer writes changes. + + `@objectstack/spec`: `llms.txt`'s package-ecosystem count reads 69 with `@objectstack/skills` joining the scope. + +### Patch Changes + +- e4a770d: docs(skills): the `objectstack-ai` skill names the `agent` type's environment-overlay switch by its protocol-18 spelling, `allowEnvironmentOverlay:false` (#22340 S3) + + Clause-②: no + + `skills/objectstack-ai/SKILL.md`'s "Platform agents — reference only" section said the `agent` type is closed with `allowOrgOverride:false`. That key was renamed to `allowEnvironmentOverlay` in protocol 18 (ADR-0131 D6 retired the per-organization overlay axis, so the key names the one scope it governs; the old key stays on the registry entry schema only as a tombstone that refuses at parse with the new name in its prescription), so the published line now reads `allowEnvironmentOverlay:false`. The value, the `allowRuntimeCreate:false` beside it and the ADR-0063 §2 citation are unchanged; nothing an author writes changes. The same rename is recorded in `AGENTS.md` rule 7 and as header amendment notes on ADR-0005 and ADR-0010, none of which publishes. +- 179f7bf: fix(skills): the `objectstack-data` field-types rule names all five formula `returnType` members, `'currency'` included, and the `currencyConfig` a currency formula carries (#22743) + + Clause-②: no + + `skills/objectstack-data/rules/field-types.md` taught a formula's `returnType` as the four-member set `'number' | 'text' | 'boolean' | 'date'`, and its formula example said `(no 'currency')`. `@objectstack/spec`'s `FieldSchema.returnType` is the five-member enum `'number' | 'text' | 'boolean' | 'date' | 'currency'`, and a formula whose `returnType` is `'currency'` carries its currency in its own `currencyConfig` — `CurrencyConfigSchema`, the same shape a `currency` field declares; absent, the mode is `dynamic`, the tenant default currency. The `formula` table row and the formula example now both state the five-member set and name `currencyConfig` as the carrier. The example's `CEL — record. prefixes required` comment, which restated the table cell above it, is removed to pay the skill's token ratchet. Nothing is said about how a currency formula renders. +- eed637c: docs(automation): every text that teaches a sender of an inbound `api` hook teaches the timestamped `x-objectstack-signature` form only — `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock + + Clause-②: no + + The inbound hook door has accepted the timestamped form since `signHttpBodyAt` landed in `@objectstack/core`, and it is the only form that stops a captured post from verifying forever; the published teaching still showed the body-only `sha256=` form. The guidance converges first; the door's refusal of the body-only form is a later change of its own. + + - **`@objectstack/skills`.** The `objectstack-automation` skill's Inbound webhook section teaches `t=,v1=`: `v1` is the HMAC-SHA256 of `.` under the start node's `secret`, and the door refuses a `t` more than 300 seconds from its clock. The body-only form is no longer shown. + - **`@objectstack/lint`.** The hint of `flow-api-trigger-secret-missing` and its `os explain` entry describe the same form. The rule's id, severity, verdict sentence and judgement are unchanged. + - The showcase example's comment and the platform checklist's intake recipe sign the same way. + + **Nothing to migrate for an author.** A sender that already signs with `signHttpBodyAt` is unchanged. **The outbound contract stays as published:** the messaging outbox and the flow `http` node keep signing body-only (`signHttpBody`) for third-party receivers, and the inbound door's accept set is not changed by this release. +- e9e10fd: docs(skills): the `objectstack-data` indexing rule's partial-index example teaches the rule with an application-shaped index instead of restating the platform's overlay uniqueness key (#23039) + + Clause-②: no + + `skills/objectstack-data/rules/indexing.md`'s "Access methods and partial indexes" section illustrated a partial index with the platform's own overlay-uniqueness DDL and said that was what `ensureOverlayIndex` runs. The quoted key had four parts; the platform's runtime key has carried a fifth, `bundle_member`, since protocol 18, so the sentence stated something the platform no longer runs. The example is re-cut to an application-shaped partial index — one *active* subscription per account, the coarse `{ fields: ['account_id'] }` declared in metadata and the unique partial form built in a migration — so the rule is taught without restating a platform key that changes on its own schedule. The rule itself is unchanged: declare the coarse index (or none) in metadata, build the partial form as raw SQL in a runtime migration, fall back to a plain index on a dialect that rejects the predicate. Nothing an author writes changes. diff --git a/packages/skills/package.json b/packages/skills/package.json index 1f2d51c42f4..cdcf363e303 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/skills", - "version": "17.7.0", + "version": "18.0.0-next.0", "description": "The published ObjectStack skills catalog \u2014 the repository's skills/** tree, shipped at the version of the @objectstack/* packages it teaches.", "license": "Apache-2.0", "homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/skills", diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 0f884a636da..7949400fb9b 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,3203 @@ # @objectstack/spec +## 18.0.0-next.0 + +### Major Changes + +- 317cddd: feat(spec)!: an element binds data through the node-level `dataSource` only — the flat `object` / `filter` / `sort` / `limit` of `element:record_picker`, `element:number` and `element:repeater`, and `object-grid.defaultFilters`, are retired (#11509) + + Clause-②: no (narrowing: the ten element-layer flat data-binding keys and `object-grid.defaultFilters` leave the accept set, and the component-props gate's `dataSource.object` waiver becomes a refusal) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). `@objectstack/lint` is `major` with it: its component-props rule refuses what it used to waive. + + **Why.** One page element carried two doors onto one query. Each of these flat keys was the same query as a key of the node-level `dataSource` binding (`ElementDataSourceSchema`), and the three renderers resolved them by three contradictory rules: the record picker let the binding win, `element:number` AND-combined the two filters, and the repeater read the flat keys alone and ignored the binding — while the component-props rule waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed `os validate` and drew "No records". The console moved all three elements onto the binding first (objectui#11880); from this release the binding is the one door. `object-grid.defaultFilters` was the legacy second spelling of `filter`, read only when `filter` lowered to nothing. + + **What changes.** + + - **`@objectstack/spec`.** `ElementRecordPickerPropsSchema` `object` / `filter` / `sort` / `limit`, `ElementNumberPropsSchema` `object` / `filter`, `ElementRepeaterPropsSchema` `object` / `filter` / `sort` / `limit` and `ObjectGridPropsSchema` `defaultFilters` are `retiredKey()` tombstones: each is refused at its key with the prescription, and its input type is `never`. The repeater's other spellings of its query keys (`objectName`, `where`, `orderBy`, `top`, …) now point at the binding. The `object-*` blocks keep their own keys, and the relationship-scoped blocks are unchanged. + - **`@objectstack/lint`.** `validate-component-props` (`component-props-invalid`, warning) reports an `element:record_picker`, `element:number` or `element:repeater` node with no `dataSource.object` at that path, instead of waiving the flat `object` for any type whose binding names one. A flat key is reported by its tombstone. + - **Conversions (stored rows, artifacts, `os migrate meta --from 17`).** `element-flat-data-binding-to-data-source` moves a flat key the binding lacks onto it, deletes one the binding already set where the binding won, and appends `element:number`'s flat filter to the binding's (they always AND-combined). It leaves for the author, as a TODO: a record-picker key beside a `dataSource.view` that sets no such key of its own, a repeater key the binding sets to a different value or beside a `view`, and an `element:number` filter pair that is not two rule arrays. `object-grid-default-filters-removed` moves `defaultFilters` onto an empty `filter` (absent, `null`, `[]` or `{}`) and deletes it beside a `filter` that has rules. Both run before `page-component-filter-record-to-rule-array`, which then converts a moved record-form filter at its new door. Both are retired from the load path: an author is refused at the parse. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ type: 'element:record_picker', properties: { object: 'deal', limit: 20, labelField: 'name' } }` | `{ type: 'element:record_picker', dataSource: { object: 'deal', limit: 20 }, properties: { labelField: 'name' } }` | + | `{ type: 'element:number', properties: { object: 'deal', aggregate: 'count', filter: [...] } }` | `{ type: 'element:number', dataSource: { object: 'deal', filter: [...] }, properties: { aggregate: 'count' } }` | + | `{ type: 'element:repeater', properties: { object: 'deal_note', sort: [...], titleField: 'subject' } }` | `{ type: 'element:repeater', dataSource: { object: 'deal_note', sort: [...] }, properties: { titleField: 'subject' } }` | + | `object-grid` `properties: { defaultFilters: [...] }` (no `filter`) | `properties: { filter: [...] }` | + | `object-grid` `properties: { filter: [...], defaultFilters: [...] }` | `properties: { filter: [...] }` — the fallback was never read beside a `filter` with rules | + + **The one-line fix: move each key from `properties` to the node's `dataSource` (a sibling of `type`), unchanged; rename `defaultFilters` to `filter` where `filter` is empty, and delete it where it is not.** + + **For a consumer that pins both repositories:** its objectui pin moves past objectui#11880 no later than its objectstack pin moves past this release — the converted shape is one only that objectui reads. + + **Who is affected, measured.** This repository authors none of the eleven keys: its one element-layer author (the showcase record picker) already binds through `dataSource`, and a tree-scoped absence pin (`packages/spec/src/ui/element-flat-binding-retirement.test.ts`) keeps it that way. Other repositories and deployed metadata were not measured here. + + ### The retirement kit + + - `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ElementRecordPickerProps:object|filter|sort|limit`, `ui/ElementNumberProps:object|filter`, `ui/ElementRepeaterProps:object|filter|sort|limit`, `ui/ObjectGridProps:defaultFilters`. D3 entries `element-flat-data-binding-retired` and `object-grid-default-filters-retired`, each with a step-18 rationale fragment. They absorb the three protocol-18 narrowings of the same keys to the rule array (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`, `object-grid-default-filters-rule-array`), whose keys are gone in the same major; `page-component-filter-record-to-rule-array` drops the retired doors from its reach. + - Generated: `authorable-surface/ui.json` (11 rows `[RETIRED]`) and the `ui/component` reference page. Hand-edited ledger: `dropped-refinements.baseline.json` loses the three rows whose only dropped refinement was a retired `filter`. + - Pins: the tombstones, the binding, both conversions and the tree-scoped absence walk in `element-flat-binding-retirement.test.ts`; the missing-binding refusal and the repeater trap in `validate-component-props.test.ts`; the docs gate's twin in `check-yaml-examples.ts --self-test`. +- f66fdc7: The maps a flow node hands to a CALLEE are value slots now: a `subflow` node's `input`, a `map` node's `input` (evaluated once per item) and a `script` node's `inputs`. Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, evaluated in the calling flow's scope and handed over as the value it computes, or a literal written as it is. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. These three maps are computed values: each one becomes the callee's input. Until now each executor handed its map to the single-brace interpolator, so a `{token}` resolved there, and a CEL envelope written there reached the callee as the object `{ dialect: 'cel', source: '…' }` it spells, with the run reporting success. + + **What changes for a value that may be absent.** Where a whole token resolved to nothing, the template handed the callee nothing. A child flow then seeded its input variable from the variable's `defaultValue`. Under CEL an absent variable or key fails the run, and the guarded form `has(vars.x) ? vars.x : null` hands `null`. A `null` the caller supplies is a value: it wins over the child's `defaultValue`. To keep the default for an absent value, write it in the guard, `has(vars.x) ? vars.x : 'standard'`. Leave the key out where the value is never meant to be supplied. A `script` function is handed `null` where the template handed `undefined`. The refusal at those positions says this. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `input: { ownerId: '{record.owner}' }` on a `subflow` | `input: { ownerId: { dialect: 'cel', source: 'record.owner' } }` | an absent `owner` fails the run; guarded, it hands `null`, which wins over the child's `defaultValue` | + | `input: { ownerId: '{x}' }` where the child's default should apply when `x` is absent | `input: { ownerId: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'standard'" } }` | the default is written in the guard, because a supplied `null` is not absent | + | `input: { row: '{item}' }` on a `map` | `input: { row: { dialect: 'cel', source: 'item' } }` | evaluated once per item, with the item variable bound; a list or a record is handed over with its type | + | `inputs: { lines: '{lines}' }` on a `script` | `inputs: { lines: { dialect: 'cel', source: 'lines' } }` | the function receives the list; guarded, an absent value is `null` where it was `undefined` | + | `input: { message: 'Task "{record.title}" is done.' }` | `input: { message: { dialect: 'cel', source: "'Task \"' + record.title + '\" is done.'" } }` | one CEL concatenation; wrap a hole that may be null in `coalesce(…, '')` | + + **The one-line fix: write each value of a `subflow` / `map` `input` and a `script`'s `inputs` as a CEL value envelope, and write a child's default into the guard where it should apply.** + + **Who is affected, measured.** In this repository: 5 values in 3 nodes of the showcase example (`showcase_task_completed`'s `script`, and the `subflow` nodes of `showcase_task_done_notify_owner` and `showcase_project_closure`), 3 values in the todo example's `task_completion` `script`, 2 docs pages, the service-automation README, 2 dogfood fixtures and the test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. + + **Still accepted, unchanged.** A CEL value envelope and every literal. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `SubflowConfigSchema.input`, `ScriptConfigSchema.inputs` and `MapConfigSchema.input` take `FlowValueSlotSchema` per value (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `subflow.input.*`, `map.input.*` and `script.inputs.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `MapConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three maps. + - **The executor.** One per-key resolver, shared with the CRUD `fields` map, evaluates an envelope through `AutomationEngine.evaluateValueEnvelope` in the parent's scope and run context. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 9fdb127: A `loop`'s (with a `body`) and a `map`'s `collection` is a value slot now: the list the node iterates is a CEL value envelope, `{ dialect: 'cel', source: 'tasks' }`, that the executor evaluates in the run's scope, or the items written as an inline array, iterated as written. A string there is refused at `objectstack validate`, at `registerFlow` and by the executor: a `{…}` template token, naming its CEL spelling, and a variable's bare name, naming that variable in an envelope. The envelope is newly accepted there; the contract refused it before this change. + + Clause-②: yes (narrowing: a string is refused at loop.collection and map.collection, a {…} token and a variable's bare name alike; widening: both accept a CEL value envelope they refused, their published JSON Schema and inferred types gain the envelope arm, and FlowCollectionSlotSchema is a new export) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (both slots accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. The list a `loop` or `map` iterates is a computed value. Until now the executors ran a string `collection` through the single-brace interpolator. The contract also declared a variable's bare name there (`collection: 'tasks'`), but the structured `loop` and `map` never read one: the interpolator handed the name back unchanged, the value was no list, and every run failed at the node ("collection 'tasks' did not resolve to an array"), although `registerFlow` and `objectstack validate` accepted the flow. That arm retires with the token, and the refusal names the envelope that iterates. + + **What changes where the list may be absent.** The envelope must evaluate to a list, and anything else fails the node, `null` included, as a token that resolved to nothing did. One difference remains on an absent variable: the template failed the node with a returned failure (the step's `NODE_FAILURE`), and CEL fails it with an evaluation fault naming the variable (`EXECUTION_ERROR`). A `fault` edge routes both. To iterate nothing when the list may be absent, guard it to answer `[]`: `has(vars.tasks) ? vars.tasks : []`. + + **What does not change.** An inline array is iterated as written, and its elements are data: a `{…}` inside one was never interpolated, and is not judged now. A `loop` with no `body` is the legacy flat-graph form: it still reads `collection` as a bare variable name and sets `$loopItems` and `$loopIndex`, and no door judges it. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `collection: '{tasks}'` | `collection: { dialect: 'cel', source: 'tasks' }` | an absent `tasks` fails the node with an evaluation fault instead of a returned failure | + | `collection: '{record.items}'` | `collection: { dialect: 'cel', source: 'record.items' }` | as above | + | `collection: '{tasks}'` where an absent list should iterate nothing | `collection: { dialect: 'cel', source: 'has(vars.tasks) ? vars.tasks : []' }` | the node iterates nothing where the template failed it | + | `collection: 'tasks'` (a bare name, on a `loop` with a `body` or a `map`) | `collection: { dialect: 'cel', source: 'tasks' }` | the node iterates; it failed every run before, so decide whether it should | + | `collection: ['a', 'b']` | unchanged | an inline array is the list | + | `collection: 'tasks'` on a `loop` with no `body` | unchanged | the legacy flat-graph loop is untouched | + + **The one-line fix: write a `loop`'s or a `map`'s `collection` as a CEL value envelope naming the list, `{ dialect: 'cel', source: 'tasks' }`, or as an inline array.** + + **Who is affected, measured.** In this repository: the showcase example (two `loop` collections and one `map` collection), the todo example (two `loop` collections), the flows guide, the service-automation README, and test fixtures; all are migrated in this change. hotcrm writes a `{…}` collection on eleven structured loops in nine flow files (and one test fixture) and no bare name; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `loop.collection` and `map.collection`: the published JSON Schema for both gains the envelope arm (a list, or the envelope object), and so do the inferred `LoopConfig['collection']` and `MapConfig['collection']`, which lose `string`. `FlowCollectionSlotSchema` (`@objectstack/spec/automation`) is the contract both take. + + **Still accepted, unchanged.** An inline array at both, and on a body-less `loop` any `collection` its legacy form reads. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `LoopConfigSchema.collection` and `MapConfigSchema.collection` take `FlowCollectionSlotSchema`: a list or a CEL value envelope, so the executor's contract parse refuses a string as a guard, in the words above. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS`' `loop.collection` and `map.collection` rows take the `value` role, and `loop.collection` is read only on a `loop` with a `body` (`onlyWithKey`). The `loop` and `map` descriptors declare the slot with `xExpression: 'value'`. The single-brace `flow-template` role, which recorded these two slots and no other, is gone from `FlowNodeExpressionRole`, so a descriptor marker `'template'` fails the reconciliation ratchet as unknown. + - **The executors.** The `loop` and `map` nodes evaluate an envelope through `AutomationEngine.evaluateValueEnvelope`, as every value slot does, and iterate an inline array as written. A value that is not a list fails the node, naming what it evaluated to and its source. + - **The lint.** `validate-flow-template-paths` reads a loop iterator's object off an envelope `collection` that names a variable, as it read the token before. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 615c696: Each operand of a `get_record`, `update_record` or `delete_record` node's `filter` is a value slot now. A field's value, an operator's comparand and each element of a list comparand take a CEL value envelope, `{ id: { dialect: 'cel', source: 'record.id' } }`, which the executor evaluates in the run's scope before the query, or a literal handed to the query as written. A query placeholder — `{current_user_id}`, `{current_org_id}`, `{today}`, `{30_days_ago}` and the rest of the date macros — is such a literal: the query engine resolves it, and it passes untouched, as before. Every other single-brace `{…}` token at an operand is refused with its envelope: at `objectstack validate`, at `registerFlow`, at the metadata save door and by the executor's parse. An operand envelope that evaluates to `null` refuses the node, as a token that resolved to nothing did. + + Clause-②: yes (narrowing: a flow {…} token at a get_record, update_record or delete_record filter operand is refused, a whole-string query placeholder excepted, an object naming a string dialect at an operand must be a valid CEL value envelope, an operand envelope that evaluates to null refuses the node, and @objectstack/lint no longer exports validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN or FlowFilterTokenFinding and retires the flow-filter-token-unknown rule id; widening: a filter operand gains the CEL value envelope arm, the exported FlowNodeExpressionPath type gains the optional filterOperands field, ValueSlotTemplateOptions gains filterOperand, FLOW_NODE_EXPRESSION_PATHS gains the three filter rows, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains get_record and delete_record, the published GetRecordConfig, UpdateRecordConfig and DeleteRecordConfig JSON Schemas mark filter as a value slot, and flowFilterOperands, FlowFilterOperand, classifyFlowFilterOperandString and FlowFilterOperandStringKind are new exports) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL — its contract table names "filter values" — and Decision 3 deletes the single brace. A CRUD filter was the last value position that read the single-brace dialect: the executor interpolated the whole filter, resolving a flow token against the run's variables and handing a token no variable resolved on to the query engine when it was a query placeholder. Two `{…}` vocabularies met in one string. The query placeholders are the query-filter surface, which ADR-0032 puts out of its scope, so they stay; the flow token goes. + + **One classifier.** `classifyFlowFilterOperandString` (`@objectstack/spec/automation`) draws the line: a string that is one whole wrapped token the query engine resolves on the server (`isKnownFilterToken`) is a placeholder and passes; any other string carrying a `{…}` token is refused; the rest is a literal. The node contracts, `registerFlow`, `objectstack validate` and the executor's parse all read it, through the value-slot judge. + + **What an operand is.** The operands are the positions a value sits at, read the way the filter grammar reads a `FilterCondition` (`flowFilterOperands`): a field's value, each entry of an operator map (`$gt`, `$in`, `$between`, …), each element of a list comparand, inside `$and`, `$or` and `$not` and inside a nested-relation condition. An envelope there is one operand, and may evaluate to a list (`{ id: { $in: { dialect: 'cel', source: 'record.ids' } } }`). The filter's keys, operators and combinators are its structure, never evaluated. The top-level `filter` is never an operand. + + **What changes where a value may be absent.** The template erased a condition whose token resolved to nothing, and the node was refused. Under CEL an absent variable or key fails the run before any query, naming it, and an envelope that evaluates to `null` — a guarded form, a field that holds no value — refuses the node with the guard's message: a condition whose comparand is `null` or nothing does not narrow a query, it widens it. So route around the node with a `decision` where the value may be absent, rather than guarding it to `null`. To match an empty field, write the literal `null` (or `{ $null: true }`), never a computed one. + + **A flow variable named like a placeholder no longer shadows it.** Under the template a flow variable named `today` or `current_user_id` won over the placeholder of the same name. No flow token is read at an operand now, so the placeholder is always the query engine's; read the variable with an envelope, `{ dialect: 'cel', source: 'today' }`. No flow, variable or output variable in this repository is named like a placeholder. + + **A related field named `dialect`.** An object at a field's value that names a string `dialect` is read as an envelope. A nested-relation condition on a related object's field named `dialect` is written through an operator map, `{ rel: { dialect: { $eq: 'x' } } }`. No object in this repository declares such a field. + + **The lint rule `flow-filter-token-unknown` retires.** It reported a `{FUNC()}` token neither the flow template dialect nor the query placeholders define at a flow filter. Every token it reported is refused now by the value-slot judge at the same position and severity, with the envelope that replaces it, so the rule, its `os explain` entry and its exports (`validateFlowFilterTokens`, `FLOW_FILTER_TOKEN_UNKNOWN`, `FlowFilterTokenFinding`) are removed rather than left reporting the same defect a second time with the retired dialect's remedy. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `filter: { id: '{record.id}' }` | `filter: { id: { dialect: 'cel', source: 'record.id' } }` | an absent `record.id` fails the run before the query instead of refusing the node | + | `filter: { amount: { $gt: '{record.cap}' } }` | `filter: { amount: { $gt: { dialect: 'cel', source: 'record.cap' } } }` | a `null` cap refuses the node, as before | + | `filter: { id: { $in: ['{a}', '{b}'] } }` | `filter: { id: { $in: [{ dialect: 'cel', source: 'a' }, { dialect: 'cel', source: 'b' }] } }`, or one envelope evaluating to the list | — | + | `filter: { owner: '{$User.Id}' }` | `filter: { owner: { dialect: 'cel', source: 'current_user.id' } }` | in a run with no user `current_user` is `null` and the read fails: skip the node with a `decision` on `current_user != null` | + | `filter: { due: { $lt: '{TODAY() - 3}' } }` | `filter: { due: { $lt: { dialect: 'cel', source: 'isoDate(daysAgo(3))' } } }` | none for a whole number of days | + | `filter: { owner: '{current_user_id}' }`, `{ due: { $lt: '{today}' } }` | unchanged | a query placeholder stays the query engine's | + | `filter: { owner: '{today}' }` meaning a flow variable named `today` | `filter: { owner: { dialect: 'cel', source: 'today' } }` | the placeholder no longer shadows the variable | + + **The one-line fix: write each filter operand the flow computes as a CEL value envelope, and keep the query placeholders as they are.** + + **Who is affected, measured.** In this repository, at the base: seven flow tokens at a filter operand in the shipped examples (`app-crm` 2, `app-showcase` 3, `app-todo` 2 — `app-todo`'s two query placeholders stay), five in the docs (`getting-started/common-patterns`, `kernel/runtime-services/examples` 2, the `service-automation` README 2), the flows guide's and the spec's own remedy texts (a `$User.Email` read named `filter: { id: '{uid}' }`; a `recordId` refusal named `filter: { id: '{record.id}' }`) and the `FlowSchema` example; all are migrated in this change. The value-slot retirement's run-user changeset on this line names `filter: { id: '{uid}' }` for a `$User.Email` read; this one supersedes that line: read the user record with `filter: { id: { dialect: 'cel', source: 'current_user.id' } }`. hotcrm was not measured. + + **Still accepted, unchanged.** Every literal operand, every query placeholder, a filter's keys, operators and combinators, an empty filter, and a filter on `create_record`, which has none. The `filters` alias still converts to `filter` at load. + + ### The kit + + - **The contract.** `GetRecordConfigSchema`, `UpdateRecordConfigSchema` and `DeleteRecordConfigSchema` judge each filter operand: an envelope must be a valid CEL value envelope, and a `{…}` token in a literal is refused by the value-slot judge in its filter mode, naming the operand's envelope and what `null` does there. Each `filter`'s `describe()` and TSDoc is restated, and so are the `recordId` guidance and the run-user remedy, which named a single-brace filter value. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains a `filter` row per node, with the new `filterOperands` flag: both walks emit each operand at its own path (`filter.amount.$gt`, `filter.id.$in[0]`), never the filter itself. `GetRecordConfigSchema` and `DeleteRecordConfigSchema` join `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`. + - **The executor.** The CRUD executors parse their config as authored, evaluate each operand envelope through `AutomationEngine.evaluateValueEnvelope` before the query, hand every other operand on as written, and refuse the node — as a guard a `fault` edge cannot route — when an operand evaluates to `null`. The single-brace `interpolateFilter` is deleted. + - **The lint.** `objectstack validate` refuses a token at each operand position and judges an operand envelope as CEL through the shared judges; the wrong-syntax hints at a filter operand name its envelope; `flow-filter-token-unknown` retires. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 968acc8: An `http` node reads each config key by what it is now. Its `url` and every `headers` value are text slots, like a notify `title`: they render `{{ }}` holes, `'https://api.example.com/v1/orders/{{ record.id }}'`. Its `body` is a value slot: a CEL value envelope, `{ dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }`, that the executor evaluates in the run's scope to the payload, or a literal sent as written. Its `method`, `durable`, `timeoutMs` and `signingSecret` are literals, read as written. A single-brace `{…}` token at any of them is refused, naming its `{{ }}` spelling at a text slot, its CEL spelling in the body, and at a literal the remedy to write the value: at `objectstack validate`, at `registerFlow` and by the executor, except at `signingSecret`, which the build doors leave to the run because the flow credential store may hold the secret it signs with, and which the executor's parse refuses there. + + Clause-②: yes (narrowing: a {…} token is refused at http url, each headers value, any string of a literal body, method, durable, timeoutMs and signingSecret, and as a whole headers map, and a body value that names a dialect must be a valid CEL value envelope; widening: the exported FlowNodeTextSlot type gains the optional eachValue field, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains http, and the published HttpConfig JSON Schema marks body as a value slot) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a url and a header are text. Until now the executor handed its whole config to the single-brace interpolator and parsed the result, so every key read the retired dialect, a whole token in a typed key (`timeoutMs: '{t}'`) resolved to its value's type, and a CEL envelope written as the body was sent to the endpoint as the object it spells. + + **The request is rendered once.** The executor parses the config as authored — after the flow credential store's `signingSecret`, unchanged — then renders the url and the headers and evaluates the body, before either mode. The durable outbox enqueues exactly the request the inline call would send, and nothing renders at delivery. + + **What changes where a value may be absent.** A url that renders to no text refuses the node, as an empty one did. A header that renders to no text is not sent, where the template sent it empty. In the body an absent variable or key fails the run under CEL, naming it, where the template wrote `null` for a whole token and nothing inside text; guard a value that may be unset, `has(record.owner) ? record.owner : null`. CEL holds a map's values to one type, so in a CEL map literal a literal or a text beside a field read is wrapped in `dyn(…)`. + + **The literals.** `method`, `durable`, `timeoutMs` and `signingSecret` passed through the interpolator too, but no shipped flow computed one: the census found no `{…}` token at any of them in this repository's examples, docs or dogfood fixtures, only in the tests that pinned the interpolation itself. So no value slot is built there. They are read as written, and a `{…}` token in one is refused with the literal's remedy; an envelope there is refused too, since nothing evaluates it. A token written as the whole `headers` map, which the template resolved to a map, is refused the same way. A signing secret that arrived through a template (`signingSecret: '{signing_key}'`) is refused: write the secret itself, which a flow saved through the metadata API keeps in the write-only flow credential store, or `''` to send unsigned on purpose. + + **What does not change.** Every literal is sent as written. The method still defaults to `GET` inline and `POST` durable. An authored `signingSecret: ''` still sends unsigned, and a secret the credential store holds still takes the literal's place. A url or header with no token, and a body with no token, behave as before. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `url: 'https://api.example.com/v1/orders/{record.id}'` | `url: 'https://api.example.com/v1/orders/{{ record.id }}'` | a `Date` in the url renders as its ISO text, not JSON-quoted | + | `headers: { 'X-Ref': 'r-{record.id}' }` | `headers: { 'X-Ref': 'r-{{ record.id }}' }` | a header that renders to no text is not sent | + | `body: { id: '{record.id}', status: 'done' }` | `body: { dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }` | an absent `id` fails the run instead of sending `null` | + | `body: { text: 'Done: {record.title}' }` | `body: { dialect: 'cel', source: "{'text': 'Done: ' + record.title}" }` | an absent `title` fails the run instead of sending `'Done: '` | + | `body: '{payload}'` | `body: { dialect: 'cel', source: 'payload' }` | an absent `payload` fails the run instead of sending no body | + | `timeoutMs: '{timeout}'`, `durable: '{flag}'`, `method: '{verb}'` | the value itself: `timeoutMs: 5000`, `durable: true`, `method: 'POST'` | the key is a literal now; nothing computes it | + | `headers: '{headers}'` | the map itself: `headers: { 'X-Ref': '{{ record.id }}' }` | a map is not computed into `headers` | + | `signingSecret: '{signing_key}'` | the secret itself, or `''` to send unsigned | a secret read from a flow variable is refused at the run | + + **The one-line fix: double the braces in `url` and the `headers` values, write `body` as one CEL value envelope that builds the payload, and write `method`, `durable`, `timeoutMs` and `signingSecret` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (three bodies holding four tokens) and the flows guide (one body); both are migrated in this change. No url, header, method, `durable`, `timeoutMs` or `signingSecret` in the examples, docs or dogfood fixtures carries a token. hotcrm was not measured at these keys. + + **Newly refused in the body.** An object naming a `dialect` was data before and was sent as written; it is an expression now, evaluated per run, and a malformed one is refused. An envelope nested inside a literal body is still data, sent as written. + + **Still accepted, unchanged.** Every literal at these keys, and the published shapes of `url` (a string), `headers` (a map of strings), `method`, `durable`, `timeoutMs` and `signingSecret`. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `HttpConfigSchema`: `url` and each `headers` value refuse a single-brace token through the text-slot judge; `body` takes `FlowValueSlotSchema`; `method`, `durable`, `timeoutMs` and `signingSecret` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. The token refusals at `method`, `durable`, `timeoutMs` and a whole `headers` map are marked read-as-written, so the build doors' value arm reports them at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains the `http` body row. `FLOW_NODE_TEXT_SLOTS` gains `http.url` and `http.headers`, whose new `eachValue` flag reads each value of the map at its own path (`headers.X-Ref`). `HttpConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the body's marker to the reconciliation ratchet. The build doors' carve-out for a config parsed after interpolation is gone with the interpolation. + - **The executor.** The `http` node parses its config as authored, renders the url and headers through the text renderer, evaluates the body through the shared value-slot resolver, and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names the http url and headers among the text slots, and at an http literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused`, `flow-text-slot-unbound-dollar-root-refused` and `flow-builtin-node-config-values-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 111ffff: A `notify` node's computed values are value slots now: `recipients`, `sourceId` and the values of `templateData` take a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, that the executor evaluates in the run's scope, or a literal written as it is. `recipients` holds one recipient or a list of them, each entry a string or an envelope, and an entry that evaluates to a list adds each of its recipients. The node's link, `actionUrl`, is a text slot like `title`: it renders `{{ }}` holes, `'/crm_case/{{ record.id }}'`. Its `sourceObject`, `actorId` and `payload` are literals, read as written: no flow computed one, so nothing is evaluated there. A single-brace `{…}` token at any of these positions is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling at a value slot, its `{{ }}` spelling at `actionUrl`, and, at a literal, the remedy to write the value. + + Clause-②: yes (narrowing: a {…} token is refused at notify recipients (the value or a list entry), templateData values, sourceId, actionUrl, sourceObject, actorId and any string of a payload value, a templateData value that names a dialect must be a valid CEL value envelope, and a CEL value envelope written as a payload value is refused; widening: recipients and sourceId accept a CEL value envelope they refused, a recipients list accepts envelope entries, and their published JSON Schema and inferred types gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (four positions accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a link with holes is text. Until now the executor ran each of these positions through the single-brace interpolator, and one of its answers reached nobody: a whole token holding a list, written as an entry of a `recipients` list — `recipients: ['{owners}']` with `owners = ['u1', 'u2']` — was handed to the messaging service as ONE recipient, `'u1,u2'`, and the run reported success. A token holding a record there became the recipient `'[object Object]'`, and so did one in a link or a `sourceId`. + + **What changes where a value may be absent.** A recipient envelope that evaluates to `null` or a blank string adds no recipient, as a token that resolved to nothing did, and a node left with none fails, naming the expressions that evaluated to nothing. An absent variable or key fails the run under CEL, naming it, where the template dropped the recipient; guard a field that may be unset, `has(record.assignee) ? record.assignee : null`. A relation hop on a scalar id (`record.account.owner` without the start node's `expand`) fails the evaluation itself, naming the key it could not read, where the template resolved it to nothing. `sourceId` must evaluate to a string; `null` leaves the key unset, and a half-specified click-through pair is still dropped. A recipient, or a `sourceId`, that evaluates to anything else — a map, a number — fails the node, naming what it evaluated to. + + **The literals.** `sourceObject`, `actorId` and the values of `payload` passed through the single-brace interpolator too, but no flow computed one: the census found no `{…}` token at any of them, in this repository or in hotcrm. So no value slot is built there. They are read as written, and a `{…}` token in one — in any string of a `payload` value, at any depth — is refused with the literal's remedy: a single-brace token is not resolved here; write the value. An envelope written there is refused too, since nothing evaluates it (a deeper one inside a `payload` value is data, as it always was). + + **What does not change.** A literal at every value slot is written as it is: a recipient string (`'team:ops'`, a user id, an email address) is one recipient, as before, and is not split. `template`, `topic`, `channels` and `severity` are read raw, as before. An explicit `actionUrl` still wins over the link synthesized from `sourceObject` / `sourceId`, and a link that renders no text yields to it, as an empty one did. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `recipients: '{record.owner}'` | `recipients: { dialect: 'cel', source: 'record.owner' }` | an absent `owner` fails the run instead of leaving the node without a recipient | + | `recipients: ['{record.assignee}']` where it may be unset | `recipients: [{ dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }]` | none: the `null` is dropped, as the unresolved token was | + | `recipients: ['{owners}']` (a list variable) | `recipients: [{ dialect: 'cel', source: 'owners' }]` | each of its recipients is notified, where the template sent one joined string | + | `recipients: ['{$User.Id}']` | `recipients: [{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }]` | none: a run with no user adds no recipient | + | `sourceId: '{record.id}'` | `sourceId: { dialect: 'cel', source: 'record.id' }` | an absent id fails the run instead of dropping the link | + | `sourceObject: '{record.object_name}'`, `actorId: '{record.owner}'`, `payload: { who: '{record.owner}' }` | the value itself: `sourceObject: 'crm_case'` | the key is a literal now; nothing computes it | + | `templateData: { name: '{record.name}' }` | `templateData: { name: { dialect: 'cel', source: 'record.name' } }` | the template gets `null` (a guard's answer) where the key was absent | + | `actionUrl: '/crm_case/{record.id}'` | `actionUrl: '/crm_case/{{ record.id }}'` | a `Date` in the link renders as its ISO text, not JSON-quoted | + + **The one-line fix: write each `notify` value position as a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, double the braces in `actionUrl`, and write `sourceObject`, `actorId` and `payload` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (16 recipient strings, 7 `sourceId`s and 4 links), the todo example (2 recipients and 2 `sourceId`s), the flows guide, the email-templates and common-patterns guides, two dogfood fixtures and test fixtures; all are migrated in this change, and a recipient field that may be unset is guarded. hotcrm at `1d7148bf2d` writes 19 notify nodes in 15 flow files, each with one `{…}` recipient (2 of them `{$User.Id}`), 35 `{…}` `templateData` values and a link with holes; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `recipients` (the value, or each entry of a list) and `sourceId`: their published JSON Schema gains the envelope arm, and so do the inferred `NotifyConfig` types. At a `templateData` key an object naming a `dialect` was data before; it is an expression now, evaluated per run, and a malformed one is refused. `sourceObject`, `actorId` and `payload` keep their published shapes (a string, a string, a map of any values), with no envelope arm. + + **Newly refused at the literals.** A `{…}` token in `sourceObject`, in `actorId` or in any string of a `payload` value, and a CEL value envelope written as a `payload` value, which was handed on as the object it spells. + + **Still accepted, unchanged.** Every literal at these positions. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `NotifyConfigSchema`: `recipients` takes a string, a list of strings and envelopes, or one envelope; `templateData` takes `FlowValueSlotSchema` per key; `sourceId` takes a string or an envelope; `actionUrl` refuses a single-brace token through the text-slot judge; `sourceObject`, `actorId` and `payload` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. That refusal is marked read-as-written, so the build doors' value arm reports it at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains three `notify` value rows. `recipients` carries `eachElement`: a list is read entry by entry, at its index (`recipients[1]`). `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`. `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the markers to the reconciliation ratchet. + - **The executor.** The `notify` node evaluates each envelope through the shared value-slot resolver, renders `actionUrl` through the text renderer and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names `actionUrl` among the text slots, and at a notify literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 12b9daf: A `screen` node's three positions that hand a value to the client are value slots now: an object form's `defaults` (each field's prefill), a field's `defaultValue`, and an object form's `recordId` (the record an `edit` form opens). Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, that the executor evaluates in the run's scope before the screen goes on the wire, so the client receives the value it computes, never the envelope; or a literal, served as written. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. `recordId` gains the envelope arm, which it refused before this change, and its literal stays a string. + + Clause-②: yes (narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId; widening: screen.recordId accepts a CEL value envelope it refused, and its published JSON Schema and inferred type gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (`recordId` accepts a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. A screen's prefill and its record id are computed values handed to the client. Until now the executor ran each through the single-brace interpolator: a `{token}` resolved there, an envelope written in `defaults` or a `defaultValue` went to the client as the object `{ dialect: 'cel', source: '…' }` it spells, and `recordId` was the text of whatever the token found, so a token that found a record served the id `[object Object]`. + + **What changes where a value may be absent.** Where a whole token resolved to nothing, the template served nothing there. Under CEL an absent variable or key fails the run at the screen, with its source, and the guarded form `has(vars.x) ? vars.x : null` serves `null`: + + - In an object form's `defaults`, the form keeps a `null` prefill as a value and opens the field blank, over the object field's own `defaultValue`, which it applied when the prefill left the field out. To keep that default for an absent value, write it in the guard: `has(vars.x) ? vars.x : 'prospecting'`. + - A field's `defaultValue` seeds the field with `null`. + - `recordId` must evaluate to the record's id, a non-blank string. Anything else, `null` included, fails the run at the screen. The template served such a screen with no record id, and its `edit` form opened with no record and could not save. Where the record may be absent, route around the screen with a `decision` on the value instead of guarding the id. + + The refusal at each position says what it serves. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `defaults: { account: '{account_id}' }` | `defaults: { account: { dialect: 'cel', source: 'account_id' } }` | an absent `account_id` fails the run; guarded, it prefills `null` and the field opens blank over the object field's `defaultValue` | + | `defaults: { stage: '{x}' }` where the object field's default should apply when `x` is absent | `defaults: { stage: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'prospecting'" } }` | the default is written in the guard, because a `null` prefill is a value | + | `defaults: { parent: '{rec}' }` | `defaults: { parent: { dialect: 'cel', source: 'rec' } }` | the record is served as a map, a list as a list | + | `fields: [{ name: 'email', defaultValue: '{lead.email}' }]` | `fields: [{ name: 'email', defaultValue: { dialect: 'cel', source: 'lead.email' } }]` | guarded, it prefills `null` | + | `recordId: '{record.id}'` | `recordId: { dialect: 'cel', source: 'record.id' }` | the envelope must evaluate to a non-blank string id: `null`, a map or a list fails the run at the screen | + | `recordId: 'acc_1'` | unchanged | a string literal is the id | + + **The one-line fix: write each value of a screen's `defaults`, each field's `defaultValue` and an object form's `recordId` as a CEL value envelope; write the object field's default into the guard where it should apply, and route around a screen whose record may be absent.** + + **Who is affected, measured.** In this repository: the CRM example's lead conversion (`crm_convert_lead_wizard`: 3 `defaults` values in its two object-form steps), the object-form example in the flows guide, and test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. + + **Newly accepted.** A CEL value envelope at `recordId`, which `z.string()` refused before this change: the published JSON Schema for `recordId` gains the envelope arm (a string, or the envelope object), and so does the inferred `ScreenConfig['recordId']`. + + **Still accepted, unchanged.** A CEL value envelope at `defaults` and at a field's `defaultValue`, and every literal at all three, a string `recordId` included. The single-brace dialect keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The contract.** `ScreenConfigSchema.defaults` and a field's `defaultValue` take `FlowValueSlotSchema`, and `recordId` takes a string or a CEL value envelope (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `screen.defaults.*`, `screen.fields[].defaultValue` and `screen.recordId` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `ScreenConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three positions. The build doors keep refusing a `recordId` literal that is not a string, as before. + - **The executor.** The `screen` node resolves each position through the value-slot resolver the CRUD `fields` map and a callee's input share: an envelope is evaluated by `AutomationEngine.evaluateValueEnvelope` in the run's scope, before the screen is served. A screen that passes through without pausing goes on no wire, and evaluates nothing. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- d85615d: A flow VALUE slot now refuses the date macros as well — `{TODAY()}`, `{NOW()}`, with an optional `± N` day offset — so it reads no single-brace `{…}` token at all. Each is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form: `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())`, `isoDatetime(addDays(now(), N))`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots: one dialect per slot, with a remedy for every spelling. The date macros were the last spelling kept, because CEL had no string form for a timestamp: `today()` and `now()` reach the data engine as a `Date` object, not the ISO text the macros wrote. The CEL stdlib now has one — `isoDate(t)` (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC calendar the macros rendered on — so the macros can be refused with a remedy that writes the same text. + + **What is refused.** In every value slot — `create_record` / `update_record` `fields`, the `assignment` node's `assignments` map, and both legacy `assignment` shapes — a string carrying a date macro, alone or beside other text and tokens. A string that mixed a date macro with another token used to be kept whole, the run-user token included (`'Due {TODAY()} by {$User.Id}'`); it is refused now, with a remedy for every token. + + **Where the remedy parts from the template.** For no offset or a whole number of days the CEL form writes the template's text byte for byte, at every instant (measured live through both engines over DST transitions, month and year ends and a leap day). The refusal names the three places it does not: + + - a fractional offset: the template added it to the day of the month and truncated the sum, `addDays` truncates the offset itself — going back, a day apart on most days of a month — and `daysFromNow(1.5)` / `daysAgo(1.5)` are refused at build; + - a variable offset (`{TODAY() + days}`): the template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number; CEL reads the path, and fails the run on an absent variable or a value that is not a number; + - an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days. + + **Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged. + + **The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing | + | `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`, `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | nothing for a whole number of days; a fraction truncates differently, and `daysFromNow` / `daysAgo` refuse one at build | + | `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing | + | `'{NOW() + 2}'`, `'{NOW() - 1}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }`, `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }` | nothing — `addDays` keeps the time of day, where `daysFromNow` would land on midnight | + | `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 | + | `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one | + | `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object | + | the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run | + | a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | nothing for a whole number of days | + + **The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.** + + **Who is affected, measured.** This repository's examples carry no date macro in a value slot; its own test fixtures and docs that did are migrated in this change. The census taken for this retirement's first change measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`); they migrate there. Deployed metadata and other repositories were not measured here. + + **Still accepted, unchanged.** A CEL value envelope and every literal. The single-brace dialect, date macros included, keeps resolving where it still lives, a CRUD `filter` value. + + ### The kit + + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms. +- 6a06e3b: A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots, one dialect per slot, with a remedy for every spelling. The run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. + + **What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. + + **The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. + + **Still accepted, unchanged.** A CEL value envelope and every literal. `{$User.*}` keeps resolving where the single-brace dialect still lives, a CRUD `filter` value. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | + | `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | + | `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | an `assignment` of `uid: { dialect: 'cel', source: 'current_user.id' }`, a `get_record` on `sys_user` with `filter: { id: '{uid}' }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | + | `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | + | a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | nothing when the run has a user; where the flow can run without one, compute `current_user != null ? current_user.id : null`, which renders nothing where the template did | + + **The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** + + **Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}`, a `notify` `recipients` entry, is migrated to `current_user` with the `notify` value slots on this line. Other repositories and deployed metadata were not measured here. + + ### The kit + + - **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. + - **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, the hint at a node's literal names the value itself, and every other position keeps the single brace. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope and `current_user`. +- 0e9e7b7: A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2). + + **What is refused.** In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare `{ : }` config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve. One judge says it everywhere (`flowNodeValueTemplateRefusals` / `valueSlotTemplateRefusals` in `@objectstack/spec/automation`): `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema`, `UpdateRecordConfigSchema` and `AssignmentConfigSchema` refuse it at the value's path; `objectstack validate` reports it as `expression-invalid` at `error`, located at the node and `config.`, which also refuses it at the metadata save door; `registerFlow` refuses the flow (a stored flow carrying one is skipped at boot with a warn naming it); and the `create_record` / `update_record` / `assignment` executors refuse the node before writing anything. Each refusal leads with `VALUE_SLOT_TEMPLATE_REFUSAL` and then names the CEL spelling of the string's tokens. + + **Not converted.** No D2 conversion rewrites any spelling: every token spelling authored in flows answers differently under CEL for some input (measured through both engines over the same variables, 13 of 25 probes the same, 12 different), so a rewrite would change what some flow writes. Where a value may be absent, whether the field should take nothing, `null` or a default was decided silently by the template; it is now the author's decision. + + **What is still accepted, unchanged.** A CEL value envelope and every literal (a token-free string, numbers, booleans, `null`, arrays, objects). A `filter` value keeps the template dialect; a text slot (`notify` `title` / `message`, a screen `description`, …) reads `{{ }}` holes and refuses a single-brace token. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | + | `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run | + | `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` | + | `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` | + | `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | + | braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal | + + **The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.** + + **Who is affected, measured.** A TypeScript-AST census of every `create_record` / `update_record` `fields` value and `assignment` value (all three shapes, same-file spreads included): this repository at `959c209d5` carried 21 authored sites (`examples/**` and `packages/verify/src`) — 20 refused by this change (9 bare references, 10 dotted paths, 1 `$error` path, all migrated in this change) and 1 `{$User.Id}` (`examples/app-todo`), which the run-user refusal on this line refuses and migrates; hotcrm at `c529de2` carries 91 (2 of them through a same-file spread) — 71 refused by this change (31 bare references, 36 dotted paths, 4 text with holes) and 20 that the date-macro and run-user refusals on this line refuse (15 date macros, 5 `{$User.Id}`). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `automation/flow-value-slot-template.ts` (`VALUE_SLOT_TEMPLATE_REFUSAL`, `valueSlotTemplateRefusals`, `flowNodeValueTemplateRefusals`), composed into the value-slot contracts in `automation/builtin-node-config.zod.ts`; one new dropped-refinement site (`automation/AssignmentConfig` `out.catchall`, the bare legacy shape's values). + - **The doors.** `AutomationEngine.registerFlow` and the `create_record` / `update_record` / `assignment` executors (`@objectstack/service-automation`); `validateStackExpressions` (`@objectstack/lint`), whose `warning` hint pointing a template expression at the envelope this refusal replaces. + - **The ledger.** The D3 semantic entry `flow-value-slot-template-dialect-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: no authored spelling maps losslessly. +- a87d8be: The v18 line opens (ADR-0131; #22050). Every release from here is an `18.0.0-next.N` prerelease on the npm dist-tag `next`, until 18.0 GA. + + Clause-②: no + + - **What this marks.** Changesets is in pre mode with the tag `next`. Every `@objectstack/*` package versions together, so this one `major` takes all of them to 18, and the first version cut is `18.0.0-next.0`. `PROTOCOL_VERSION` follows `@objectstack/spec`'s major at version time, so it reads `18.0.0` from that prerelease on. The marker itself changes no code, key, export or stored shape. + - **What the line carries.** ADR-0131 (organization ownership is total: no NULL `organization_id`) lands on this line in stages. Each stage that breaks something says so in its own changeset, with its own migration. + - **Who receives it.** A plain install still resolves the dist-tag `latest`, which stays on 17.x until 18.0 GA. A prerelease is installed only on request: `@objectstack/spec@next`, or an exact `18.0.0-next.N`. + - **What ends it.** 18.0 GA exits pre mode and publishes to `latest`. + + +- 2b61f2d: A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. + + **What changes.** + + - **The renderer.** The text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. + - **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. + - **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. + - **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. + - **Unchanged:** a CRUD `filter` value keeps the single-brace dialect, and the value slots are CEL's. + + **Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | + | `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | + | `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | + | `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node's CEL value envelope — `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, by: { dialect: 'cel', source: 'current_user.id' } }` — then `'Due {{ due }}'`, `'By {{ by }}'` | + + **The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** + + **Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. +- 4e9fe9f: feat(spec)!: `PROTOCOL_VERSION` is `18.0.0`; the runtime handshake accepts `engines.protocol: '^18'` and refuses `'^17'` + + Clause-②: yes (narrowing: the handshake refuses a manifest pinned to protocol 17 (`'^17'`, `'^17.x.y'`, `'17.x'`), while the exported `PROTOCOL_VERSION` moves to `'18.0.0'` and a `'^18'` manifest is admitted) + + + + **BREAKING**, graded `major`: Changesets is in pre mode (`next`) for the v18 line, so this ships in an `18.0.0-next.N` prerelease. The protocol major moves in this reviewed change, not in the version pass (ruling record 6049734955, Q1 → B). Until that line's first version pass, `PROTOCOL_VERSION` is one major ahead of the package version; `check-changeset-no-major.mjs` admits that only in pre mode with a pending `major` for `@objectstack/spec` (ruling record 6056808625). + + - **`PROTOCOL_VERSION` reads `'18.0.0'`** and `PROTOCOL_MAJOR` reads `18`. The ADR-0087 D1 handshake on every load and install door (the app load seam, `POST /api/v1/packages`, `POST /api/v1/marketplace/install-local`) now admits a package whose `engines.protocol` admits major 18, and refuses one pinned to 17 (`'^17'`, `'^17.x.y'`, `'17.x'`) with `OS_PROTOCOL_INCOMPATIBLE` (422), naming `objectstack migrate meta --from 17`. + - **What you change.** Run `objectstack migrate meta --from 17` to list the edits for existing sources; `--write` applies the ones it can prove, including the declared `engines.protocol` range, rewritten in place from `'^17'` to `'^18'`, and you apply the rest by hand. + - **`spec-changes.json` and the protocol upgrade guide** now carry the protocol 17 → 18 record, generated from the registries: 64 mechanical conversions and 329 semantic entries. The aggregate record spans 16 → 18. + - **`os init`** stamps `'^18'` in the manifests it writes, and `os lint`'s `protocol/missing-engines-range` fix names `'^18'`. The `create-objectstack` template keeps `'^17'` until the version pass stamps it together with its `@objectstack/*` dependency ranges. + - **`@objectstack/mcp`**: the README's `defineStack` examples declare `'^18'`. +- 326a90a: feat(spec)!: a `page` gains an optional `print` declaration and a linted printable block subset; the list-export `'pdf'` prescription points at the print page; the zero-reader document schemas retire whole (#22158) + + Clause-②: yes (narrowing) + + + + **BREAKING** — the document family's exports leave `@objectstack/spec/data` (an export removal on a published entry), graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes, so the level says what the change is. The v18 opening marker already takes the fixed group to `18.0.0-next.N`, so this grade moves no version on its own. `@objectstack/lint` is `minor`: its new rule refuses blocks only inside a page that declares `print`, which no page could carry before this release, and it adds exports. The `print` declaration itself is additive. + + Card ① of the ruling on PDF and print documents (letter B′): **a document is a page with a print declaration; there is no separate template type.** + + ### The print page (additive) + + `PageSchema` gains an optional, closed `print` block. Its presence makes the page a print page — a document (an invoice, a delivery order, a letter, a report) authored in the page's own blocks: + + ```ts + definePage({ + name: 'invoice_print', + label: 'Invoice', + type: 'record', + object: 'invoice', + regions: [ + { name: 'header', components: [{ type: 'element:image', properties: { src: '/logo.png', alt: 'ACME' } }] }, + { name: 'main', components: [ + { type: 'record:details', properties: { fields: ['customer', 'invoice_date'] } }, + { type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'description' }, { name: 'amount' }] } }, + ] }, + { name: 'footer', components: [{ type: 'element:text', properties: { content: 'Payment due within 30 days.' } }] }, + ], + print: { paperSize: 'A4', orientation: 'portrait', margins: { top: 15, bottom: 15 }, repeatHeader: true, repeatFooter: true, pageNumbers: true }, + }); + ``` + + - **Keys** (`PagePrintSchema`): `paperSize` (`A4` | `A5` | `Letter` | `Legal`), `orientation` (`portrait` | `landscape`), `margins` (`{ top, right, bottom, left }` in millimetres), `repeatHeader` / `repeatFooter` (repeat the page's own `header` / `footer` region on every sheet), `pageNumbers`, and the page-break hints `repeatTableHeaders` and `avoidBreakInside`. Each maps to print CSS (`@page`, `thead { display: table-header-group }`, `break-inside`). + - **Not yet rendered.** Nothing applies these keys until the console's print rendering lands; the liveness ledger carries `print` as `planned` with an author warning, so `os validate` tells an author who writes it that the layout is validated but not yet applied. + - **Refused at the parse** (`checkPagePrintComposition`, exported for `.shape` mirrors): `print` on a `slotted` page, on an `html` / `jsx` / `react` page, on a `list` or `utility` page (a print page is a `record`, `home` or `app` page), on a `full` page with no `regions`, and `repeatHeader` / `repeatFooter` on a page with no region of that name. + - **The printable block subset** (`PRINTABLE_PAGE_COMPONENT_TYPES`, with the reason for every other vocabulary type in `PRINT_REFUSED_PAGE_COMPONENT_TYPES`): `page:section`, `page:card`, `page:footer`, `record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`. Inside a print page any other block — one that pages or windows its rows (`object-grid`, `record:related_list`), one that lays itself out to the screen (`page:sidebar`, `object-kanban`, `object-calendar`), or one with nothing printable (controls, inputs, shell chrome, `page:tabs`) — is refused by `@objectstack/lint`'s new gating rule `print-page-block-unprintable` (`validatePrintPageBlocks`), on `os validate`, `os build`, `os lint` and the page save door. + + ### The list-export prescription + + `'pdf'` stays refused in `view.exportOptions` formats. The refusal no longer says "PDF export itself was declined as NOT PLANNED" — no longer true — and instead names the view's `allowPrinting` for printing a list and a page that declares `print` for a document. + + ### FROM → TO (the retirement) + + `DocumentTemplateSchema`, `DocumentSchema`, `ESignatureConfigSchema` and the orphaned `DocumentVersionSchema` (`data/document.zod.ts`) are removed from `@objectstack/spec/data`, with their type aliases (`DocumentTemplate`, `DocumentTemplateParsed`, `Document`, `DocumentParsed`, `ESignatureConfig`, `ESignatureConfigParsed`, `DocumentVersion`, `DocumentVersionParsed`). No code in this repository, objectui or hotcrm read them. + + | before | what to write instead | + | --- | --- | + | `DocumentTemplateSchema` (a docx template with placeholders) | a `page` that declares `print`, its body drawn from the printable block subset | + | `DocumentSchema` / `DocumentVersionSchema` | nothing — no document store ever kept them; a document record is ordinary object data, its files `sys_file` attachments | + | `ESignatureConfigSchema` | nothing — no e-signature integration exists | + + **The one-line fix: delete the import; author a printable document as a page with a `print` block.** Every such import is TS2305 after upgrade. `data/DocumentSchemaValidation` (the NoSQL driver's block) is unaffected. `os migrate meta --from 17` lists the delegated step (D3 `document-schemas-retired`); there is no mechanical edit, since no stack collection ever carried these shapes. + + ### The retirement kit + + - `RETIRED_DEFS_BY_MAJOR[18]`: `data/DocumentTemplate`, `data/Document`, `data/ESignatureConfig`, `data/DocumentVersion`; D3 semantic entry `document-schemas-retired`, with a step-18 rationale fragment. The `ESignatureConfig` deadline-key entries in `RETIRED_KEYS_BY_MAJOR[18]` stay as history. + - No tombstone and no D2 conversion: none of the schemas is a stack collection member or a metadata type, so a conversion would have no seam that runs. + - Generated: `json-schema.manifest/data.json` −4 defs, `authorable-surface/data.json` −30 rows, `authorable-defaults/data.json` −2, the api-surface / declaration-map / export-origins shards, the `data/document` reference page removed. + - Pin: `src/data/document-schemas-retirement.test.ts`, a tree-scoped absence walk over the declared radius. +- e23eccb: feat(spec,metadata-protocol)!: the metadata type registry's `allowOrgOverride` is renamed to `allowEnvironmentOverlay`, on the registry and on `GET /meta/types` + + Clause-②: yes (narrowing) + + + + **BREAKING**: a renamed authorable key and a renamed response field, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0131 D6 retired the per-organization overlay axis: every metadata write and read is environment-wide. The registry flag kept the name that said "per-org" while it decided one environment-scope question at every door — whether an environment may overlay an item of the type that a managed package ships: the metadata protocol's save door, the managed-content seal on write and on the served read, the repository's put gate, and Studio's write gate through `GET /meta/types`. It is renamed to say that. One key, not two, and not the old name with a new meaning; no window in which both spellings are accepted or served. + + ## FROM → TO + + | where | you wrote / read | write / read instead | + |:--|:--|:--| + | a `DEFAULT_METADATA_TYPE_REGISTRY` entry (`MetadataTypeRegistryEntry`, `@objectstack/spec/kernel`) | `allowOrgOverride: true` | `allowEnvironmentOverlay: true` | + | a reader of a registry entry | `entry.allowOrgOverride` | `entry.allowEnvironmentOverlay` | + | each `entries[]` item of `GET /api/v1/meta/types` (`GetMetaTypesResponse`) | `allowOrgOverride` | `allowEnvironmentOverlay` | + + **The one-line fix: rename the key, and every read of it; the value is unchanged.** + + Every value is unchanged: `true` on `view`, `dashboard`, `report`, `translation` and `email_template`, `false` on every other built-in type. On the wire the value is still the registry's grant OR the `OS_METADATA_WRITABLE` hatch naming the type (`overrideSource: 'env'`), and the hatch still opens only items no managed package ships, never a managed one. `overrideSource` keeps its `registry` / `env` values; its describe now names the new field. + + ⚠️ **Read the old key nowhere.** Writing `allowOrgOverride` fails to compile (its input type is `never`) and fails to parse with the rename prescription (issue code `invalid_type` at path `allowOrgOverride`). A READER does not fail: `if (!entry.allowOrgOverride)`, a filter on it or a partial-match assertion still compiles against the tombstone and reads `undefined`, which treats every type as closed to environment overlays. A client that declares its own type for the `/meta/types` response, instead of importing `GetMetaTypesResponse`, sees no compile error at all and reads every type as locked. Search for the old name, do not wait for `tsc`. + + **`supportsOverlay` keeps its name; its describes now say what it gates.** It decides one thing: a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type's environment-overlay permission: the removal removes no managed definition, and changes what is served for `permission` alone. It opens no save and it decides no read. The registry's describe, the `GET /meta/types` describe and `MetadataTypeInfoResponse`'s describe used to promise that "the loader can merge overlays", in the retired per-organization wording. + + **Who is affected, measured.** No manifest, stack, plugin configuration or stored row can carry a registry entry (`contributes.kinds` is closed, `config.additionalTypes` is a tombstone, the registry is a TypeScript literal), so no authored metadata changes. The readers are code: every reader in this repository moved in this change, and a reader of the `/meta/types` field outside it moves with it — the Studio console reads the field and moves in its own repository's paired change, which the console pin carries. + + ### The kit + + - **The tombstone.** `MetadataTypeRegistryEntry.allowOrgOverride` is a `retiredKey()` naming `allowEnvironmentOverlay` (`kernel/metadata-plugin.zod.ts`); the 28 built-in rows spell the new key. + - **The ledger.** The retired-keys entry `kernel/MetadataTypeRegistryEntry:allowOrgOverride` and the D3 semantic entry `metadata-type-registry-allow-org-override-renamed` (protocol 18), which records the registry key and the wire field together. No D2 conversion: there is no authored or stored source a conversion could rewrite. + - **The wire.** `getMetaTypes` writes the hatch elevation onto `allowEnvironmentOverlay` on both of its arms — a registry type and a runtime-registered type with no registry row — and no entry carries `allowOrgOverride`. + - **The refusal sentences** that named the flag name the new key: `… takes no environment overlay (allowEnvironmentOverlay=false) …`, `… has neither allowEnvironmentOverlay nor allowRuntimeCreate …`, `… allowRuntimeCreate=false and allowEnvironmentOverlay=false …`. Codes and statuses are unchanged. +- 20e7d52: A `type: 'chart'` list view must bind a dataset: a view whose effective chart binding names no `dataset` is refused at every list-view door, at `chart` (no binding at all) or at `options.chart.dataset` / `options.chart.values` (an incomplete legacy bag), with the binding to declare. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and on the view write door, graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes. + + **Why.** A chart list view plots only the ADR-0021 `dataset` its binding names. The renderer reads that binding as the top-level `chart` block, else the legacy `options.chart` bag, the block replacing the bag whole. The `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema: the view write door (`PUT /api/v1/meta/view/:name`) saved `type: 'chart'` with no `chart` block, and an `options.chart` bag holding only `chartType`, and `defineStack` / `os validate` accepted the block-less view. Such a view renders a dead screen: the renderer either guessed a binding nobody wrote or, since objectui retired that guess, refuses on screen. + + **What is refused.** A list view whose `type` is `chart` and that: + + - declares no `chart` block and no `options.chart` bag: one `custom` issue at `chart`, whose message begins *This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot.*; + - declares no `chart` block and an `options.chart` bag with no `dataset` or no `values` (the bag is legal on the flattened overlay only): one `custom` issue per missing key, at `options.chart.dataset` / `options.chart.values`. + + It is a check on the list-view schema itself, so it reaches every door that parses a list view: `defineView`, `defineStack`, `os validate` / `os build`, a view item's `config`, and the metadata write door, which answers `422 INVALID_METADATA`. **New export:** `checkListViewChartBinding`, published from `@objectstack/spec/ui`, is this refinement check itself, a `(view, ctx) => void` function; objectui's `ListViewSchema` mirror, which is built from `ListViewSchema.shape` and so drops the schema's object-level checks, attaches it with `.superRefine(checkListViewChartBinding)`. + + **What stays accepted, byte for byte.** A chart view whose `chart` block names a `dataset` and at least one measure in `values`; a chart overlay whose `options.chart` bag carries both and no `chart` block replaces it; an incomplete bag under a complete `chart` block, which replaces it whole; a flattened overlay patch that names no `type`; and every view of another type, including a grid that only offers a chart in `appearance.allowedVisualizations`. + + **What to do.** Bind the chart: declare a top-level `chart` block naming the dataset to plot and at least one of its measures, for example `chart: { dataset: 'lead_metrics', values: ['amount_sum'] }`, with `dimensions` (the X / group axis) optional and `chartType` defaulting to `bar`. A view that carries its binding in the legacy `options.chart` bag either completes the bag or, preferred, moves it to the top-level `chart` block. A view that is not meant to be a chart takes another `type`. No conversion can do this for you: only the author knows which dataset a chart plots. + + **Stored views.** A stored `view` row is neither rewritten nor refused on read: it is served as stored, carries the same issue in its read-side `_diagnostics`, and is refused on its next save. + + **Who is affected, measured.** No chart list view without a binding exists in this repository: the two chart list views in `examples/app-showcase` and the chart list views in the `@objectstack/lint` fixtures all bind a dataset and a measure. Deployed metadata was not measured. + + ### The kit + + - **The refusal.** `checkListViewChartBinding` in `ui/view.zod.ts`, attached beside the calendar binding check at the three list-view doors: `ListViewSchema`, `ObjectListViewSchema` and the flattened list overlay member of the view write door. The `chart` slot's description now says a chart view must bind one, and the generated reference page carries it. + - **The ledger.** The D3 semantic entry `view-chart-binding-dataset-required` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion. +- 3e72f93: A flow node's `outputVariable` (`get_record`, `create_record`, `map`, `script`, `subflow`) refuses a variable name that starts with `$`, and a `try_catch` node's `errorVariable` refuses every `$` name except the engine's own `$error`, its default. The refusal names the remedy: the same name without the `$`, read as `{{ name }}`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The `$` names are the flow engine's own variables: it binds `$record`, `$runId`, `$flowName`, `$flowLabel` and `$error`, a flat-graph `loop` binds `$loopItems` and `$loopIndex`, and a resume signal may not write any `$` name. A flow text slot already refuses a `{{ }}` hole whose root is a `$` name the engine does not bind, and tells the author to drop the `$`. But the binding keys took any string, so a flow could bind `errorVariable: '$caught'` and then be refused `'Failed: {{ $caught.message }}'`. The two doors of one contract gave two answers. Now both give the text slot's answer. + + **What is refused.** + + - `outputVariable` on `GetRecordConfigSchema`, `CreateRecordConfigSchema`, `MapConfigSchema`, `ScriptConfigSchema` and `SubflowConfigSchema`: a name that starts with `$`. That includes the engine's own names, since a binding over `$record` would overwrite the trigger record for the rest of the run. + - `errorVariable` on `TryCatchConfigSchema`: a name that starts with `$`, other than `$error`. + - Each key states the rule as a JSON Schema `pattern`, so the published `json-schema/**` refuses what the parse refuses. The parse issue is an `invalid_format` (regex) issue at the key, and its message names the remedy. + - `FlowSchema.parse`, `registerFlow` and `objectstack validate` refuse the flow at `nodes.N.config.outputVariable` / `nodes.N.config.errorVariable`, inside a region body too (`nodes.N.config.try.nodes.M.config.outputVariable`). A stored flow carrying one is skipped at boot with a warn naming it, and each executor's own contract parse refuses the node at run time. + + **Unchanged.** `errorVariable` absent (it defaults to `$error`) or an explicit `errorVariable: '$error'`; any name that does not start with `$`, a `$` later in the name (`a$b`) included; an empty string, which every executor reads as no binding; and every hole the text-slot judge already admits. Non-string values keep the type refusal they had. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or drop `errorVariable` and write `{{ $error.message }}` | + | `outputVariable: '$lead'` with `title: '{{ $lead.name }}'` | `outputVariable: 'lead'` with `title: '{{ lead.name }}'` | + + **The one-line fix: name the variable without the `$`, and rename every read of it with it.** + + No D2 conversion rewrites the name: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks (a text-slot hole, a CEL expression, a single-brace token in a value position), so the rename is the author's. + + **Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), types all six keys as plain strings and accepts any `$` name. This repository was measured with `git grep` over `packages`, `examples`, `skills`, `apps`, `content` and `docs`: the only `$`-named `errorVariable` / `outputVariable` other than `$error` were three test fixtures, all renamed here (`errorVariable: '$err'` in two `packages/spec` automation tests, `errorVariable: '$caught'` in a `service-automation` test). Every authored `errorVariable` in examples and docs is `$error`, which stays legal, and no example or doc binds a `$`-named `outputVariable`. The pinned `objectui` checkout uses `errorVariable: '$error'` only. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The rule.** `flowBoundVariableNameSchema` in `automation/flow-bound-variable-name.ts`, package-internal (no new public export), composed into the six keys. It reads the `$` prefix the engine's closed list implies, not a copy of the list. + - **The ledger.** The D3 semantic entry `flow-binding-variable-dollar-name-refused` (protocol 18), with no D2 conversion. +- 5fb1746: A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate`. The run fails such an expression with `Unknown variable: X` (X being the root) on every evaluation, and the build door used to pass it. The reachable case is the run user: `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` — the spellings formulas, row-level security and the client accept — are refused, naming `current_user`, the one spelling flow CEL binds. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule). + + **Why.** Flow CEL evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, `record` bound to the record the run was handed (where an entrance hands it one), and `previous`, `vars` and `current_user` bound by the engine. Formulas, row-level security and the client also bind the run's user as `user`, `ctx.user` and `os.user`; flow CEL does not, because a top-level `user` would collide with a variable or a lookup field of that name. So an author who wrote the alias by habit shipped a flow that faulted at its first evaluation, and nothing warned. + + **What is refused.** At every flow CEL site the build door reads — a node's `config.condition` (the start node's trigger gate included), an edge `condition`, a `decision` branch `expression`, a screen field `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — a root that is none of: + + - the engine's roots: `previous`, `vars`, `current_user`; + - `record`, where an entrance the stack declares hands the flow a record — a record trigger (a `record-*` `triggerType`), a time-relative sweep, the inbound hook, a `type: 'flow'` action, a `map` node with an `itemObject`, or a `subflow` / `map` parent handed one — or where the flow binds a variable named `record`; + - a name the flow binds: a declared variable, an `outputVariable`, an `iteratorVariable` (a `loop`'s default `item` included), an `indexVariable`, an `errorVariable`, an `assignment` target, a node id, a screen field's `name`; + - a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it (an object-less flow action hands only the row `id`). + + The user spellings are told to write `current_user`, with the guard for a flow that can run without a user; any other root is named with what could have bound it, and the nearest in-scope name. The error is `expression-invalid`, `error`. Where the flattened-scope near-miss advisory ("`X` is not a field of … If `X` is a flow variable this is safe to ignore") named the same root, the refusal replaces it. + + **Not judged — the door stands down where the run can bind a name it cannot see.** + + - **A node:** a `script` or `connector_action` node (each hands its code the live variable map), a `wait`, `subflow` or `map` node (a resume folds the caller's `variables` bag in under their plain names), a screen with no field list, or a node type outside the builtin set. + - **An entrance the stack declares that hands the flow a record whose keys are not in hand:** + - a record trigger (`config.objectName`) or a time-relative sweep (`config.timeRelative.object`) on an object the stack does not declare; + - an `api` trigger, whose inbound hook hands the request body in as the record; + - a `type: 'flow'` action launching the flow on an undeclared object; + - a `map` node feeding the flow whose `itemObject` is undeclared or absent; + - a `subflow` or `map` parent that is itself such a flow. + - **The runtime publish gate:** a flow write there is not judged for unbound roots, by design (rulings `5791822697` item 3 and `6104584601`). Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged. + + A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `condition: 'user.id == "u1"'` | `condition: 'current_user.id == "u1"'` (`current_user != null && current_user.id == "u1"` where the flow can run without a user) | + | `condition: 'ctx.user.id == record.owner'` | `condition: 'current_user.id == record.owner'` | + | `assignments: { who: { dialect: 'cel', source: 'os.user.id' } }` | `assignments: { who: { dialect: 'cel', source: 'current_user.id' } }` | + | `condition: 'deals.size() > 0'` with nothing binding `deals` | declare it (`variables: [{ name: 'deals', type: 'list' }]`) or bind it with a node (`outputVariable: 'deals'`) | + + **The one-line fix: write `current_user` for the run user, and bind or correct every other root the refusal names.** + + **Who is affected, measured.** The last published spec line, 17.x, judged a flow expression's syntax only, so it accepts every refused source. Measured with the judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 45 sites), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `f0afcbda07` (32 flows, 65 sites) — 0 refusals; every root those flows read is an engine root, `record` on a flow an entrance hands a record, a binding of the flow's own or a field of its trigger object. One flow of the 67 is left unjudged by an entrance (`showcase_inbound_task_webhook`, an `api` trigger). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The judge.** `flow-cel-root-scope.ts` in `@objectstack/lint`, called by `validateStackExpressions` at each flow CEL site. One bound set per flow, read once like the shadowing pass's variable set, and one entrance index per stack (`flowCelEntrances`). + - **The ledger.** The D3 semantic entry `flow-cel-unbound-root-refused` (protocol 18). There is no D2 conversion: none of these roots ever evaluated in a flow, so there is no behaviour to carry over. +- 9f5eca5: Every remaining place a flow binds a variable by name refuses a name that starts with `$`: a `loop` or `map` node's `iteratorVariable` and `indexVariable`, an object-form `screen` node's `idVariable`, a `screen` field's `name`, a declared flow variable's `name`, and an `assignment` node's targets. The refusal names the remedy: the same name without the `$`, read as `{{ name }}`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The `$` names are the flow engine's own variables: it binds `$record`, `$runId`, `$flowName`, `$flowLabel` and `$error`, a flat-graph `loop` binds `$loopItems` and `$loopIndex`, and a resume signal may not write any `$` name. A flow text slot refuses a `{{ }}` hole over a `$` name the engine does not bind, and `outputVariable` / `errorVariable` already refuse one. Every other binding took any string, and on the run a `$` binding was worse than unreadable (measured with the engine on this release's `main`): + + - `iteratorVariable: '$record'` on a `loop` or `map` left `$record` holding the last item for the rest of the run, and `indexVariable: '$runId'` left `$runId` holding an index; + - `assignments: { $record: … }` overwrote the trigger record; + - a declared variable named `$record` was overwritten by the engine at run start, so its `defaultValue` never reached the run; + - a `screen` whose `idVariable` or field `name` was a `$` name paused, and then could never be submitted: the resume carrying the value was refused with `INVALID_SIGNAL`; + - a leading blank hid the `$` from a first-character rule, while the `screen` and `script` executors trim a name before they bind it: under that rule `idVariable: ' $id'` registered, the paused screen named `$id`, and its resume was refused the same way; a `script`'s `outputVariable: ' $record'` passes the protocol-18 rule too and is trimmed to `$record` by its executor (read from the executor, not run). + + **What is refused.** + + - `iteratorVariable` and `indexVariable` on `LoopConfigSchema` and `MapConfigSchema`, `idVariable` on `ScreenConfigSchema`, `name` on `ScreenFieldConfigSchema` and on `FlowVariableSchema`: a name that starts with `$`. Each key states the rule as a JSON Schema `pattern`, so the published `json-schema/**` refuses what the parse refuses. The parse issue is an `invalid_format` (regex) issue at the key, and its message names the remedy. + - Every binding key — `outputVariable` and `errorVariable` included — now judges the first NON-BLANK character, so `' $x'` is refused like `'$x'`. The pattern's `\s` is exactly the set `String.prototype.trim` removes. + - An `assignment` node's targets, in each shape its executor binds: a key of the `assignments` map (`AssignmentConfigSchema` states it as `propertyNames.pattern`), a top-level key of the bare legacy config, and the `variable` (or `name`, `key`) of a legacy `assignments: [{ variable, value }]` item. + - `FlowSchema.parse`, `registerFlow` and `objectstack validate` refuse the flow where the name was written — `nodes.N.config.iteratorVariable`, `nodes.N.config.fields.M.name`, `nodes.N.config.assignments.NAME`, `variables.N.name` — inside a region body too. A stored flow carrying one is skipped at boot with a warn naming it, and the `loop`, `map` and `screen` executors' own contract parse refuses the node at run time. + + **Unchanged.** Any name whose first non-blank character is not `$`, a `$` later in the name (`a$b`) included; the defaults (`iteratorVariable` is still `item`); an empty string where the key took one; a body-less legacy `loop`, whose `iteratorVariable` nothing reads; and every hole the text-slot judge already admits. Non-string values keep the type refusal they had. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `iteratorVariable: '$row'` with `title: '{{ $row.name }}'` | `iteratorVariable: 'row'` with `title: '{{ row.name }}'` | + | `idVariable: '$account'` | `idVariable: 'account_id'`, read as `{{ account_id }}` | + | `variables: [{ name: '$total', type: 'number' }]` | `variables: [{ name: 'total', type: 'number' }]`, read as `{{ total }}` | + | `assignments: { $total: … }` | `assignments: { total: … }` | + + **The one-line fix: name the variable without the `$`, and rename every read of it with it.** + + No D2 conversion rewrites the name: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks (a text-slot hole, a CEL expression, a single-brace token in a value position), so the rename is the author's. + + **Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), types every one of these keys as a plain string. Measured on `main` at this change's base: the 35 flows `examples/app-crm`, `examples/app-todo` and `examples/app-showcase` ship all parse clean under the new rule, and a TypeScript-AST scan of `examples` (234 files), `packages/platform-objects` (167, which ships no flow), `packages/qa/dogfood` (280), the rest of `packages` and the `hotcrm` app found no `$`-led binding on any of these positions. The pinned `objectui` checkout binds none either. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The rule.** `flowBoundVariableNameSchema` in `automation/flow-bound-variable-name.ts`, package-internal (no new public export), now composed into every binding position. A `$` binding is refused with one sentence whichever door judges it. + - **The ledger.** The D3 semantic entry `flow-binding-name-dollar-refused` (protocol 18), with no D2 conversion. + +### Minor Changes + +- fec87e7: feat(spec)!: a package manifest's `permissions` no longer takes a flat list of permission strings — the structured `{ services, hooks, network, fs }` block is the only form (#13458) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). + + `ManifestPermissionsSchema` was a union of a flat `string[]` and the structured ADR-0025 §3.2 block. Nothing ever acted on the list: the loader registers the consented `grantedPermissions` set from the environment artifact with the permission enforcer, never the manifest's request, and the only code that met a list on a manifest was two reports saying it had been skipped. The marketplace install disclosure reads only the four lists, so a list was shown to an installer as "Requests no special permissions." (ADR-0049 enforce-or-remove). `ManifestPermissionsSchema` is now the structured block itself. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `permissions: ['system.user.read', 'system.data.write']` | `permissions: { services: [...], hooks: [...], network: [...], fs: [...] }`, naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches. | + | `permissions: []` | delete `permissions`: absence is the spelling for "requests nothing". | + | a list on an app manifest that ships no code | delete `permissions`. An app's record access is its permission SETS, in the stack's own top-level `permissions` collection. | + + **The one-line fix: replace every manifest `permissions` list with the structured block, or delete it.** A permission string has no mechanical mapping onto the four lists, so the translation is done by hand. `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + **What an author now sees.** Writing a list fails `tsc` (the key's type is the block), and `os validate`, `os build`, `os plugin build` and `defineStack` refuse it at `manifest.permissions` with the block's own answer: `Expected the plugin permission block { services?, hooks?, network?, fs? }, received a flat list.`, followed by the prescription. Every structured block that parsed before still parses, unchanged. + + ### The retirement kit + + - **Schema.** `ManifestPermissionsSchema` is `PluginPermissionsSchema`, by identity, and both export names stay. The block answers a list with its prescription on its own error map, the bare-array pattern `ListViewExportOptionsSchema` uses. The block also carries `EnvironmentArtifactSchema.grantedPermissions` values, so the answer is worded true there too, where a list was never legal. + - **D2 conversion `manifest-permissions-string-list-removed`** (step 18, retired from the load path): a lossless delete of an all-string list from the stack's `manifest` and every `packages[].manifest`. The notice carries the dropped strings. A built artifact replays it at the artifact door, so an artifact built while the list was legal still boots. It never touches the structured block, an array of objects, or the top-level ADR-0090 permission-set collection. + - **D3 entry `manifest-permissions-string-list-retired`** carries the judgement the delete cannot make: what each dropped string meant in services, hooks, hosts and paths. + - **Liveness.** `manifest.permissions` stays `live` on corrected evidence. Its consumer is the marketplace install disclosure, and it refuses nothing at load. The four keys are now drilled. + - **The two skip reports reworded.** `AppPlugin`'s security registrar and `@objectstack/plugin-security`'s audience-binding reconciler both report a manifest-stage `permissions` they cannot read as permission sets. They now name the flat list as the retired legacy form. They behave as before. + + **Measured producers: none outside tests.** On origin/main e67ba80049, no manifest in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes a list. The exceptions are five `@objectstack/spec` `manifest.test.ts` fixtures, re-triaged here, and the skip-report tests of `@objectstack/runtime` and `@objectstack/plugin-security`, which hand a list to an unparsed bundle on purpose and still pass. The same instrument finds those five fixtures, which is its control. At the objectui pin `a58626c88dc8`, nothing reads `manifest.permissions` (control: 91 `manifest.(id|name|version)` reads), and the install disclosure reads the structured block alone. Deployed and cloud-held manifests NOT MEASURED. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate`), which applies `convertPositionBindingRows`. A name that organizations bound differently is reported `conflicting` and is never merged. +- 60bcfe7: feat(plugin-security,rest,spec)!: the permission-set "Discard Overlay" action, its REST route and its contract member are retired; no door creates the overlay it discarded, and a database holding one is refused at cold boot (ADR-0131 cutover, ADR-0048) + + Clause-②: no (narrowing) + + + + **BREAKING** — a narrowing of the public surface: one HTTP route, one Setup action and one optional contract member are removed. Shipped as `minor` under the launch-window convention for breaking changes. + + The action deleted the environment-wide `sys_metadata` row that shadowed a package-declared permission set, then re-projected the set's `sys_permission_set` row. Neither half has anything left to do: + + - No door creates such an overlay. The metadata door refuses a save over a packaged permission set on every topology, whatever `OS_METADATA_WRITABLE` says. The data door refuses an edit of one (clone it instead). The data API cannot write `sys_metadata`. A package that declares a name the environment holds is refused at install. + - An overlay stored before those refusals makes the cold boot refuse the deployment with `422 NAMESPACE_CONFLICT`, so a server that could answer the route never holds one. + - The authorization resolver reads the security catalog, not `sys_permission_set` rows, so the re-projected row granted nothing. + + ### What was removed + + | removed | what to use instead | + | --- | --- | + | `POST /api/v1/security/permission-sets/:id/discard-overlay` | `os migrate security-catalog-overlays` with the flags and environment the deployment boots with lists every overlay row with no server running; `--apply` deletes them (a history tombstone per row). | + | the **Discard Overlay** action on `sys_permission_set` in Setup | the same offline step. To keep a customization of a packaged set, clone it under a new name (the **Clone** action) and assign the clone. | + | `ISecurityService.discardPermissionSetOverlay` and `PermissionSetOverlayDiscardResult` (`@objectstack/spec/contracts`) | nothing: delete the call and the import. `tsc` names each one. | + | `discardPermissionSetOverlay`, `PermissionSetNotFoundError`, `PermissionSetOverlayStateError` and the `PermissionSetOverlayDiscardDeps` / `PermissionSetOverlayDiscardResult` types (`@objectstack/plugin-security`) | nothing: delete the import. | + + **The one-line fix:** stop calling the route or the member, and clear any overlay offline with `os migrate security-catalog-overlays --apply` before the first boot on this release. + + `ERROR_CODE_LEDGER['@objectstack/plugin-security']` no longer lists `INVALID_STATE` or `NOT_FOUND`: the removed action was their only producer in this package. Both codes stay registered under `@objectstack/rest`, and the union of codes is unchanged. + + The permission-set drift diagnostic no longer names the action in its `overlay_shadow` detail, and the boot reading of shadowed packaged sets names the offline step instead. +- 4806b18: Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). + + **Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): + - the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; + - the owner's `install-local` uninstall of that package took the set away from that member; + - the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. + + The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. + - `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. + - `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. + + **Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. + + **The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. +- 1802315: Under a walled posture the `/packages` verbs that publish, discard, roll back, revert, duplicate or adopt security catalog items are the platform operator's + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant"). + + **Why.** A package's positions and permission sets are environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack: the operator published a permission set in a writable package, and another organization's member held it. An organization owner granted `manage_metadata` inside their own organization (never the platform operator) then: + - published the operator's pending draft adding `manage_users`, and that member's next resolution carried it; + - rolled the package back, and the member lost it; + - reverted the operator's commit, and the member lost it; + - copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy; + - discarded the operator's pending draft (through `revert`, and `discard-drafts` is the same discard by a second door); + - adopted the environment's catalog rows into their own package. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs: + + - `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position, permission-set or capability draft. + - `POST /api/v1/packages/:id/revert`, when the package has such a draft. + - `POST /api/v1/packages/:id/discard-drafts`, when the package has such a draft. + - `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position, permission set or capability. + - `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing. + - `POST /api/v1/packages/:id/duplicate`, when the source package's published environment rows (the ones it copies) hold one. + - `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one. + + **Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read. + + **The remedy.** The platform operator publishes, discards, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete. +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- b389e43: feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) + + Clause-②: yes (narrowing) + + + + **BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. + + **What changes.** + + - Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. + - The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. + - The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. + - `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. + - A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | + | `organizationId` on any other protocol write verb's request | drop it, same refusal | + | `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | + | `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | + | `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | + | `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | + | `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | + | a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | + + **What a deployment observes.** + + - Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. + - An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. + + **What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- 1920cf3: feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`. + + With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces. + + **What moves for consumers.** + + - **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables. + - **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`. + - **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables. + + **Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one. + + **Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names. +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- c52bfb4: feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. + + - **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. + - **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. + - **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. + - **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. + + **What moves for consumers.** + + - **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. + - **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. + - **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. + - **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. +- c7d6037: A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) + + Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) + + A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. + + - **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. + - **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: + - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; + - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; + - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. + An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. + - **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. + - **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. + + Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. +- 72b26ed: `importUnmanagedPackage` copies one package into the environment ledger, editable from then on — the import behind the `unmanaged` install mode (ADR-0131 D6) + + Clause-②: yes (widening) + + A package installs one of two ways. Managed: the package is the authority, it upgrades, and nothing it ships may be edited. Unmanaged: every metadata item is copied once into this environment, is fully editable, and never follows an upgrade. This release adds the import; the install door calls it for an unmanaged install, and the manifest key `installModes` declares the modes a package permits. + + - **The verb** (`@objectstack/metadata-protocol`). `ObjectStackProtocolImplementation.importUnmanagedPackage({ manifest, targetPackageId?, targetName? })` writes every metadata item of ONE package body into a NEW writable base through the metadata save door: each row is owned by the base, carries no organization, is served as environment-authored (`_provenance: 'org'`), and the next save edits it. The base's id differs from the source (`targetPackageId`, defaulting to `-copy`), it keeps the source namespace so every object name stays valid, and nothing is registered under the source id. The answer names the source, the base, its namespace and every item copied, in write order. + - **All or nothing.** Every refusal that can be judged is raised before the base exists, so a refused import writes nothing: a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value); a body carrying what cannot be copied; a malformed name or an unusable target id; a protocol-incompatible package; a target id that names an installed package, or a namespace another package owns (a managed install of the same package included); an item that does not convert to this protocol; a name an environment row or a managed package already holds. A write that fails afterwards deletes the base — its rows and its tables — and the failure is thrown with the item's own code and status, naming the item and what the rollback did. + - **What is copied, and what is refused.** The items of every manifest collection and the assembled `viewItems` are copied. A body carrying a runtime-uncreatable type (`jobs`, `agents`, `apis`, `capabilities`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), extensions of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`) or an artifact envelope (`packages`) is refused whole; `OS_METADATA_WRITABLE` does not open a type for it. The package's `translations` are copied too, each locale of each bundle as one `translation` item, and an unmanaged install loads no seed `data`; deployment configuration (`i18n`, `requires`, `datasourceMapping`, …) is not part of a package install in either mode. + - **Two new error codes** (`@objectstack/spec`, registered to `@objectstack/metadata-protocol`). `UNMANAGED_INSTALL_WALLED_POSTURE` (409) and `UNMANAGED_INSTALL_UNCOPYABLE` (422). Both mean the package can be installed managed instead. A collision, an existing target or an owned namespace answers the standard `RESOURCE_CONFLICT` (409). + - **Two new schemas** (`@objectstack/spec/api`). `ImportUnmanagedPackageRequestSchema` is parsed by the verb, so a request key it does not declare is refused with `400`. `ImportUnmanagedPackageResponseSchema` declares the answer. + + Nothing existing changes: no schema, door or code is removed or narrowed. +- 2d21b9c: An unmanaged install no longer copies a package's protection lock, in either spelling: every copied item is editable, and the install's answer names each lock it dropped with the package's reason (ADR-0131 D6, ADR-0010) + + Clause-②: yes (widening: the new response field `strippedLocks` on `ImportUnmanagedPackageResponseSchema`, relayed on the install-local answer for `installMode: 'unmanaged'`) + + An unmanaged install copies a package into this environment as the environment's own metadata. A package can lock an item in two spellings: the `_lock` envelope (`_lock`, with `_lockReason`, `_lockSource` and `_lockDocsUrl`), or the author-facing `protection: { lock, reason, docsUrl }` block that a managed install translates into it. Until now an item locked with `_lock` was copied with that lock, so the next save of the copy answered `403 ITEM_LOCKED`; an item locked with `protection` was copied with an inert block that bound nothing, so the vendor's lock vanished without a word. The package permitted the unmanaged install through its `installModes`, so it chose to let environments take editable copies; a lock whose source is the package does not survive the copy. + + - **The copy** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` writes every item without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and, on a type whose schema declares one, without its `protection` block. Its answer gains `strippedLocks`: one entry per lock that bound (`no-overlay`, `no-delete` or `full`), in the order the items were written — `{ type, name, lock, reason? }`, with `reason` the package's reason verbatim and absent when it gave none. Each entry is the lock a managed install of the same item would have bound: an item carrying both spellings is reported once, with the `protection` block's lock and reason, because that is the one a managed install binds. A package that locks nothing answers `strippedLocks: []`. + - **Refusals are unchanged.** A lock the spec's declarations refuse (an unknown `_lock` value, a reason that is not a string, a malformed `protection` block) is not stripped: the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back. So is a `protection` block on a type whose schema does not declare one (`action`, `hook`, `page`). A managed install is unchanged: the package stays the authority, and its locks bind. + - **The declaration** (`@objectstack/spec`). `ImportUnmanagedPackageResponseSchema` declares `strippedLocks`; `lock` takes the three binding values of `MetadataLockSchema`. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'`). The answer carries the import's `strippedLocks` as the import states it. + - **`os package install --install-mode unmanaged`** (`@objectstack/cli`). Each dropped lock prints as one line under `Locks removed`, spelled `.: — `, so the installer reads why the vendor locked an item before editing it. An answer from a runtime that states no list prints no lock line. + + Nothing is removed or narrowed: the answer gains a field, and every refusal of the unmanaged install is unchanged. +- ac9f8bd: A builtin flow node's `config` value that its executor contract refuses is refused at parse, with a location, in the contract's own words: `create_record` `outputVariable: 42`, a screen field `min: '1'`, a `get_record` `limit: '10'` and the like no longer pass the build doors and then fail every run. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** Every builtin executor parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: `create_record 'mk': config does not satisfy the create_record contract — config.outputVariable: Invalid input: expected string, received number`. + + **What is refused.** A node of any builtin type (`get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `script`, `subflow`, `map`, `loop`, `parallel`, `try_catch`), at any depth, whose present config value its executor contract refuses — a wrong type, a value outside the declared set or range, an empty `function` / `flowName`, or a rule finding on present keys (a `notify` `template` beside an inline `title`). The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, anchored at the key (`nodes.N.config.outputVariable`, `nodes.N.config.fields.0.min`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door (`422 INVALID_METADATA`). + + **What the build doors still accept, byte for byte.** Every value its contract accepts, and the values this arm holds back: + + - a value carrying a `{token}` (also spelled with double braces or a leading `$`) — never refused at the build doors for its pre-interpolation type. That is not a promise it runs: every builtin parses its config as authored, so a token in one of its number or boolean slots (`limit: '{n}'`, `maxIterations: '{cap}'`, a screen field `min: '{m}'`, `multi: '{bulk}'`) still fails at its first run, exactly as before — write a literal there (an `http` node's `timeoutMs` and `durable` refuse the token itself at the build doors); + - on `http`, `signingSecret` (the credential channel may supply it); + - a `loop` with no `body` (its executor does not parse it), and the region slots of `loop`, `parallel` and `try_catch`; + - an undeclared or retired key, a screen field's `visibleWhen` and a CRUD `fields` value — each keeps the judge it had. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `outputVariable: 42` | `outputVariable: 'taskId'` — the variable's name | + | a screen field `min: '1'`, `max: '10'` | `min: 1`, `max: 10` | + | `limit: '10'`, `maxIterations: '5'` (any number slot outside `http`) | `limit: 10`, `maxIterations: 5` — a literal number only: these executors parse the config as authored, so a `{token}` here passes the build and fails every run | + | `multi: 'true'`, a screen field `required: 'yes'` (any boolean slot outside `http`) | `multi: true`, `required: true` — a literal boolean only, for the same reason | + | `http` `timeoutMs: '5000'`, `durable: 'yes'` | `timeoutMs: 5000`, `durable: true` — a literal only: `http` parses its config as authored, and a `{token}` there is refused at the build doors | + | `severity: 'loud'`, `mode: 'view'` | one of the declared values (`'info'` / `'warning'` / `'critical'`; `'create'` / `'edit'`) | + | a `notify` with both `template` and `title` | one content path, as the refusal's sentence says | + + **The one-line fix: write the value the contract declares at the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does. + + **Who is affected, measured.** At `833d57c9cf`, every builtin node `config` authored in this repository's examples, docs, skills and `packages/qa` fixtures (96 nodes), and every one in hotcrm at `4054ec2680` (138 nodes), parses under this arm. A second census at `d1c7d8d392` that also reads helper calls, same-file constants and assignments into a node config (1065 configs in this repository, 138 in hotcrm) found no other real writer; 64 configs here take a value from an import, a call or a spread that no static reading evaluates, and are not counted either way. The one real writer found to store a refused value is the Studio flow designer, which saved a screen field's Min / Max as strings until objectui `5ba255538a`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + + ### The kit + + - **The refusal.** The value half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries. + - **The ledger.** The D3 semantic entry `flow-builtin-node-config-values-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know the value the author meant. +- 2f70c22: A key a builtin flow node's executor contract does not declare is refused at parse, with a location, on every builtin but `try_catch`: a `notify` `bogusKey`, a screen field's `visibleIf`, a `create_record` `fieldValues`, an `http` `outputVariable` and the like no longer pass `objectstack validate` and `objectstack compile` and then get the whole flow refused at registration. + + Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** Each of these executors parses the node's `config` against a strict contract before it acts. The build doors' executor-contract arm judged required keys and present values on these types, but held key membership back, because `registerFlow`'s undeclared-key walk judged it against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied the key into `dist/objectstack.json`), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. + + **What is refused.** A `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node, at any depth, whose config carries a key its executor contract does not declare: at the config itself, on a `screen` field, or on one of a field's `options`. That includes a body-less legacy `loop`, which is judged on key membership alone. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). It comes from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share. Its message carries the contract's own sentence, with the contract's prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss), and closes with the remedy: rename the key to one the contract declares there, or remove it. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the save door. `script` and `subflow` keys were already refused this way. The new `builtinNodeConfigKeysJudged(nodeType)` export names exactly the builtins the spec judges. + + **`@objectstack/service-automation`.** `registerFlow`'s descriptor walk (`validateNodeConfigKeys`) stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. Before the move, the declared key sets were measured equal: on each of these types the descriptor's declared keys, at every position the walk descends to, equal the keys the contract accepts there. So registration refuses exactly what it refused before. The refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. + + **`@objectstack/metadata-protocol`.** The save door judges a flow in its canonical spelling on every path. When no flow canonicalizer resolved (a host with no automation service), or the canonicalizer threw (a draft with a temporary cycle), the schema gate and the runtime authoring gate now judge the body with the ADR-0087 D2 conversions applied (`applyConversionsToFlow`), for the verdict only. The stored body stays the raw request body, exactly as before. A D2 spelling the load path still rewrites (`filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`; `functionName` and `input` on a `script`; `flow` on a `subflow`) is therefore accepted at that door, as at every other one, and an undeclared key that no conversion rewrites is refused there, located. This widens the fallback path back for the `script` and `subflow` aliases the previous key arm had begun refusing there. `duplicatePackage` re-saves through the same gate. + + **What stays as it was.** + + - `try_catch`: its undeclared keys stay registration's, judged by the walk. Its contract's `retry` is the shared `RetryPolicySchema`, which strips an unknown key, while its descriptor closes `retry` to `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. Moving the judge would have widened registration. + - Every plugin node type (the spec does not declare its contract): judged by the walk against its descriptor's `configSchema`, with the walk's own prescriptions. + - A key at or under a region slot (a `loop` body or a `parallel` branch, and their nodes and edges): judged at registration by the region check, as before. + - A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged. + - `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names | + | `fieldValues` on `create_record` / `update_record` | `fields` | + | `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` | + | `visibleIf` on a screen field | `visibleWhen` | + | `itemVariable` on a `loop` | `iteratorVariable` | + | `outputVariable` on an `http` node | delete it: the http executor binds no output variable | + | a key nothing reads | delete it | + + **The one-line fix: rename or delete the key the refusal names.** Registration already refused such a flow, so the fix changes nothing a running flow does. + + **Who is affected, measured.** At `fbcbcf124`, every builtin flow node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (95 nodes) carries only keys its contract declares. The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys whenever the engine publishes the node's descriptor. Its fallback form (while the descriptor list is loading, or when it cannot be read) offers `outputVariable` on an `http` node and the `url` alias on a `notify` node. Registration already refused the first and the conversion rewrites the second; the designer fix is tracked in objectui. hotcrm, deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, over every builtin in `getBuiltinNodeConfigContracts()` but `try_catch`, named by the new export `builtinNodeConfigKeysJudged`; no new code joins `FLOW_SLOT_REFUSAL_CODES`. + - **The ledger.** The D3 semantic entry `flow-builtin-node-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- 78f841b: A `script` or `subflow` flow node whose `config` carries a key its executor contract does not declare is refused at parse, with a location, in the contract's own words: a `script` `bogusKey`, a `subflow` `timeoutMs` written inside `config`, and the like no longer pass the build doors and registration and then fail every run. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The `script` and `subflow` executors parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check reads the node type descriptor's `configSchema`, and these two descriptors publish none, while the build doors' executor-contract arm judged required keys and present values but not key membership. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: ``script 'n': config does not satisfy the script contract — config: Unrecognized key(s) on this script node config: `bogusKey` ``. + + **What is refused.** A `script` node, at any depth, whose config carries a key other than `function`, `inputs` and `outputVariable`, or a `subflow` node whose config carries a key other than `flowName`, `input` and `outputVariable`. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door. + + **What stays as it was.** + + - Every other builtin node type: its undeclared keys are judged at registration against its descriptor's `configSchema`, with that check's own prescriptions, and the build doors do not judge them. + - `decision`: it publishes no descriptor `configSchema` either, but its executor parses no contract, so an undeclared key fails no run and stays unjudged. + - A retired `script` key (`actionType`, `template`, `recipients`, `variables`, `script`) keeps its tombstone path. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`functionName` and `input` on a `script`, `flow` on a `subflow`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key, as its missing canonical key already was. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`funtion`, `outputVariabel`) | the declared key: `function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow` | + | a value the function or child flow should receive, as its own config key (`config: { function: 'f', taskId: '{record.id}' }`) | inside the input map: `config: { function: 'f', inputs: { taskId: { dialect: 'cel', source: 'record.id' } } }` (`input` on a `subflow`) | + | a `subflow` `config.timeoutMs` | on the node: `{ id, type: 'subflow', timeoutMs: 30000, config: { … } }` | + | a key nothing reads | delete it | + + **The one-line fix: rename, move or delete the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does. + + **Who is affected, measured.** At `15ec50e528`, every `script` and `subflow` node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (8 nodes: 6 `script`, 2 `subflow`) carries only declared keys, and so does every one in hotcrm at `c9678036d9` (5 `subflow`, no `script`). The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys for both types (its `timeoutMs` field writes the node, not `config`), and seeds a new node with an empty `config`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + + **`@objectstack/lint`.** `validateStackExpressions` keeps the pre-conversion tolerance it declares: on a raw source, a `script` node's `functionName` alias stays the callable check's to read, not an undeclared-key error, while every other undeclared `script` key is refused there as at the build doors. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, judged for the builtins in the spec's schemaless class (`SCHEMALESS_NODE_CONFIG_SCHEMAS`) that have an executor contract; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries. + - **The ledger.** The D3 semantic entry `flow-script-subflow-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- c565813: feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (`fields[].aggregate`) + + Clause-②: yes (widening) + + - **What a renderer can now read.** Each measure column of a dataset answer (`POST /analytics/dataset/query`) carries `fields[].aggregate`: the aggregate its dataset measure declares, in the closed `AggregationFunction` vocabulary (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`). It is there whether or not the author gave the measure a `label`. So a chart can tell a count from a sum, for example to draw whole-number axis ticks for a count instead of 0.75 / 1.5 / 2.25. + - **What was missing.** The only aggregate on the wire was `builtinAggregate`, and it is present only when the measure has no `label`. A labelled measure, such as a `count` named "Tasks", reached the wire as `{ name, type: 'number', label }`, with nothing to say what kind of number it was. + - **Where it is set.** `AnalyticsService` writes it in the one step that describes a dataset answer's columns from the dataset's own measures. That step runs for both the live query and the draft-data preview, so the two answers agree. A measure's `__compare` column carries the same aggregate. + - **Where it is absent.** Dimension columns. Derived measures, which combine other measures and have no single aggregate (a stray `aggregate` written beside `derived` is ignored when the dataset compiles, so it is not stated here either). And a cube query answer (`POST /analytics/query`), which does not run through the dataset column step. + - **Unchanged.** `builtinAggregate` keeps its meaning: present only on a label-less measure column, to mark a header that is the server's default. No authoring key is added; `aggregate` is a response member only. `AnalyticsResultResponseSchema` and the `AnalyticsResult` contract declare the member, and the REST route relays it as it does every other column key. +- 56c8844: `@objectstack/spec/ui` now exports the rule that decides which forms a `view` body opens to anonymous intake, so a console reads "published" from the same rule the server's anonymous form doors serve + + Clause-②: yes (widening) + + - **New on `@objectstack/spec/ui`:** `publicFormSlug`, `anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`, `anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type. They lived only in `@objectstack/metadata-core`, which a browser console should not depend on. They are pure functions with no imports, beside the `SharingConfigSchema` they read. + - **What they decide is unchanged.** A form is open when its `sharing` has `enabled === true`, `allowAnonymous === true` and a non-empty `publicLink`. The scan covers the same three shapes in the same order: the nested `form`, every `formViews` entry, then the `config` of a `viewKind: 'form'` item. + - **`@objectstack/metadata-core` re-exports the same functions** from `@objectstack/spec/ui`. They are the spec's own bindings, not wrappers or copies, so there is still one copy of the rule. Its exports, names and types are unchanged, and `@objectstack/rest` and `@objectstack/metadata-protocol` keep importing from it. Its built output now loads `@objectstack/spec/ui` to get them. + - **Not covered by the new export:** whether another metadata layer withdraws a form (`anonymousFormIntakeWithdrawnIn`), and whether the deployment's tenancy posture lets the form take an anonymous submission (`anonymousFormIntakeUnavailability`). These two read server state and stay in `@objectstack/metadata-core`. `anonymousFormObjectName`, which names the object a form submits into, stays there beside them; it is a pure read of the form and the view, not of server state. A form the new functions call open can still be withheld by a withdrawal in another layer or by the posture. +- d4680d2: feat(spec)!: `NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots, and a blank bare string is now refused there + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing at two authorable keys (`automation/NotifyConfig:title`, `automation/NotifyConfig:message`), shipped as `minor` under the repo's launch-window convention for breaking changes. It is the grade `TemplateExpressionInputSchema`'s blank-string rule shipped with when it reached the first twelve typed keys. + + - **What widens.** Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`). It now parses and runs. + - **What narrows.** A blank bare string (`''` or whitespace-only) at either key is newly refused, by the shared template input's non-blank rule (`invalid_union`, with the `TYPED_EXPRESSION_SOURCE_REQUIRED.template` sentence). Before, every blank value parsed: + - `title: ''` then failed every run at the executor's guard ("notify: title is required"), so it fails either way, now earlier; + - a whitespace-only `title` passed that guard and was delivered as the notification title, and it is now refused; + - `message: ''` or a whitespace-only `message` was delivered as an empty or blank body, and it is now refused. + + The fix is to write the text, or to delete the key (`message` is optional). + - **Parse output.** `NotifyConfigSchema.parse(...).title` and `.message` go from `string` to `{ dialect: 'template', source }`, for both spellings, because the parse normalizes a bare string to that envelope. The exported `NotifyConfigParsed` type changes with them. Code that reads parse output reads `.source`. The `notify` executor, the one reader in this repo, now does, so both spellings of one text deliver the same `payload.title` and `payload.body`, and a bare string renders exactly what it rendered before. + - **Still refused, with a new sentence.** A value that is neither a string nor a template envelope (a number, an array, a `cel` envelope) was refused before (`invalid_type`). It is refused now as `invalid_union`, with the `TYPED_EXPRESSION_DIALECT_ONLY.template` sentence. + - **New, notify-only.** A template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`). An envelope never parsed at these keys before, so this refuses nothing that used to parse. + - **Placeholder spelling.** These two slots render `{{ }}` holes (`{{ record.name }}`), for a bare string and an envelope alike, and a single-brace `{token}` in them is refused. The `.describe()` on both keys now says so, and no longer says the text is "sent verbatim". + - `@objectstack/service-automation`: the `notify` executor reads `source` from the two template slots, and the descriptor's `title` / `message` descriptions state the `{{ }}` holes in place of "sent verbatim". +- cdeabec: `os migrate meta` stops listing semantic notices whose surface the stack provably does not declare. It counts them instead, and `--all` lists them in full. + + Clause-②: yes + + - **`SemanticMigration.relevantWhen`** (`@objectstack/spec/migrations`) is a new optional field. It holds a structured question over the loaded stack, `{ kind: 'stack-declares', keys: [...] }`: does the stack declare anything under one of these top-level keys? A key's value in a `packages[].manifest` body counts the same as a top-level value. The question is closed and named. It is never free text, and it never matches against the prose of `surface`. The new types `SemanticRelevance`, `StackDeclaresRelevance` and `SemanticRelevanceKey` are exported beside `SemanticMigration`. + - **`applyMetaMigrations`** asks each entry's question of the stack it is given and of every hop checkpoint. + - **`todos` is unchanged.** `MigrationChainResult.todos` and `MigrationHopResult.todos` still hold every semantic entry of every hop crossed, whatever the stack holds, as before. + - **`absentTodos` is a new required member** of `MigrationChainResult` and `MigrationHopResult`. It names the subset of `todos` whose question answered `absent` in all of them: the same objects, in chain order. Code that only reads a chain result needs no change. Code that builds one of these two interfaces itself must now supply `absentTodos` (an empty array when nothing is proven absent). + - **Only a positive proof names an entry.** These cases answer `unknown` and leave it off `absentTodos`: + - a value the question cannot read (a function, a promise, a scalar, a getter that throws); + - a stack that is not a plain object; + - any `plugins`, `devPlugins` or `tiers` entry, since a plugin, or the platform plugins a tier preset loads, can register metadata the stack does not show. + + An entry that judges a conversion which applied an edit in the same run is never named either. + - **The first batch is 25 entries** (5 from protocol 17, 20 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. None of them names a code door. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is never named absent, so it is listed exactly as before. + - **`os migrate meta`** lists `todos` minus `absentTodos`. After the listed notices it prints one line that counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores. + - `--all` prints each of those entries in full, with the keys it was proven absent under. + - `--json` keeps `todos` whole and adds `absentTodos`, plus `hops[].absentTodos` with `--step`. + - `--step` reports each hop's listed count, and adds a `not listed` count to the hop line when that count is not zero. + - A run whose only notices are proven absent still writes `--out`. +- db4c45b: feat(spec)!: `FlowSchema` refuses an edge whose `source` or `target` names no node of its graph, and an edge that repeats an earlier one + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** `FlowSchema` held node ids and edge ids unique, and checked nothing else about an edge. A flow whose edge named a node it no longer held (`start → node_1` over nodes `[start, end]`), or that held `start → node_1` three times, passed `FlowSchema.parse`, `objectstack validate` and the metadata save door, and publish answered 200 with `_diagnostics.valid: true`. Then it ran. The dangling edge carried the run nowhere, silently, and the repeated edge ran its target once per copy: one record update created three identical records. The Studio flow designer produced both shapes after a node was removed. + + **What is refused.** Both rules run in the region walk the node-id rule already uses, at every depth it reaches. The issue's `code` is `custom`, and each issue is anchored on the edge to fix: + + - **An endpoint that names no node of the edge's own graph**, at `edges.N.source` / `edges.N.target`. For an edge inside a `loop` / `parallel` / `try_catch` region the path is the region path, such as `nodes.N.config.body.edges.M.target`. The graph is the flow's own `nodes` for a top-level edge, and the region body's `nodes` for a region edge, because the engine resolves an endpoint there alone. So a top-level edge into a region node is refused too, and the message names the graph the node does live in. The node-id space is still one across the flow for uniqueness. + - **A repeated edge**, at `edges.N`, naming the earlier copy. Repeated means the key the engine selects on: the same `source`, `target`, `type`, `condition` (its dialect and source, so a bare CEL string and its envelope are one condition) and branch `label`. An `isDefault` copy of an unconditional edge is a repeat. A repeat is judged only between edges whose endpoints both resolve. + + That covers `FlowSchema`, `defineFlow`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate`, `os compile`, an artifact's parse, `AutomationEngine.registerFlow` (which parses first) and the metadata save door (`422 INVALID_METADATA`, in draft and in publish mode). + + **What is still accepted, byte for byte.** Two nodes joined by edges the engine tells apart: different conditions, a `fault` edge beside a default one, or `approve` and `reject` branch labels into one node. Every flow whose edges all resolve in their own graph and repeat nothing. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | an edge into a node that is not in the same `nodes` list (`target: 'node_1'`, no `node_1`) | point it at the node it was meant to reach, or delete the edge | + | a top-level edge into a node inside a region body | an edge into the region's container node; the region's own edges reach the nodes inside it | + | the same `source` → `target` edge twice, with the same `type`, `condition` and `label` | one edge. Delete the later copy; an edge meant to take its own route needs its own `condition` or branch `label` | + + **The one-line fix: delete the edge the refusal names, or re-point its endpoint.** Deleting a dangling edge changes nothing a run did, with one exception. A conditioned edge into a missing node still counted as the branch taken when its condition held, so where a flow relied on that, point the edge at a node that ends the branch. Deleting a repeated copy runs its target once per traversal instead of once per copy, which is the defect being removed. + + **Who is affected, measured.** At `aa71c4d9d`, every flow the examples ship (`app-showcase`, `app-crm`, `app-todo`: 35 flows, 55 graphs counting region bodies, 131 edges) has no dangling endpoint and no edge pair sharing a `source` and `target` at all. The flows the packages ship (the `os generate` and `os explain` templates, the new-flow seed, the `@objectstack/verify` fixture) and the platform test checklist's flow bodies are clean by reading. The CLI's golden eval corpus carries no flow. Deployed metadata and other repositories were not measured. Where such an edge already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + + ### The kit + + - **The refusal.** Two blocks in `FlowSchema`'s `superRefine`, after the edge-id rule, over `collectFlowGraphs`. No new error code: the issue is the same `custom` issue the id rules raise. + - **The ledger.** The D3 semantic entry `flow-edge-unresolved-or-repeated-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. +- 8f2e808: feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data + + Clause-②: yes (narrowing) + + **BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. + + ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. + + - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. + - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. + - **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. + - **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. + + +- 9ad3d24: `defineSeed()` refuses a seed record key that names no column of the target object, whatever shape the records arrive in, and its record type now admits the system columns the platform injects (`created_at`, `owner_id` and the rest), which it used to refuse in a record literal. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. It is a call-time refusal, not a type-only change: the check runs when `defineSeed` is called, so it is reached by `os validate`, `os build` and boot through the config module's evaluation. + + **Why.** The docblock promised that "typos in record field names are caught at compile time", and nothing else checked them. The promise held only for a record written as an object literal directly in `records`. TypeScript's excess-property check is the only thing the record type enforced, and it does not run for records from a variable or a `.map()`, for an object typed `ServiceObject`, or for any inline record in an array that also spreads a `Record[]`. A seed with a misspelled key passed `tsc` and `objectstack validate`, and the mistake surfaced, if at all, only when the seed loaded. Measured with the published 17.7.0 types on a real app: an `ObjectSchema.create()` object keeps its literal field keys, and the spread is what silenced the check. The same type refused `created_at` in a record literal, a key the seed loader keeps on insert, so the one legitimate way to seed it was the shape that also hid typos. + + **What is refused.** `defineSeed(obj, config)` throws when any record carries a key that is neither one of `obj.fields` nor a system column the platform injects on `obj`. The injected set is `resolveInjectedSystemColumns(obj).names`, the same per-object answer the registry's injection reads. It holds the driver's `id` always, plus `organization_id`, the audit columns (`created_at`, `created_by`, `updated_at`, `updated_by`), `owner_id` and `owning_business_unit_id` as the object's `systemFields`, `tenancy`, `ownership` and `managedBy` select them. All unknown keys are reported in one error, one line per key, naming the object, the record index and the key, with a near-miss suggestion: + + ```text + defineSeed('crm_case'): unknown field(s) in records — created_atx. + • records[0]: `created_atx` is not a field of `crm_case`. Did you mean 'created_at'? + ``` + + **What is admitted that was not.** The record type adds every injectable system column name (the new exported type `InjectedSystemColumnName`) to the keys a record literal may carry, typed `unknown`. A literal writing `created_at` or `owner_id` now passes `tsc`. The type cannot evaluate an object's opt-outs, so the call narrows it: `created_at` on a `systemFields: false` object, or `owner_id` on an `ownership: 'org'` object, is refused when the call runs. A declared field of the same name keeps its declared value type. + + **Remedy.** Correct, declare or remove the key the refusal names. A misspelled key takes the spelling the refusal suggests (the declared field, or the system column such as `created_at`). A key for a field the object does not declare needs that field declared on the object, or the key removed. A system column the object opts out of (`created_at` on `systemFields: false`, `owner_id` on `ownership: 'org'`) does not exist on that object, so the key is removed. The key named no column of the object, so no value it carried could be stored under it, and no working seed depends on it. + + **Who is affected, measured.** At `0767335c`, every `defineSeed` call in this repository passes: `examples/app-crm` (5 seeds, 28 records), `examples/app-showcase` (19 seeds, 132 records) and `examples/app-todo` (1 seed, 8 records), evaluated against the built package. A misspelled key in the same context is refused, which is the control. Every seed module in hotcrm at `99d290a` passes too (8 modules, 354 records, including the `created_at` its case seeds author), and its `crm_case` with the misspelled `created_atx` is refused. Other repositories and deployed packages were not measured. +- 4578c56: fix(import): `createMissingOptions` now keeps an unmatched option value through the engine's option check + + Clause-②: yes (widening) + + - **What was wrong.** With `createMissingOptions: true` on an import (objectui's Import Wizard sends it for *Keep unknown option values*), the import coercion kept a `select` / `radio` / `multiselect` cell that matched none of the field's options. The engine's write-path option check then refused the same value ("Priority must be one of: high, low"), so on every writable field the row still failed. Only the stage that refused it changed. + - **What happens now.** The import stores the kept value: a single-value cell trimmed, and each unmatched item of a multi-value cell. The dry run admits the same rows the write does. The engine admits exactly the values the import kept, on that import's writes. Every other value outside the options is still refused, and a field bound to a shared picklist that did not resolve is still refused. + - **The field's options do not change.** No option is added to the field. A later write that sends the field is judged against the options again, so sending the kept value back is refused and picking an option is accepted. A write that leaves the field out, such as an edit form saving only the fields it changed, is not judged on that field. + - **`@objectstack/spec`.** `ExecutionContext` declares `keptOptionValues`: per field name, the option values an import kept. It is server-constructed only, like `skipStateMachine` and `preserveAudit`, and is never client-supplied. The `ImportRequest.createMissingOptions` description now states exactly the behaviour above. + - **`@objectstack/core`.** `coerceRow` returns `keptOptionValues` beside `data` and `errors`, and `coerceFieldValue` reports the values it kept. `runImport` puts exactly those values on each write's context. A per-row write carries its own row's values, and a batched create carries the union of its rows' values. A row that kept nothing is written with the import's context unchanged. + - **`@objectstack/objectql`.** `ValidateRecordOptions.keptOptionValues` lets the single-value and multi-value option arms admit a listed value. The engine fills it from the write's `ExecutionContext.keptOptionValues` on insert, update and `validate`. +- c512c25: feat(spec,metadata-protocol): each `GET /meta/_drafts` row carries the draft body's own `label`, or `null` + + Clause-②: yes (widening) + + - **What a client can now read.** Every row of the pending-drafts list (`GET /api/v1/meta/_drafts`, the runtime's `GET /metadata/_drafts`, and the SDK's `client.meta.listDrafts()`) carries `label`: the draft body's own top-level `label`. This list is the only place a client finds an item that exists only as a draft, so before this such an item could be shown by its machine name alone (a draft permission set read `technician`, not "Technician"). + - **Its shape.** `I18nLabel | null`, required on the wire. It is carried as authored: a plain string, or an inline locale map (`{ en: …, 'zh-CN': … }`) on the types whose `label` is an `I18nLabel`. The route takes no locale, so the reader resolves a map the way it resolves every other `I18nLabel` (`resolveI18nLabel` in `@objectstack/spec/ui`). + - **When it is `null`.** The body declares no `label`; it declares one `I18nLabelSchema` refuses (a row stored before its type's schema was enforced on save, or a row of a type with no registered schema); or its stored bytes do not parse, in which case the draft stays listed and every read of its body still fails. It is never the item name standing in for a missing label: a reader that wants a fallback chooses it, knowing the label is absent. + - **Where it comes from.** `SysMetadataRepository.listDrafts` reads it off the `sys_metadata` row it already fetches, so there is no second query, and `ObjectStackProtocolImplementation.listDrafts` passes it through. Of the stored body, only this one member leaves; field definitions and every other body key stay off the header. + - **Unchanged.** The other six members, the filters (`?packageId=`, `?type=`), the org scope, and the authoring gate on both routes. +- 215e662: A `date` or `datetime` field can declare that it is a deadline, and when that deadline is settled: `dueLike` and `settledWhen` + + Clause-②: yes + + - **`dueLike: boolean`** (optional, no default) declares the date a deadline. A renderer may then show relative overdue wording and an overdue colour once the date has passed. Absent means not a deadline: nothing is inferred from the field's name. + - **`settledWhen`** (optional) is a per-record CEL predicate in the `visibleWhen` / `readonlyWhen` / `requiredWhen` family. While it holds, the deadline is settled and no overdue wording or colour applies to that record, for example `record.status == 'done'`. + - **The doors that judge them.** `FieldSchema` refuses either key on any type other than `date` / `datetime`, and refuses `settledWhen` unless the field also declares `dueLike: true`. The CEL is judged by `@objectstack/lint`'s field-rule pass, the one `visibleWhen` goes through, at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no root but `record` (plus `previous`, and `parent` on a master-detail line item). + - **Display only.** Nothing on the write path reads either key. Every metadata that parsed before parses unchanged; a field without the keys behaves exactly as before. + - **Who reads them.** objectui's date and datetime cells read `dueLike` on the record detail surfaces today. Reading `settledWhen`, and retiring the field-name guess, follows in objectui once this spec version is published. + - **Where to author them.** The field designer offers both on date and datetime fields, `settledWhen` only once the field is a deadline. The example apps declare them on the showcase and todo `task` and the CRM `activity`. +- 3d886ee: A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows + + Clause-②: yes (widening) + + - **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. + - **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. + - **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). + - **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). + - **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). + - **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. +- 5910b5e: feat(spec): the error-code ledger lists `INVALID_REQUEST` under `@objectstack/verify` + + Clause-②: yes (widening: a new owner provenance row in the published error-code ledger) + + `ERROR_CODE_LEDGER['@objectstack/verify']` now lists `INVALID_REQUEST`. The verify handle's two write doors, `hooks.run` and `hooks.updateWhere`, answer it for a malformed call, such as a call naming two callers (`{ as: token, system: true }`), or a `hooks.updateWhere` the engine would write by id. They answer in-process, with a thrown `Error` carrying `code`, `status` and `statusCode`; it is a test door with no HTTP path. No code is added: `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before. +- 1884948: feat(spec): the error-code ledger lists `PAYLOAD_TOO_LARGE` under `@objectstack/service-storage` + + Clause-②: yes (widening: a new owner provenance row in the published error-code ledger) + + `ERROR_CODE_LEDGER['@objectstack/service-storage']` now lists `PAYLOAD_TOO_LARGE` (`413`), the code `@objectstack/rest` already registers for its import row ceilings. The storage upload doors answer it for an upload over the File Storage settings' `max_upload_mb`: the presigned upload's declared `size`, the chunked upload's declared `totalSize`, the local raw PUT body and each chunk's running total. + + No code is added. `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before. What widens is the per-package list a reader consults to learn which packages answer a code: a code emitted by several packages is listed under each of them. +- ac8f2c5: The shared retry policy refuses a key it does not declare, and the build doors now judge a `try_catch` node's config keys like every other builtin's: a `retry.maxRetry` slip under a `try_catch` node, or on a job's `retryPolicy`, no longer passes `objectstack validate` and `objectstack compile` to be dropped or refused later. + + Clause-②: no (narrowing: an undeclared key on the shared retry policy is refused at parse wherever it is written, a job's retryPolicy and a try_catch node's retry, and an undeclared try_catch config key at the build doors and the save door, where each passed) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** `RetryPolicySchema` — the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry` — was a plain object schema, so it dropped a key it did not declare. Its defaults are opt-in (`maxRetries: 0`, `backoffMultiplier: 1`), so a dropped key fell back to "no retry" or to a flat delay: a job with `retryPolicy: { maxRetry: 3 }` parsed, deployed and never retried, and nothing said so. On a `try_catch` node the same strip kept the flow parse from judging the node's keys at all: its node type descriptor closes `retry` to five keys, so only `registerFlow`'s descriptor walk refused such a key, after `objectstack validate` and `objectstack compile` had passed it. + + **What is refused.** + + - **A key the retry policy does not declare**, on a job's `retryPolicy` or under a `try_catch` node's `retry`. The policy declares `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. The refusal names the key and, for a near miss or a spelling borrowed from a neighbouring retry vocabulary, the declared key to write (`maxRetry` → `maxRetries`, `maxDelayMs` → `maxRetryDelayMs`, `baseDelayMs` or `initialDelayMs` → `backoffMs`, `retries` → `maxRetries`). A `maxAttempts` is answered with the off-by-one it carries: write `maxRetries` one lower. On a job it is the issue `unrecognized_keys` at `jobs.N.retryPolicy` (`defineStack` → `STACK_SCHEMA_INVALID`, 422; an artifact carrying it is refused whole at load). + - **A key a `try_catch` node's executor contract does not declare** — beside `try`, `catch`, `errorVariable` and `retry`, or under `retry` — at every door that parses a flow: `FlowSchema`, `defineFlow()`, `defineStack`, `objectstack validate`, `objectstack compile`, an artifact's parse, the metadata save door and `registerFlow`. It is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType: 'try_catch', key }`, anchored at the key (`nodes.N.config.retry.maxRetry`), in the contract's words and closed with the rename-or-remove remedy, from the same judge (`flowNodeConfigRefusals`) that already refused an undeclared key on every other builtin. `builtinNodeConfigKeysJudged('try_catch')` now answers `true`. + - **A `retryDelayMs` the load-path conversion leaves behind** under a `try_catch` node's `retry`: the conversion renames the pre-17 spelling to `backoffMs`, but keeps it when a `backoffMs` beside it holds a different value, and leaves a `null`. That copy is refused at `nodes.N.config.retry.retryDelayMs` in the tombstone's own words (rename it to `backoffMs`). Registration refused both before, as undeclared keys. + + **What stays as it was.** + + - Registration refuses exactly what it refused before. The descriptor walk (`validateNodeConfigKeys` in `@objectstack/service-automation`) stands aside for `try_catch` and now judges plugin node types only. A `try_catch` flow that carried such a key was already refused there, so a running flow changes nothing; the refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. + - A key on a `try_catch` region object (`try`, `catch`), or on a region's nodes and edges: the region check's, at registration, as before. + - A pre-17 `retryDelayMs` alone: still renamed to `backoffMs` before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it meets its tombstone, like every other retired spelling. + - `Flow.errorHandling`, which carries the same policy keys, was already closed; a `script` node's retired keys keep the scope they had. + + **Also corrected:** `Flow.errorHandling`'s refusal of a `maxAttempts` key said a bare rename to `maxRetries` would run one attempt fewer than asked for; it runs one more (`maxAttempts: 3` is 3 runs, `maxRetries: 3` is 4), and the message now says so — the prescription, `maxRetries` one lower, is unchanged. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `maxRetry`, `retries` or `attempts` | `maxRetries` | + | `maxAttempts: n` (counts the first attempt) | `maxRetries: n - 1` | + | `baseDelayMs` or `initialDelayMs` | `backoffMs` | + | `maxDelayMs` | `maxRetryDelayMs` | + | `retryDelayMs` beside a `backoffMs` | delete it, keeping the `backoffMs` you meant | + | a key nothing reads | delete it | + + **The one-line fix: rename or delete the key the refusal names.** + + **Who is affected, measured.** Before closing the policy, every writer of either parser was read: in this repository the showcase job and flows, the `app-todo` flows, the docs, the service READMEs, the test fixtures and the load-path conversion's output; in the pinned objectui the job preview sample and the flow designer, whose descriptor form closes `retry` to the same five keys. All of them write only declared keys, and `objectstack validate` over the showcase and `app-todo` examples stays clean. `service-job`'s `runWithPolicy` reads an already-parsed policy and parses nothing. hotcrm, deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `RetryPolicySchema` (`shared/retry-policy.zod.ts`) is a `strictObject` with a curated near-miss table; the key arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts` judges every builtin in the executor contract map, and refuses a tombstoned key the author wrote on every judged type but `script`. No new code joins `FLOW_SLOT_REFUSAL_CODES`. + - **The ledger.** The D3 semantic entry `try-catch-and-retry-policy-undeclared-keys-refused` (protocol 18). No key is removed, so there is no new tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- e75dced: `AuthSessionApi.getSession` (`@objectstack/spec/contracts`) now declares the optional `query.disableRefresh` that the in-process session readers send + + Clause-②: yes (widening) + + - **The declaration.** `getSession`'s input is `{ headers: unknown; query?: { disableRefresh?: boolean } }`. It was `{ headers: unknown }`. The return type and the rest of `AuthSessionApi` are unchanged. + - **Why.** The in-process readers call `api.getSession(inProcessSessionReadInput(headers))` (`@objectstack/types`). For a request that carries a better-auth session cookie, that input is `{ headers, query: { disableRefresh: true } }`, so the session renews only on the `get-session` route, which re-issues the cookie. A bearer-only request is still read with `{ headers }` alone. The declaration said the readers send only `{ headers }`, which stopped being true when the readers moved to the helper. + - **Nothing to migrate.** The added key is optional. A caller that passes `{ headers }` compiles as before, and so does an implementation of `getSession` that accepts `{ headers }` and ignores the rest. An implementation that reads `input.query?.disableRefresh` now type-checks against the contract instead of needing a cast. +- 9af0005: `ObjectSchema.attachedOnRead`: an object declares the blocks a service attaches to each row it serves, computed per caller on read and never stored + + Clause-②: yes (widening: a new optional `ObjectSchema` key) + + - **The key.** `attachedOnRead` is an optional map from block name to that block's leaves, each leaf naming its value type: `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. Block names and leaf keys take the field-name grammar (lowercase snake_case). A leaf's type is one of `number`, `text`, `boolean` or `date`, the four value types `Field.returnType` declares. + - **It is not a field.** It provisions no column, and no driver, form, list view, export, write path or translation bundle reads it. Its reader is the shared build validator, `@objectstack/lint`'s expression rule over `@objectstack/formula` (what `os build` and `os validate` run): `record.` resolves, and `record..` resolves only to a leaf the block declares (see the `@objectstack/formula` and `@objectstack/lint` entries). No other field-existence check reads it. + - **Refused at parse:** a leaf type outside the four, a leaf that is a nested block or a field definition, a block or leaf name outside the grammar, a block that names no leaf, and a block that repeats a field name the object declares. Each refusal is located at the offending key. + - **Nothing to migrate.** The key is optional and nothing writes it by default; an object without it parses and validates exactly as before. +- b2549a8: feat(spec): `ConnectorProviderContext.resolvePackagePath` — a connector provider factory can resolve a path against the declaring app's root + + Clause-②: yes (widening) + + - **What is new.** `ConnectorProviderContext` gains one optional, host-provided member, `resolvePackagePath(relativePath): Promise`, beside `loadPackageFile`. It resolves a relative path against the root of the stack or package that declared the connector entry and returns the absolute path. `'.'` returns the root itself. It refuses (throws on) an empty path, an absolute path, and a path that escapes the root after resolution, such as `../x` or `a/../../x`. That is the same rule `loadPackageFile` follows. It reads nothing and does not check that the path exists. + - **Who hands it.** The connector materializer in `@objectstack/service-automation` hands it to every provider factory. It is anchored at the plugin's `packageRoot` option, the same root `loadPackageFile` reads under, and falls back to `process.cwd()` the same way. The two members now share one confinement check, so they cannot disagree about what is inside the root. `loadPackageFile`'s behaviour and error messages are unchanged. + - **Who reads it.** Nothing yet. `@objectstack/connector-mcp` is next: a declarative stdio transport will use it as the launched process's working directory, so a relative command or argument resolves against the app's root instead of the directory the server was started from. + - **What does not change.** Which commands a declarative stdio transport may launch is untouched. A factory that does not read the member behaves as before. + - **Nothing to migrate.** A host that builds its own `ConnectorProviderContext` may leave the member out. A factory must then keep its existing behaviour, or fail with a clear message if it needs the root. +- ee8751d: feat(storage)!: the storage scope `public` is retired — no scope ever made a file publicly readable, and `acl: 'public_read'` stays the one opt-in for anonymous download (#22443) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on two published surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. + + `StorageScopeSchema` described `public` as "publicly accessible static assets", and the upload doors stored a caller's `scope: 'public'` on the new file. Neither ever made a file public. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope `public` and the default acl was stored private, needed a signed-in caller, and nobody was told (ADR-0049 enforce-or-remove). ADR-0104 makes `acl: 'public_read'` the one opt-in for anonymous download, so the scope is retired, not enforced: enforcing it would have let any uploader make a file anonymous at upload. + + ### What now refuses `public` + + - **The presigned upload door and the chunked upload door** (`@objectstack/service-storage`) answer an upload naming `scope: 'public'` with `400 INVALID_REQUEST`, before any file row, session row, upload URL or backend upload exists. The message names the remedy. Every other scope, and an omitted one, is taken exactly as before. + - **`StorageScopeSchema`** (`@objectstack/spec`): `public` left the enum. Writing it fails `tsc`, and parsing it, on its own or as `ObjectStorageConfig.scope`, fails with the prescription instead of zod's generic enum message. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload with `scope: 'public'` | another scope, or no `scope` for the default `user`; then set `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in | + | `ObjectStorageConfig` with `scope: 'public'` | another scope, or no `scope` for the default `global` | + + **The one-line fix: stop naming scope `public`, and mark each file that must render before sign-in `acl: 'public_read'` on its stored file record.** The upload request carries no `acl`: every upload is stored `acl: 'private'`, so adding an `acl` to the upload request changes nothing. + + **Files already stored with scope `public`** download exactly as they did: a signed-in caller gets them, an anonymous one gets `401`, unless the file is `acl: 'public_read'`. Their stored file records are rewritten to scope `user` by the one-time operator sweep `@objectstack/service-storage` exports, which changes no access: the storage key and the bytes stay as they are. + + ### The retirement kit + + - **Schema.** `StorageScopeSchema` retires the member with `enumWithRetiredValues`. No D2 conversion: no metadata type carries this schema or the upload request, so `os migrate meta` has no authored source to rewrite. + - **D3 entry `storage-scope-public-retired`** carries the judgement no rewrite can make: whether a file that was uploaded as `public` must really be readable before sign-in. + - **Upload request contract.** The `scope` description on the presigned and chunked request schemas no longer offers `public` as an example, and says what the scope is and is not. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries `StorageScope`. + + **Measured producers: none.** At origin/main da159f74e6, nothing in `packages/`, `examples/`, `apps/`, `skills/` or `content/docs/` uploads with scope `public` or declares an `ObjectStorageConfig` with it. The one hit was a `@objectstack/spec` request-schema test fixture, changed to `tenant` here. The same search finds 29 `scope: 'attachments'` writes, which is its control. At the `.objectui-sha` pin f0268ad784 and at objectui main 2063f7a, the upload adapter forwards a caller's scope and no caller names `public`: the console passes none, and the record attachments panel passes `attachments` (the control). Deployed callers and stored rows NOT MEASURED. +- 35ef501: An `update`-mode `validate()` preview judges the stored row merged with the patch, as the by-id update does, so an import dry run of a matched row admits the rows the import's update admits (#22445) + + Clause-②: yes + + The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. + + - **Before.** An `update`-mode preview (`engine.validate(object, rows, { mode: 'update' })`, `validateData`, and the import dry run of a row that matched a stored record) read no stored row, so the record its rules judged held only the patch. A `requiredWhen`, an option `visibleWhen` or a `validations[]` rule that reads a column the patch omits faulted there, and the preview refused the row with `rule_violation` / `unevaluable`, although the real update reads the stored row first and admits it. The refusal also said the omitted column was one "which this object does not declare", sending the author looking for a field that exists. + - **Now.** A row that carries its `id` (the address every update door already folds into the payload) is judged against the row that id names: the stored row is the rules' `previous`, and their record is the stored row merged with the patch, exactly as the by-id update judges it. A reference field a traversing rule reads is resolved from that merge too. The import dry run of a matched row sends the matched record's id this way, so nothing new crosses the protocol. + - **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it). A stored column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a value the caller could not read in full. File fields are compared in their stored form, so a readable file reference is judged as the id it holds. Measured on `driver-sql` (SQLite) and `driver-memory`, a plain readable column (number, currency, percent, boolean, date, datetime, JSON, multiselect, select, text) reaches the rules as its stored value. + - **Unchanged.** A row with no `id`, or whose id names no row the caller can read, is judged on the patch alone, as before. `required` and value checks still look only at the supplied keys, matching a PATCH. A merge that really violates a rule is refused, as the write refuses it. The preview still runs no `readonlyWhen` or primary-key strip, so it can report fewer dropped fields than the update. + - **The refusal names the real cause.** Where a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row), the refusal now says the value was not supplied and no stored row was read, instead of saying the object does not declare the column. A column the object really does not declare keeps the old sentence. + + `@objectstack/core`: the import runner's dry run passes the matched record's id with an update row. `@objectstack/spec`: the `ValidateDataRequest.mode` description and the `ValidateDataResponse` note say what an `update` preview now reads. + + The pending entry for the option-gate change in this release says an `update`-mode preview reads no stored row and refuses a cascade pick whose parent column the patch omits. That describes the preview before this change: with the row's `id`, the preview now reads the stored row and judges the pick as the write does. +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- 557ae7c: `ISecurityService` declares an optional, feature-detected member that answers the existing ADR-0055 master-detail write check: `checkControlledByParentWrite(object, recordId, context)`. + + Clause-②: yes (widening) + + - **What it answers.** It answers what the master-detail write check decides for an update of one record by the caller. A by-id write to a `controlled_by_parent` record requires edit access to its master, and the write path checks each master up the chain: the object-level `update` grant, the master's write row-level security, and record sharing. The answer equals what a by-id update of the same record gets from that check, the ADR-0090 D10 delegator leg included. It adds no verdict: it exposes the one the write path already reaches, so a gate on a record's attachments or comments can judge the record as its own update is judged. + - **What it resolves with.** `ControlledByParentWriteOutcome` is a discriminated union. `allow` means the check does not refuse. `deny` names the leg that refused (`ControlledByParentWriteDenialLeg`: `object_permission`, `row_level_security`, `record_sharing` or `master_chain`). `not_applicable` means the object is not `controlled_by_parent`. `unresolvable` names why the check reached no verdict (`ControlledByParentWriteUnresolvedReason`: a missing `master_detail` relation, a record that does not exist, or an empty master reference). A store fault is not an outcome: the member rejects with the engine's own error, so the fault keeps its declared status (a datasource outage is `503`). + - **Optional, and absence is typed.** A security service without the member still satisfies the contract, and an unguarded call does not compile. Absence means no master check is composed for a consumer to ask, which is not a policy that admits. + + Nothing an author writes changes, and no runtime behaviour changes. `@objectstack/plugin-security` does not serve the member yet. An implementation typed as `ISecurityService` that serves this name must serve it under the declared signature. +- 6a3fe25: feat(spec,service-storage,client)!: one upload-scope list — the upload requests' `scope` and the SDK's `storage.upload` close to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published request contract and on the SDK method that sends it, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. + + The presigned and chunked upload requests declared `scope` an open string, while the stored file record (`sys_file.scope`, a closed select) only ever took `user`, `tenant`, `private`, `temp` and `attachments`. Any other value reached the `sys_file` insert, the data engine refused it as an invalid option, and the upload door answered that caller error as `500 INTERNAL`, with a message telling the operator to restore the data engine. + + ### What changes + + - **`UploadScopeSchema` / `UploadScope`** (`@objectstack/spec`, new, exported from `@objectstack/spec/api`): the upload-scope vocabulary, declared once — `user`, `tenant`, `private`, `temp`, `attachments`. It is a different list from `StorageScopeSchema`, which classifies a storage configuration and is not read by any upload. + - **`GetPresignedUrlRequestSchema.scope` and `InitiateChunkedUploadRequestSchema.scope`** read it. The default stays `user`. A literal outside the list fails `tsc`, and a parse refuses it on the `scope` key. + - **`client.storage.upload(file, scope)`** (`@objectstack/client`): the `scope` parameter is typed `UploadScope` instead of `string`, default `user` unchanged, so a scope outside the list fails `tsc` at the SDK call. `client.storage.getPresignedUrl` and `client.storage.initChunkedUpload` take the request types above and narrow with them. + - **The `sys_file` scope select** (`@objectstack/service-storage`) takes its options from the enum, in its order, with the same labels. The stored values do not change. + - **The presigned upload door and the chunked upload door** answer a scope outside the list — any string, a case variant, `null`, a number — with `400 INVALID_REQUEST`, naming the allowed values, before a file record, a session record, an upload URL or a backend upload exists. `public` is refused by the same gate and keeps its remedy (`acl: 'public_read'` on the stored file record). An omitted scope is the default `user`, as before. A real data-engine fault still answers `500`. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload naming a scope outside the list (a key prefix such as `avatars`, a folder, a record path) | one of `user`, `tenant`, `private`, `temp`, `attachments`, or no `scope` for the default `user` | + | `client.storage.upload(file, scope)` called with a `scope` typed `string` (`@objectstack/client`) | pass one of the five, or type the value `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | + | a caller passing a scope typed `string` into either upload request | type it `UploadScope` | + + **The one-line fix: send one of the five upload scopes, or none.** `attachments` is for a file whose referrers are record attachment rows (it is what orphan tombstoning reads); `temp` for a scratch file; otherwise `user` or `tenant`. + + **No stored file record moves**: no upload naming another scope ever succeeded, so no stored record carries one. + + ### The kit + + - **D3 entry `upload-request-scope-closed`** carries the judgement no rewrite can make: which scope a file that was sent under a free name really belongs under. No D2 conversion: no metadata type carries the upload request, so `os migrate meta` has no authored source to rewrite. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries the upload request. +- 7731d70: `record:approval_decision` is a declared page component type — the approval decision panel of a `sys_approval_request` record page — with a `ComponentPropsMap` row that accepts no props + + Clause-②: yes (widening) + + - **New `PageComponentType` member:** `record:approval_decision`, in the reserved `record` namespace. One node shows an approval request's decision progress and its declared decision actions. It reads the record context (the request row the page binds, with its `viewer` and `decision_progress`) and `sys_approval_request`'s own declared actions, and nothing authored. Outside a `sys_approval_request` record page it draws nothing. + - **Its `ComponentPropsMap` row is an empty strict object.** A node with no `properties`, or `properties: {}`, passes. Any key inside `properties` is refused by name: `os validate` / `os build` / `os lint` report it as `component-props-unknown-key`, naming the component and the key. Node-level keys (`id`, `className`, `visibleWhen`, …) stay on the node as for every component. + - **A misspelling is refused.** Because `record` is a reserved namespace, `record:approval_decison` and other near spellings are `component-type-unknown` errors that offer `record:approval_decision`. + - **Not printable.** Inside a page that declares `print`, the type is refused with its own reason (decision controls that differ by viewer, with nothing to print), like the other action-control blocks. + - **Consumers.** objectui renders it (objectui#12045, the approvals request page), and `@objectstack/plugin-approvals` places it on the request page it ships (objectstack#22473). A consumer that derives coverage from `PageComponentType.options` — a designer palette, a renderer registry — sees one more member to classify. +- 3073b72: A `{{ }}` hole in a flow text slot (a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`) whose root is a `$` name the flow engine does not bind is refused. It is refused at the same doors, and by the same judge (`textSlotTemplateRefusal`), as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. `'By {{ $User.Id }}'` used to pass all three and send `'By '`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The hole grammar admits `$` in a name so that the engine's own variables have a spelling (`{{ $error.message }}`). That also makes `{{ $User.Id }}` a well-formed hole, but over a root no flow variable answers to. The text went out with the fragment missing, the run reported success, and nothing warned. Its single-brace spelling, `{$User.Id}`, was already refused with a remedy. The `$` names are reserved for the engine: a resume signal may not write one. + + **What is refused.** + + - A hole whose root is a `$` name other than the variables the engine binds: `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`. + - `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` raise a `custom` issue at the slot's key. + - `registerFlow` refuses the flow, and a stored flow carrying such a hole is skipped at boot with a warn naming it. + - `objectstack validate` reports `expression-invalid` at `error`. + - The remedy for `{{ $User. }}` is the sentence `{$User.}` gets: for `$User.Id`, compute the run user's id into a variable with an `assignment` node's CEL value envelope (`current_user.id`), then write the variable as a hole; any other `$User` path never resolved, and the refusal says how to read the user record by that id. Any other root is named in the refusal, beside the variables the engine does bind. + - A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy. + + **Unchanged.** `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}` and every hole over an engine-bound `$` variable. The template engine binds no new variable. + + **`@objectstack/lint`.** In a text slot, `flow-bare-dollar-reference` prescribes the hole for a bare `$X.y` written outside the holes only when the judge admits that hole. A bare `$User.Id` gets the judge's refusal and remedy instead of a `{{ $User.Id }}` the judge refuses. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `message: 'By {{ $User.Id }}'` | an `assignment` node first, `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `message: 'By {{ by }}'` | + | `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` | + + **The one-line fix: compute a run-user value into a variable first, and name a variable the flow binds itself without the `$`.** + + **Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), has no text-slot judge. Its `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain strings, so it accepts `'By {{ $User.Id }}'` in every one of these slots. Its single-brace interpolator substituted the inner `{ $User.Id }` token and left a literal brace on each side. This repository was measured with `git grep` over `examples`, `packages`, `skills`, `apps` and `content`: no flow text slot outside tests carries a `{{ $… }}` hole other than `{{ $error.… }}`. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `textSlotTemplateRefusal` in `automation/flow-text-slot-template.ts` reads one package-internal list of the `$` variables the engine binds. `@objectstack/service-automation`'s `text-slot-template.test.ts` scans that package's sources for every `$` variable they bind by name, and fails when the list misses one. + - **The ledger.** The D3 semantic entry `flow-text-slot-unbound-dollar-root-refused` (protocol 18). There is no D2 conversion: what the hole was meant to read is not in the flow. +- 5b12503: feat(spec): the protocol upgrade guide ships inside `@objectstack/spec`, and both ADR-0087 projections are generated and verified at publish + + Clause-②: no + + `@objectstack/spec` now ships `protocol-upgrade-guide.md` beside `spec-changes.json`. An upgrading project reads the guide for the exact version it installed, offline: + + ```bash + cat node_modules/@objectstack/spec/protocol-upgrade-guide.md + ``` + + The same file is attached to the package's GitHub Release, next to `spec-changes.json`. Its content is unchanged: one section per protocol major from the chain's support floor on, generated from the ADR-0087 conversion table and migration chain. + + Both projections are now generated by the release lane, from the registries, right before the tarball is packed. The lane refuses to publish an artifact whose copies differ from a fresh generation. This covers the per-major records and aggregate of `spec-changes.json`, and the whole guide. The rc lane takes the same step, so an rc tarball no longer carries a copy that was committed earlier. The per-release `release` section of `spec-changes.json` is generated and verified exactly as before. + + Nothing changes for an author, and nothing is renamed or removed. +- f89b5f5: feat(i18n): a screen field's option labels and a flow's terminal toasts have translation keys (`flows..screens..fields..options.`, `flows..successMessage` / `.errorMessage`) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. Two options of one screen field whose values read the same as text — `1` and `"1"`, `true` and `"true"`, or a repeated value — are now refused when the flow is parsed (`FlowSchema`, `objectstack validate`, the metadata save door, `registerFlow`), at the second option's `value`. Neither the option's label translation (keyed by the value as text) nor the console's select can tell such a pair apart. To fix a refused flow, give each option of the field a value that stays distinct as text, or drop the duplicate. Measured reach in this repository: 0 — no flow in its examples carries such a pair. + + - **What is new.** A fully translated screen flow no longer has to keep its select options and its completion or failure toast in the source language. Translate an option's label under `flows..screens..fields..options.`, and the flow's own toasts under `flows..successMessage` and `flows..errorMessage`. + - **How an option is addressed.** By its value read as text: `String(value)`, so the option `{ value: 1, label: 'Tier 1' }` is translated under `options.1` and `{ value: true, … }` under `options.true`. `flowScreenFieldOptionKey(value)` (`@objectstack/spec/automation`) spells the key. Not by position (reordering options would re-point every translation), and not by an object field's options (a screen field declares no such binding). + - **One new refusal**, the narrowing the BREAKING note above describes: option values of one screen field must stay distinct as text, because the label translation is keyed by them. + - **The toasts translate only where the flow authors one.** A bundle cannot add a toast to a flow that declares none; `objectstack validate` refuses such a key (`translation-target-unknown`, error). + - **Where it is read.** `translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn. + - **`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`). + - **`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them. + - **A screen's `description`** is keyed too, in the same release, and translated by the engine rather than the console: see the entry for `flows..screens..description`. +- d25b187: feat(i18n): a screen's body text has a translation key, and a screen's heading and body text are translated by the engine in the run's locale (`flows..screens..description`) + + Clause-②: yes (widening) + + - **What is new.** A fully translated screen flow no longer has to keep a screen's body text in the source language. Translate it under `flows..screens..description`, beside the heading's `flows..screens..title`, whose address does not change. + - **A translation is a template.** A screen's `title` and `description` are `{{ }}` templates rendered for each run, so a translation keeps the holes of the text it translates: `description: '任务“{{ subject }}”创建成功!'` for `'Task "{{ subject }}" created successfully!'`. The schema judges a translated `description` with the same text-slot rule the source text gets, so a single-brace `{subject}` is refused with the `{{ subject }}` spelling. An empty string is accepted: it is the untranslated slot `os i18n extract` writes. + - **The body text translates only where the screen authors one.** A bundle cannot add body text to a screen that declares no `config.description`. + - **Who translates it.** The automation engine, not the console. It picks the translated template in the locale of the person who started the run and then fills the holes, so the screen a paused run serves is already translated. `ScreenSpec.title` and `ScreenSpec.description` now document that a client draws them as served and never overlays a translation on them. + - **`flowScreenCopyKey(flowName, nodeId, key)`** (`@objectstack/spec/system`) spells the key the engine reads, beside `flowRefusalMessageKey`. `FLOW_SCREEN_COPY_KEYS` is now `['title', 'description']`, and `translateFlow` overlays a translated `description` on a flow document where the screen authors one. + - **`resolveFlowScreenTitle` is not for a served screen.** A served `ScreenSpec.title` is already translated and filled, so overlaying the resolver's answer on it would draw the template's holes as literal text. The console stops calling it in a following release, and the function retires after that. +- dab6bf4: `record:approvals` and `record:attachments` are declared page component types — the record page's Approvals and Attachments panels — each with a `ComponentPropsMap` row that accepts no props + + Clause-②: yes (widening) + + - **Two new `PageComponentType` members:** `record:approvals` (the record's approval timeline: the step its approval sits at, who it waits on, what has been decided) and `record:attachments` (the record's attached files, with upload, download and delete). objectui already renders both, and its default record page places them: the Attachments tab on every record page of an `enable.files: true` object, the Approvals tab on a record that has approval requests. + - **The page Studio's page create seeds now passes `os validate`.** Before, `record` was a reserved namespace with neither type declared, so `os validate` / `os build` / `os lint` refused the seeded page of any `enable.files` object with `component-type-unknown` on its `record:attachments` node. The metadata save door did not refuse it and still does not. + - **Both rows are strict and accept no key.** A node with no `properties`, or `properties: {}`, passes. Any key inside `properties` is reported as `component-props-unknown-key`, naming the component and the key. Node-level keys (`id`, `className`, `visibleWhen`, …) stay on the node as for every component. + - **`record:approvals` refuses the runtime channel it reads, with the fix.** Its renderer reads `approvals` (the approval requests the default record page fetched) and `currentUserId` (the signed-in user) from the node, and both are filled by the host at runtime. An authored `approvals` would show a static approval history that never updates, and an authored `currentUserId` would decide for every viewer who counts as the submitter. Both are refused with a message that says to omit them: the block fetches the record's own approval requests and reads the signed-in user itself. + - **A misspelling is refused.** `record:attachment`, `record:aprovals` and other near spellings are `component-type-unknown` errors that offer the declared spelling. + - **Not printable.** Inside a page that declares `print`, each type is refused with its own reason, in place of the generic "not in the printable block subset" one. + - **Consumers.** A `kind: 'react'` page that names `` or `` is now refused by `react-block-needs-record-context`, as every `record:*` block is: these blocks read the record context a record page mounts, and a react page mounts none. A consumer that derives coverage from `PageComponentType.options` or the keys of `ComponentPropsMap` — a designer palette, a renderer registry — sees two more members to classify. +- a00cf99: feat(spec): `enable.approvalsVisibleToReaders`, the per-object opt-in for read-only approval visibility to a record's readers + + Clause-②: yes (widening) + + - **The key.** `ObjectCapabilities` (an object's `enable` block) gains `approvalsVisibleToReaders`, a boolean that defaults to `false`. An object that declares nothing is unchanged. + - **What `true` grants.** A caller who can read a record of the object sees that record's approval requests and their full action history, read-only. "Can read" is the object's own CRUD, sharing and RLS, asked as the caller. No new permission is granted. This holds on the approvals API and on the generic data API alike (`sys_approval_request`, `sys_approval_action` and `sys_approval_approver`), on a read that names the record. A list that names no record, such as the inbox, is not widened. No approval action is offered: approve, reject, reassign, recall and comment keep authorizing as before. + - **What becomes visible.** The request row, including its snapshot of the record at submission, and each action's actor, decision, time, comment text and attachments. Turn it on only for objects whose approval commentary the record's readers are meant to see. + - **Studio.** The object form's Capabilities section lists the new toggle. + - **Nothing to migrate.** To opt an object in, write `enable: { approvalsVisibleToReaders: true }` on it. +- 29a163f: `sys_comment_reaction`: an emoji reaction to a comment is now the reacting user's own record, one row per comment, emoji and user + + Clause-②: yes (widening) + + - **The object.** `@objectstack/plugin-audit` registers `sys_comment_reaction` beside `sys_comment`. A row holds `comment_id` (the reacted comment's id), `emoji` (the character sequence, at most 64 characters) and `user_id` (the user who reacted). The data API offers `get`, `list`, `create`, `delete` and `bulk` on it, so `createMany`, `deleteMany` and `/batch` creates and deletes work too. It offers no `update`, so `updateMany` stays refused: a reaction is removed and made again, not edited. Every row of a batch meets the same rules as a single write. + - **Who may read it.** A reaction is readable exactly when its comment is. Every read of the object (`find`, `findOne`, `count`, `aggregate`, and so the list total) is narrowed to the rows whose comment the caller can read, decided by a read of those comments under the caller's own context. A reaction whose comment was deleted is read by nobody. + - **Who may write it.** A caller reacts only to a comment it can read; on any other comment, including one that does not exist, the create is refused with `403 RECORD_NOT_ACCESSIBLE`. `user_id` is set from the signed-in user, and a value the client sends is replaced; a session with no user is refused. A member deletes their own reaction, and not another member's, by the platform's own-record rule (`owner_only_deletes`). The author-only edit rule on `sys_comment` is unchanged: reacting no longer writes the comment's row. + - **One row per reaction.** `(comment_id, emoji, user_id)` is unique within an organization. Two members reacting at once write two rows. The same reaction a second time is refused, and the data API answers `409 UNIQUE_VIOLATION`. + - **Reading reactions for a feed.** Read the reactions of the comments on screen in one request, `comment_id` `$in` those ids, and group the rows by comment and emoji. Nothing is stored or computed on the comment row. + - **Granting it.** The object's create, read and delete bits come from a permission set, as `sys_comment`'s do. The `everyone`-bound baseline (`member_default`) grants nothing on it. + - **`@objectstack/spec`: the platform object registry lists it.** `PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']` now includes `sys_comment_reaction`, and so do `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName`. A stack that references the object is no longer reported as naming an unknown platform object. This is the only change in the package. + - **`sys_comment.reactions` is unchanged in this release.** The column is still declared and still written by clients that write it. It is retired separately, and reactions stored in it are not converted into records. +- e223152: feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The slot map read as seven independent slots, and two of them are not. On a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored. A page authoring both passed `PageSchema.parse`, `objectstack validate` and the metadata save door, and its details body (its `sections`, its `hideFields`) silently never applied. The platform's own `sys_user` record page was one: its Identity and Audit sections never showed, and the ban columns it hides were never hidden by it. + + **What is refused.** A page whose `slots` map carries both a `details` and a `tabs` key, at `slots.details`, with a `custom` issue that names both slots and the fix. Either slot may be one component or an array; an empty `details: []` is refused like a full one, and the page's `kind` does not matter. That covers `PageSchema`, `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate` and the metadata save door (`422 INVALID_METADATA`). The check is exported as `checkPageSlotPair`, for a mirror built from `PageSchema.shape` to re-attach. + + **What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` | + | both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot | + + **The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair. + + **Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is replayed unchanged at load, so it renders as before (the authored tabs, without the details body); its read diagnostics name the pair, and saving it again is refused until the details body moves. + + ### The kit + + - **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code. + - **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item. + - **The ledger.** The D3 semantic entry `page-slots-details-beside-tabs-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which tab item carries the details body, and under which label, is the author's decision. +- 9963322: feat(plugin-audit)!: `sys_comment.reactions` retires; an emoji reaction lives only in `sys_comment_reaction`, and reactions stored in the old column are not migrated + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + **Existing emoji reactions stored in `sys_comment.reactions` are gone after the upgrade.** They are not converted into `sys_comment_reaction` records, and no screen or API returns them again. Reactions now live in `sys_comment_reaction`, one row per comment, emoji and user, the reacting user's own record. Where the entry that introduces `sys_comment_reaction` says the column is unchanged, this entry supersedes it: the column retires. + + - **`sys_comment`** (`@objectstack/plugin-audit`) no longer declares `reactions`. Nothing takes its place on the comment row: no stored aggregate, and no block computed on read. Its label leaves the four generated translation bundles. + - **The data door refuses the field.** A create or an update of a comment that still carries `reactions`, and a list filter on it, answer `400 INVALID_FIELD` naming the field, and nothing is written. The rest of the payload is not applied either. A read of a comment no longer returns `reactions`. + - **`@objectstack/spec`**: the protocol-18 step of the ADR-0087 migration chain gains the semantic entry `sys-comment-reactions-column-retired`, so `os migrate meta` and the upgrade guide carry the prescription below. + + **What moves for consumers.** + + | You wrote | Write instead | + |:---|:---| + | `sys_comment.reactions` read as a map of emoji to user ids | one read of `sys_comment_reaction` for the comments on screen (`comment_id` `$in` their ids), grouped by comment and emoji | + | an update of `sys_comment` that rewrites `reactions` to add or remove a user | create the user's own `sys_comment_reaction` row (`comment_id`, `emoji`) to react; delete that row to un-react | + | `reactions` in a seed row, filter or list-view column on `sys_comment` | delete it | + + An application that shows reactions grants `sys_comment_reaction` read, create and delete beside its `sys_comment` grant; without it a member cannot react. The console's record chatter already reads and writes `sys_comment_reaction` rows wherever the deployment declares that object, as this framework does. + + **Existing databases — nothing moves automatically.** Schema sync is additive: the physical `reactions` column stays with the values it holds, the boot drift report names it orphaned (`sys_comment.reactions: column exists in the database but not in metadata`), and the boot is otherwise clean. The data door neither returns nor accepts the column. To drop it, run `os migrate apply --allow-destructive` with the audit plugin composed (an auth secret resolves); the stored reactions go with it. +- b27b894: feat(spec): the approvals service contract declares an optional, transport-neutral action-page member (`IApprovalService.handleActionPage`) + + Clause-②: yes (widening) + + - **What is new.** `IApprovalService` in `@objectstack/spec/contracts` gains one optional member, `handleActionPage?(request: Request): Promise`. It serves the ADR-0043 action page, the session-less page an approver reaches from the approve or reject link in an e-mail or IM message. `GET` renders the confirm page from the `token` query parameter and never decides. `POST` redeems the `token` form field and answers the result page. + - **The token is the only credential.** The member takes no `ExecutionContext` and reads no session, cookie or `Authorization` header. The decision is audited as the approver the token binds. + - **Transport-neutral.** A web-standard `Request` in, a `Response` out: no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount pages on, and the runtime dispatcher's `/approvals/act` domain is the member's one caller. + - **Absence.** The member is optional, so an approvals implementation without action pages still satisfies the contract. Its caller answers a typed 404 or 501 when the `approvals` service or this member is absent, never `ROUTE_NOT_FOUND`. + - **Nothing changes for existing implementers.** No existing member moved. Nothing implements or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted pages stay as they are. +- 02d9f69: A file field whose `sys_file` read was refused to the reader reads as refused, not as "no file" + + Clause-②: yes + + The engine expands a stored file id (`file` / `image` / `avatar` / `video` / `audio`) by reading `sys_file` as the caller. A reader who may read the record but not `sys_file` (the default for any member whose application grants no `sys_file` read) is refused that read, and the field used to come back as the bare id: the same value an id with no committed file row produces. A refused file and an absent one were one answer, and the server logged a `warn` per read telling the operator to check storage availability. + + - **From.** `{ "attachment": "f_7cQx2Lm90a" }` for a reader refused `sys_file`, plus `warn: sys_file lookup failed; file fields keep their raw ids and will render as "no file"`. + - **To.** `{ "attachment": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }`. Multi-value fields mark each id. Nothing else is served, because nothing else was read: no `name`, `size`, `mimeType` or `url`. A consumer that needs the download link derives the stable `/api/v1/storage/files/:fileId` endpoint from the id, as it does for a bare id; the download door judges access by the record that owns the file. The refusal is logged at `debug` only, because the reader is told in the value. + - **Unchanged.** A reader allowed to read `sys_file` gets the hydrated `{ id, name, size, mimeType, url }`. An empty field reads empty. A missing `sys_file` table still leaves the bare id, silently. Any other read failure (connection, timeout, a database-level ACL fault such as `42501`) still leaves the bare id with the `warn`. Only the security layer's `PERMISSION_DENIED` refusal takes the new form. + + `@objectstack/spec` declares the shape: `FileRefusedValueSchema` / `FileRefusedValue` (`{ id, metadataRefused: true }`, closed), and `valueSchemaFor(def, 'expanded')` for the file types admits it beside the bare id and `FileValueSchema`. It is a read form only. The stored form stays the bare id. + + **For consumers.** Code that reads a file field's expanded value as `string | FileValue` should handle the third form: an object with `metadataRefused: true` and no `url` is a file this reader may not see the details of, not an empty field. +- f53f14b: `ListView.userActions.editInline` defaults to `true` on the v18 line: a list view is editable in place by default, under the permission gate that already exists, and `userActions: { editInline: false }` is the opt-out. + + Clause-②: no + + + + **BREAKING** — a declared default moves, shipped as `minor` under the launch-window convention. The author who relied on the documented default (`false`, "the list is read-only unless the author opts in") for a list that is read-only by nature — a log, an audit trail, a history, a report roll-up — now gets an inline-edit toggle on it, for every user who may `update` the object. The remedy is one key on that view: `userActions: { editInline: false }`. + + **The ruling.** Maintainer, 2026-10-10, verbatim: 「乙 v18 把 spec 默认翻成 true,editInline: false 变成关法。」 The rule of objectui#5144 stands — one vocabulary (`editInline`), a boolean view-level `inlineEdit` folds into it, an explicit `editInline` wins, declared = enforced; only the default's value changes. Not taken: declaring `editInline: true` on every existing view, and an app-level default between the spec default and the view. + + **What moves.** `UserActionsConfigSchema.editInline` is `z.boolean().default(true)` where it was `.default(false)`, with the `describe` rewritten: on by default; a user who may update the object edits a cell in place with the field's type-aware widget; declare `editInline: false` to make the list read-only in place. A list view's `userActions` block, and a page's `interfaceConfig.userActions` block (the same schema), that omits `editInline` now parses to `true` where it parsed to `false`; the published JSON Schema and the reference pages (`content/docs/references/ui/view.mdx`, `page.mdx`) say so. A view with no `userActions` block at all parses with none on either side; the renderer reads an absent key as this default. The accept set is unchanged: `editInline` is still a boolean, and every authored value parses exactly as before. The permission gate is untouched: the renderer offers the toggle only where the object is editable in place and the current principal may `update` it. + + **The consumer half** is objectui's (objectstack-ai/objectui#12086): `ListView.tsx` and `InterfaceListPage.tsx` read an absent key as `editInline !== false`. Until that lands, the console at the pinned objectui still reads an absent key as off, so nothing regresses against today in the window. + + ## FROM → TO + + | you wrote | what it means now | write instead, to keep the old behaviour | + |:--|:--|:--| + | nothing (no `editInline`) on a list view or a page list | editable in place by a user who may `update` the object | `userActions: { editInline: false }` | + | `editInline: false` | unchanged: read-only in place | nothing | + | `editInline: true` | unchanged: editable in place | nothing | + | a view-level `inlineEdit: true` | unchanged: folds to on and opens the grid in edit mode | nothing | + + **The one-line fix: on a list that must stay read-only in place, write `userActions: { editInline: false }`.** An `os compile` artifact that serialised an earlier parse carries a written `editInline: false` and stays read-only in place: recompile it, or delete the key. + + **Who is affected, measured** at `e11ef3a7bd`. No metadata in this repository declares `editInline` at all (0 `editInline: false`, 0 `editInline: true` across `examples/**` and `packages/**`, tests and changelogs excluded). Every list view that declares nothing gains the toggle: 38 list-kind views declared with an explicit `type` across 10 example `*.view.ts` files (entries with no `type` are grid by default and count the same way), 12 `interfaceConfig` page lists across 5 showcase page files (11 declare a `userActions` block, none naming `editInline`), and 105 list-kind views across 40 platform `*.object.ts` files in `packages/**`. One list view declares the view-level `inlineEdit: true` (the showcase `task` grid saved view) and keeps opening in edit mode. The card's readings outside this repository: cloud's `sys-package.page.ts` already declares `editInline: false` explicitly; HotCRM's 41 undeclared views regain cell editing after both packages upgrade, with no metadata change there. + + ### The kit + + - **The default and its describe.** `packages/spec/src/ui/view.zod.ts`, `UserActionsConfigSchema.editInline`; the schema docblock records the ruling beside the group/hideFields/rowColor asymmetry it already explains. + - **The declared default change.** `DEFAULT_CHANGES_BY_MAJOR[17]` (`packages/spec/scripts/lib/default-changes.ts`) carries `ui/UserActionsConfig:editInline` `false` → `true`, which is what lets `check:authorable-surface` authorise the moved fingerprint in `authorable-defaults/ui.json`; `CURRENT_MAJOR` is the package major (17) until the version pass takes the group to 18. + - **The ledger.** The D3 semantic entry `list-view-edit-inline-default-on` (protocol 18), one file under `src/migrations/entries/semantic/`, concatenated into `registry.ts` by `gen:migration-registry`, projected into `spec-changes.json` and `docs/protocol-upgrade-guide.md`. No key is removed, so there is no tombstone and no `RETIRED_KEYS_BY_MAJOR` row; no D2 conversion exists, because a mechanical pass writing `editInline: false` into every silent view would preserve the old posture and defeat the ruling, and one writing `true` would add nothing the default does not already do. The same class as `view-pagination-page-size-default-50` and protocol 12's `rest-requireauth-default-flip`. + - **The pins.** `packages/spec/src/ui/view.test.ts`: an absent key parses to `true` (on `UserActionsConfigSchema` and through `ListViewSchema`), an explicit `false` stays `false`, an explicit `true` stays `true`, and the view-level `inlineEdit: true` still parses as before with no `userActions` block materialised. + - **The skill.** `skills/objectstack-ui/rules/list-views.md` gains its paragraph in a separate pull request, because `skills/**` is Tier H. +- a800912: feat(spec): a list view and a dashboard take `requiredPermissions`, the audience gate, declared now and not enforced yet + + Clause-②: yes (widening) + + - **What is new.** `ListViewSchema` and `DashboardSchema` each accept `requiredPermissions`: a list of capability names (the names permission sets grant through `systemPermissions`) a user must ALL hold. It is the same key, with the same shape (`string[]`, optional, no default) and the same meaning, as `requiredPermissions` on an app, a navigation item and an action. Before this release both schemas refused the key as an unrecognized key. Because a list view's shape is shared, the key reaches every list-view door: a view container's `list` and `listViews` entries, an object's `listViews`, and a view item's `config`. + - **Not enforced yet.** No server reads either key in this release. A gated list view or dashboard is still served to every user who can read it, so do not rely on the key to keep a view or a dashboard from an audience yet. The key's description says so, and `os lint` / `os validate` warn (`liveness-planned-property`) when a dashboard or a view container's `list` sets it. Enforcement on the `/meta` reads of `view` and `dashboard` (the list read and the by-name read) is the separate enforcing change (#22639). + - **"Or" is a capability, not a new shape.** There is no any-of form: an object such as `{ anyOf: [...] }` is refused. To serve one view or dashboard to two audiences, declare one capability (for example `clm_legal_workbench.view`), grant it to both permission sets, and list that one name. `content/docs/permissions/capabilities.mdx` shows the pattern. + - **Nothing to migrate.** Every document that parsed before parses the same way; only a document that sets the new key, which was refused before, now parses. +- d973c34: A translated flow screen heading (`flows..screens..title`) is judged by the same text-slot rule as the body text and the refusal message beside it. A single-brace `{token}` in it is refused when the bundle is parsed, with the `{{ }}` spelling of the token. An empty string is still accepted. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The flow engine picks three translated templates in the run's locale and renders each through the one text-slot renderer, the `{{ }}` dialect: a screen's heading (`title`), its body text (`description`) and a refusing `end` node's `message`. The body text and the message refused a single-brace token. The heading accepted it and drew it as literal text, so `title: 'Welcome, {name}'` showed `Welcome, {name}`, and nothing was reported when the bundle was validated, saved or parsed. + + **What is refused.** A translated screen heading that the text-slot judge (`textSlotTemplateRefusal`) refuses: a single-brace `{token}`, and a `{{ }}` hole over a `$` name the flow engine does not bind. `TranslationDataSchema` and the `translation` metadata item refuse it at the key's path, with the words the source heading gets from `ScreenConfigSchema`. + + **Unchanged.** A heading that keeps the `{{ }}` holes of the text it translates (`'欢迎,{{ record.name }}'`), plain text, and the empty string `os i18n extract` writes into a skeleton, which the engine still reads as no translation. The heading's address does not change, and it still covers the node label a screen with no `config.title` shows. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Welcome, {name}'` | `title: 'Welcome, {{ name }}'` where the heading should show the value; `title: 'Welcome'` where the braces were never meant as a hole | + + **The one-line fix: write each single-brace token in a translated screen heading as the `{{ }}` hole the refusal names.** + + **Who is affected, measured.** In this repository at `86da194919`, four bundles carry a `screens` group (`examples/app-crm`, `examples/app-showcase`, and `examples/app-todo`'s `ja-JP` and `zh-CN`). They hold 8 translated screen headings, and the judge refuses none of them. The same instrument finds the single-brace `{count}` in `examples/app-crm`'s `crm.activity.due_today` message, which is its control. Deployed bundles and other repositories were not measured. + + **The ledger.** The D3 semantic entry `translation-flow-screen-title-text-slot-refused` (protocol 18). There is no D2 conversion: a single-brace token in a translated heading has always drawn as literal text, so only the translator can say whether it was meant as a hole. +- e04759d: fix(spec): the error-code ledger records `@objectstack/mcp` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22664) + + Clause-②: yes + + - **`ERROR_CODE_LEDGER` gains the `@objectstack/mcp` owner key.** The MCP stdio data bridge stamps both exposure codes onto the error it throws when an object's `enable` block refuses a data verb: `OBJECT_API_DISABLED` (404) for `apiEnabled: false`, and `OBJECT_API_METHOD_NOT_ALLOWED` (405) for an `apiMethods` whitelist that does not grant the verb. The published type of `ERROR_CODE_LEDGER` gains the key. `ErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, because both codes were already registered under `@objectstack/rest`. + - **`PROVENANCE_WAIVERS` gains one entry:** `@objectstack/types` → `UNIQUE_SCOPE_CONFIRMATION_REQUIRED`, registered under `@objectstack/cloud-connection`. `@objectstack/types` defines the shared constant `GLOBAL_UNIQUE_CONFIRMATION_REQUIRED`. The one stamp is the marketplace install seam's 409 in `@objectstack/cloud-connection`, whose owner key already lists the code. + + No code is added, renamed or removed, and no schema moves. +- b32ee3e: feat(spec)!: an approver `position` and a decision-output `position` are catalog names, listed from the metadata registry (ADR-0131 D4) + + Clause-②: yes + + + + **BREAKING** for a renderer that derives its position picker from `APPROVER_VALUE_SOURCES`, shipped as `minor` under the repo's launch-window convention for breaking changes. Authored and stored metadata is untouched. + + - **`APPROVER_VALUE_BINDINGS.position`** is a registry binding, `{ source: 'registry', type: 'position' }`. It was a record binding on `sys_position` rows that committed the row's `name`. `ApproverValueBinding` gains the `registry` member: the value is the machine name of an item of metadata `type`, and a catalog item has no other reference, so the member carries no `valueField`. + - **`APPROVER_VALUE_SOURCES.position`**, the same binding on the wire (the `xRef.sources` of the approval node schema's `approvers[].value`), reads `{ source: 'registry', type: 'position' }`. It read `{ source: 'data', object: 'sys_position', valueField: 'name' }`, and its type gains the `{ source: 'registry'; type: string }` member. A picker for a `registry` source lists `GET /api/v1/meta/position`, which serves the code-declared and the environment-authored positions alike, and commits the item's `name`. `user`, `team` and `department` still read `data`. + - **A `position` decision output collects position machine names**, the value the approver type of the same name takes. `DecisionOutputDef` said every picker collected record ids. The one reader that interprets a position output, an `expression` approver with `resolveAs: 'position'`, filters `sys_user_position.position` by name, so a `sys_position` id there routed the request to nobody. The `type` and `multiple` descriptions now say what each picker commits. + - **The runtime does not change.** The approvals engine routes a position approver, an escalation target and a `resolveAs: 'position'` value by name, as it did. + + Why: positions are declared metadata (ADR-0131 D2), and every reference to one names it and resolves through the registry (D4). The `sys_position` table is a mirror that the ADR-0131 cutover retires, so a picker that lists its rows lists the mirror, not the catalog. +- 490cb6d: A write answer now carries the advisory validation-rule hits of that write as `warnings` + + Clause-②: yes (widening) + + A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. + + - **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. + - **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. + - **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. + - **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. + - **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. + + **For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. +- e865300: A formula field can declare a currency result: `returnType: 'currency'`, with its currency in its own `currencyConfig` + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec`.** `FieldSchema.returnType` accepts `'currency'` beside `'number'`, `'text'`, `'boolean'` and `'date'`. A `currency` result is an amount of money. Its value is a bare number, as a currency field's is. Its currency is the formula field's own `currencyConfig`, the same `CurrencyConfigSchema` a currency field carries, with the same defaults and the same refusals: no `currencyConfig` means dynamic mode (the tenant default currency), and `{ currencyMode: 'fixed', defaultCurrency: 'USD' }` fixes it. Nothing is read at display time from the fields the expression references: the currency is a declaration on the formula. + - **Readers.** The filter doors judge a formula returning `currency` as the currency field it reads like: a text operator on it is refused (`INVALID_FILTER`), and the number door judges its comparands. A formula is still not title-eligible unless it returns `text`, and is still refused for `sum` / `avg` / `min` / `max` (it has no stored column). + - **Designer forms.** The field designer and the object designer's field grid both offer Currency as a return type. The field designer shows its Currency Config row for a formula whose return type is Currency, as it does for a currency field. + - **What is new in `@objectstack/formula`.** `inferFormulaReturn(expression, fields)` returns the declaration authoring stamps onto a formula field, judged over the host object's declared field map: `{ returnType: 'currency' }` when the expression is provably an amount of money, plus `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when its source amounts are in one fixed currency. "Provably money" is unit checking over the expression: amounts in one currency added, subtracted, scaled by a number or a percent, divided by a number, rounded, or picked by `min` / `max` / `coalesce` / a ternary. Two different currencies, an amount multiplied by an amount, or an amount plus a plain quantity are not money. For everything it cannot prove to be money it answers exactly what `inferExpressionType` answers, so a numeric formula stays `number`. Its types `FormulaReturnDeclaration` and `FormulaSourceField` are exported with it. `inferExpressionType` is unchanged. + - **`@objectstack/platform-objects`.** The designer help text for the return type and the currency config rows names the currency result, in all four locales. + - **Nothing to migrate.** Every value accepted before is accepted now. To declare a formula over money as money, declare `returnType: 'currency'` and copy the source currency field's `currencyConfig` onto the formula, or run `inferFormulaReturn` and write what it returns. +- 85edd61: feat(spec): the stack definition admits `appName`, the non-secret members of `email`, and `sms.retries`; credentials and the SMS provider are refused by name + + Clause-②: yes (widening: the stack definition admits `appName`, the non-secret members of `email`, and `sms` carrying `retries` alone, which it refused as unrecognized keys) + + - **What you can write now.** `defineStack()` accepts three top-level keys it used to refuse as unrecognized. The mail and SMS readers already read them, and `objectstack serve` and `bootStack` construct the email and SMS providers through those readers: + - `appName`: the deployment's product name. It is the last configured rung of the name chain (`OS_APP_NAME` > `email.appName` > `email.defaultTemplateContext.appName` > `appName` > `'ObjectStack'`), and it reaches the email templates and AuthPlugin. + - `email` (`StackEmailConfigSchema`): every member of `EmailServiceConfigSchema` except `apiKey`. That is `provider`, `defaultFrom`, `retries`, `persist`, `queueDelivery`, `appName` and `defaultTemplateContext`, plus `options` narrowed to `host` / `port` / `secure` / `user` / `messageStream`. `OS_EMAIL_*` still overrides it per key. + - `sms` (`StackSmsConfigSchema`): `retries`, the SMS service's attempt budget. It applies to whichever transport the `sms` settings namespace selects. + - **What stays elsewhere, and is refused by name.** Each refusal names the key and the setting that replaces it: + - `email.apiKey` and `email.options.apiKey`: a credential, and a stack definition is compiled into the published artifact. Set `OS_EMAIL_API_KEY`. + - `email.options.password`: a credential. Set `OS_EMAIL_SMTP_PASSWORD`; the reader layers it over the host / port / secure / user the file carries. + - `sms.provider`, `sms.providerOptions` (and a flat `authToken` / `accessKeySecret`): the delivering SMS transport is built by the `sms` settings namespace from its own provider and credentials, never from a stack definition. Configure them in Setup → Settings → SMS Delivery, or through that namespace's overrides (`OS_SMS_PROVIDER`, `OS_SMS_TWILIO_*`, `OS_SMS_ALIYUN_*`). + - **Composition.** All three keys compose as single values. `composeStacks` passes an identical value through and refuses two different ones by key. + - **Nothing to migrate.** No stack that was accepted before is refused now. A configuration that set these keys only in an object `defineStack` did not build (the one `objectstack serve` boots) reads the same as before. To build it, move any credential, and the SMS provider, into the environment or Settings. + - New exports from `@objectstack/spec/system`: `StackEmailConfigSchema`, `StackEmailProviderOptionsSchema`, `StackSmsConfigSchema`, and their `z.input` / `z.infer` types. +- e84aeb3: feat(spec): a new webhooks service contract declares an optional, transport-neutral redeliver member (`IWebhookService.handleRedeliver`) + + Clause-②: yes (widening) + + - **What is new.** `@objectstack/spec/contracts` exports a new contract, `IWebhookService`, for the `webhooks` service slot that `@objectstack/plugin-webhooks` fills. It has one optional member, `handleRedeliver?(request: Request): Promise`. It serves the webhook redeliver door, `POST /api/v1/webhooks/redeliver`, the operator button that sends a finished delivery again. + - **What it serves.** The same answers as the plugin's self-hosted route. It authenticates the caller from the request's own credentials (`401 UNAUTHENTICATED` when nobody is signed in). It reads `{ "deliveryId": string }` from a JSON body (`400` otherwise). It replays the row through the messaging service, scoped to the caller's active organization (`404 RESOURCE_NOT_FOUND` for a row outside it). It answers `409` with `DELIVERY_NOT_ELIGIBLE` or `DELIVERY_NEVER_SENT` when the outbox refuses the replay, and `200` with the row's id and new status when it succeeds. + - **Transport-neutral.** A web-standard `Request` goes in and a `Response` comes out: there is no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount the route on, and the runtime dispatcher's `POST /webhooks/redeliver` domain is the member's one caller. + - **Why a webhooks slot, not the messaging service's.** The messaging service owns the replay itself. The door (who may press it, whose rows it reaches, what each refusal answers, and the veto over replaying a webhook whose subscription or secret is gone) belongs to the webhook plugin, so the door exists exactly where that plugin is composed. + - **Absence.** The member is optional, so a webhook service without the door still satisfies the contract. Its caller answers a typed 404 or 501 when the `webhooks` service or this member is absent, never `ROUTE_NOT_FOUND`. + - **Nothing changes for existing code.** No existing contract or member moved. Nothing registers the `webhooks` slot or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted route stays as it is. +- 5fc57b3: feat(spec): a new trigger-api service contract declares an optional, transport-neutral inbound-hook member (`ITriggerApiService.handleInboundHook`) + + Clause-②: yes (widening) + + - **What is new.** `@objectstack/spec/contracts` exports a new contract, `ITriggerApiService`, for the `trigger-api` service slot that `@objectstack/trigger-api` fills. It has one optional member, `handleInboundHook?(request: Request, hook: { flowName, hookId }): Promise`. It serves the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`, the door a third-party sender posts a signed event through to start an `api` flow. + - **What it serves.** The same answers as the trigger's self-hosted route, in the same order. An unknown flow or a wrong hook id answers `404 RESOURCE_NOT_FOUND`, identically. A hook secret that cannot be read answers `503 SERVICE_UNAVAILABLE`. A missing or wrong `x-objectstack-signature` answers `401 INVALID_SIGNATURE`. Only a verified body is read as JSON: one that is not valid JSON, or not a JSON object, answers `400 INVALID_REQUEST`. An enqueued post answers `202` with `{ accepted: true, messageId }`, and the flow runs on the queue consumer, never in-band (ADR-0041 §5). No queue answers `503 SERVICE_UNAVAILABLE`, and a failed publish answers `503 ENQUEUE_FAILED`. + - **The member is the signature boundary.** The implementer verifies inside the member, with the hook's own secret, in constant time, and before anything is enqueued. It reads the raw body from the request itself. The caller forwards the request with its body unread, and never parses, re-serialises or pre-checks it. + - **Transport-neutral.** A web-standard `Request` goes in and a `Response` comes out: there is no Hono context, no raw app and no `http.server` type, and no execution context, because the signature is the only credential. The two path parameters arrive as the second argument, as the caller's route matched them, so the member never re-parses the URL. A hosted tenant kernel has no raw app to mount the route on, and the runtime dispatcher's exact `POST /automation/hooks/:flowName/:hookId` domain is the member's one caller. + - **Why a trigger-api slot, not the automation service's.** The automation service holds the trigger registry that the `api` trigger registers into. The door (the hook lookup, the secret, the signature check, the body parse, the enqueue and every answer) belongs to the trigger, so the door exists exactly where `@objectstack/trigger-api` is composed. + - **Absence.** The member is optional, so a trigger-api service without the door still satisfies the contract. Its caller answers a typed 404 or 501 when the `trigger-api` service or this member is absent, never `ROUTE_NOT_FOUND`. + - **Nothing changes for existing code.** No existing contract or member moved. Nothing registers the `trigger-api` slot or calls the member yet: the trigger's implementation and the dispatcher domain land separately, and the trigger's self-hosted route stays as it is. +- 9814035: feat(spec): `ReadableIds`, the branded `$in` comparand leaf meaning "the ids of object X this caller may read" (ADR-0139 D1) + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec/data`.** `ReadableIds.of(object)` builds a frozen leaf, `{ object }`, placed as the comparand of `$in` on a pointer field: `{ record_id: { $in: ReadableIds.of('cpz_doc') } }`. `isReadableIds(value)` answers whether a value is one. `READABLE_IDS_BRAND` is the `Symbol.for('objectstack.filter.readableIds')` key the brand lives under. The `ReadableIds` type carries the brand, so an object literal does not type-check as one. `ReadableIds.of()` throws a `TypeError` for an object name that is not a non-empty string. + - **No request can spell it.** No filter operator is added. The brand is a non-enumerable symbol-keyed property, so JSON, a structured clone or a spread produces an unbranded `{ object }`, and a plain object as the comparand of `$in` is refused `INVALID_FILTER` / 400, as it was before. + - **The comparand-shape door admits it only for a caller that composes it.** `assertListComparandShapes` takes an optional fourth parameter, `ListComparandShapeOptions`. With `{ admitReadableIds: true }` a branded leaf is admitted as the comparand of `$in`, and nowhere else. Without it, which is every existing caller (`parseFilterAST`, the save door, the analytics doors, the RLS compiler), the leaf is refused as a non-list comparand, unchanged. + - **`@objectstack/objectql` does not compose the leaf yet.** The engine's comparand door passes the option, then refuses a branded leaf found anywhere in a `where`, an aggregation's `filter` or a `having` with `INTERNAL_ERROR` / 500, before any driver is asked. Do not emit the leaf until the engine composes it. Until then, read the parent object as the caller and match the ids that read returns with a list. + - Nothing emits the leaf yet, and no read that ran before this change answers differently. +- 55382dc: A paged list under a search now reports the real number of matching records as `total`, and `hasMore` agrees with it on every page. + + Clause-②: yes (widening) — `EngineCountOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the count options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. + + Until now `GET /api/v1/data/:object?$top=25&$search=Line` over 75 matching records answered `total: 26` (offset + page + 1, a page-local estimate), and the last page claimed `hasMore: true`; `POST /api/v1/data/:object/query` with a `search` did the same. Nothing on the response marked the number as an estimate, so the Console rendered it as the record count and offered the wrong number of pages. + + - **`@objectstack/spec`** — `EngineCountOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. FROM `count(object, { where })` only → TO `count(object, { where, search, searchFields })`. The structured `search` form carries flag defaults, so the parsed state is named `EngineCountOptionsParsed` (ADR-0122). `HookCountQuery` (`ctx.api.object(name).count()`) derives from it and gains the same two keys. + - **`@objectstack/objectql`** — `engine.count()` (and `ctx.api.object(name).count()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` and `aggregate()` use: the same server-resolved searchable fields, the same `internal: true` exclusion, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two, so a count under a search is the number of rows `find()` returns for the same bag. + - **`@objectstack/metadata-protocol`** — `findData`'s paged branch passes `search` / `searchFields` to `engine.count()`, and the page-local estimate branch is gone. `$count=false` still skips the count and omits `total`. + + Nothing to migrate. A client that worked around the estimate, for example by labelling the count as page-local under a search, can read `total` as the match count again. +- efcbac7: fix(trigger-api,spec): every inbound hook post that does not verify gets one answer, `401 INVALID_SIGNATURE`, whatever its flow and hook id + + Clause-②: yes + + - **FROM → TO.** On `POST /api/v1/automation/hooks/:flowName/:hookId`, a post that named no armed flow, or the wrong hook id, answered `404 RESOURCE_NOT_FOUND`, while a post naming an armed flow and its hook id with an absent or bad `x-objectstack-signature` answered `401 INVALID_SIGNATURE`. Now all four answer `401 INVALID_SIGNATURE` with the same body, so the answer no longer tells an armed flow name from an unarmed one, whichever hook id a flow is armed with. + - **Who sees a difference.** A correctly configured sender (the flow's URL, its hook id, its secret) sees none: it still gets `202`. A sender posting to a flow that is not armed, or with a hook id that was rotated away, now gets `401` where it got `404`. A sender with a wrong secret or signing code still gets `401`, as before. + - **The cause is in the server log.** The trigger logs one `warn` line per refused post, with the cause and the flow name the post carried as structured fields: `cause` is one of `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` or `signature-outside-window`. The line never carries the secret, the signature value or the body. + - **Unchanged: the `503` residual.** A post that names an armed flow and its hook id while that hook's secret cannot be read still answers `503 SERVICE_UNAVAILABLE`, signed or not, without verifying. It stays a `503` on purpose: it is the deployment's fault, a sender retries it, and it lasts only while the secret cannot be read. A wrong hook id never reaches the secret read, so it gets the uniform `401` even then. + - **`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock states the one refusal and names the `503` as its residual. The type is unchanged. + + **To adopt it.** A sender or monitor that read `404` from this route as "wrong URL" can no longer tell that from a bad signature by the answer: read the trigger's `warn` line for the post instead, whose `cause` names which one it was. +- 9432bac: feat(spec): `ServiceSlotContracts` types the `webhooks` and `trigger-api` slots + + Clause-②: yes (widening) + + `ServiceSlotContracts` (from `@objectstack/spec/contracts`) gains two entries: + + - `webhooks: IWebhookService`. `@objectstack/plugin-webhooks` registers this slot with a service typed as `IWebhookService`. + - `'trigger-api': ITriggerApiService`. `@objectstack/trigger-api` registers this slot with a service typed as `ITriggerApiService`. + + FROM → TO: + + - `ServiceSlotContract<'webhooks'>` goes from `unknown` to `IWebhookService`, and `ServiceSlotContract<'trigger-api'>` goes from `unknown` to `ITriggerApiService`. + - A lookup keyed by `keyof ServiceSlotContracts` now accepts both names. One example is the runtime dispatcher's `resolveService` / `getRequestKernelService`, which used to answer `any` for them. It now answers the contract or `undefined`. So `resolveService(ctx, 'webhooks')` resolves to `IWebhookService | undefined`, and `?.handleRedeliver` has the declared member's type. The same holds for `'trigger-api'` and `?.handleInboundHook`. + + Every member of both contracts is optional, so a reader still checks that the member it calls is present. No other slot's entry changes, and nothing changes at runtime: this is type information only. + + Nobody needs to migrate. Existing casts at those lookups still compile. If you build an object typed as the whole `ServiceSlotContracts` map, it must now carry the two new keys. This repository and the pinned objectui checkout build no such object. +- e5687cb: An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` + + Clause-②: yes (widening) + + - **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. + - **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. + - **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. + - **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. + - **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. + - **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- ec7f7f8: feat(spec)!: retire an email template's `category` and `isSystem` — the grouping and the built-in flag nothing read (#22876) + + **BREAKING** — `category` and `isSystem` on an email template (`EmailTemplateDefinitionSchema`, the `email_template` metadata type) are now refused at parse. Nothing read either key once templates stopped being copied into `sys_email_template` rows: the Studio listing never offered the filter facet `category` promised, and no door refused deleting a template that set `isSystem`. Delete both keys. A template is found by its `name`, so group templates with a dotted namespace prefix (`auth.password_reset`, `crm.welcome`). A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. + + Clause-②: no (narrowing) + + Measured before removal: no reader of either key in `@objectstack/plugin-email`, `@objectstack/service-messaging`, `@objectstack/plugin-auth`, the metadata protocol, ObjectQL, the runtime or the REST layer, and none in the Studio console at its pinned commit beyond a fallback form input, which the console's own companion change removes. Both keys carried a schema default (`category: 'custom'`, `isSystem: false`), so every parsed template carried them and the liveness advisory fired on every template, with no way for an author to clear it. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `category: 'auth'` (or `'notification'`, `'workflow'`, `'marketing'`, `'custom'`) | nothing — delete the key; group by the `name` prefix | + | `isSystem: true` | nothing — delete the key | + | `isSystem: false` | nothing — delete the key | + | `import { EmailTemplateDefinitionCategorySchema } from '@objectstack/spec/system'` | nothing — the enum is removed with its key | + | `type EmailTemplateDefinitionCategory` | nothing — the type is removed with its key | + + **The one-line fix:** delete `category` and `isSystem` from every email template. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: + + > `email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). 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. + + > `email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. 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. + + ## The retirement kit + + - **Two `retiredKey()` tombstones** on `EmailTemplateDefinitionSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `system/EmailTemplateDefinition:category`, `system/EmailTemplateDefinition:isSystem`. The `category` enum, `EmailTemplateDefinitionCategorySchema`, had no other consumer and leaves with it: `RETIRED_DEFS_BY_MAJOR[18]`: `system/EmailTemplateDefinitionCategory`. + - **The D2 conversion `email-template-category-is-system-removed`** (protocol 18, retired from the load path) deletes both keys from every `emailTemplates[]` entry, one notice per key, as a lossless delete. Because both keys carried a default, every built artifact and every stored `email_template` row written under 17.x carries them; both load through the rehydration seams, which replay this conversion. The retired-default residue stage is not adopted: the replayed conversion heals those populations, and an authored source that still writes either key meets the prescription. + - **The D3 entry `email-template-category-is-system-retired`** asks the author whether anything they built read either key — a report or integration that filtered templates by `category`, or one that treated `isSystem: true` as protection against deletion. Nothing ever enforced that. + - **Studio:** the email-template form drops the `category` select and the `isSystem` checkbox. + - **Ledger:** both rows stay `dead`, re-noted as tombstones (a tombstone keeps the key in the walked shape). + - **No deprecation window**, per the project's startup-stage posture. + + **`@objectstack/plugin-email`:** the six built-in auth templates (in every locale) no longer write `category: 'auth'` or `isSystem: true`. They render and send exactly as before. + + **`@objectstack/platform-objects`:** the metadata-form translation bundles drop the two retired form inputs' labels (`email_template.fields.category`, `email_template.fields.isSystem`). The `sys_email_template` object and its `category` / `is_system` columns are unchanged. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 038d4c9: feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) + + Clause-②: yes + + **What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: + + - `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). + - `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. + - `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. + - `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. + - `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. + + With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. + + **The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). + + **Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: + + - `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. + - `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. + - `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. + - `POST /meta/:type/:name/publish`: `package`, `member`. + - `POST /meta/:type/:name/rollback`: `toVersion`, `member`. + + A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. + + **What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): + + - A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. + - Resetting one language keeps the other languages' customizations served at once, not only after the next restart. + - Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. + - In a cluster, a save, reset or revert of one language converges the other servers on that language. + + **For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. + + - `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. + - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. + - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. + - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. + - A type whose items have no languages ignores the argument. + - `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. + - The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. +- 147a0d1: feat(spec)!: `defineStack` refuses two entries of one stack's `functions` array that author the same `name`, naming the name and every index (#22902) + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + Functions are one flat namespace resolved by name: a hook's `handler`, an action's `target` and a flow `script` node's `config.function` all look a function up by its name. The `functions` **array** form could author one name twice, and nothing refused it. Measured on `main` at this change's base with `functions: [{ name: 'kc_a', handler: first }, { name: 'kc_a', handler: second }]`: + + - `objectstack validate` exited 0 (`valid: true`); + - `objectstack build` exited 0 and shipped `[{ name: 'kc_a' }, { name: 'kc_a__2' }]`: the second entry went out under a suffixed internal ref the author never wrote and nothing references, and `kc_a` ran `first`; + - a source boot (`objectstack serve`, no build) registered `kc_a` as `second` and dropped `first`, with no notice. + + So the same stack ran different code under `kc_a` depending on whether it was built. `composeStacks` already refused one name defined by two stacks (`STACK_COMPOSE_FUNCTION_CONFLICT`); this is the one-stack case. + + **What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per colliding name, naming the name and every index: + + ```text + ✗ Function 'kc_a' is declared twice in the `functions` array: stack.functions[0] and stack.functions[1]. … + ``` + + It runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load, printing that line: `objectstack validate --json` answers `valid: false` with `code: 'STACK_CROSS_REFERENCE_INVALID'` (exit 1), `objectstack build` exits 2, and `objectstack serve` exits 1 before the kernel boots. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. + + **Unchanged.** The map form, which cannot author one name twice; an array whose names are distinct; the suffixed refs a build emits for an internal slot (`kc_a__2` beside `kc_a` is two names); and the cross-stack refusal in `composeStacks`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `functions: [{ name: 'sync', handler: a }, { name: 'sync', handler: b }]` | `functions: [{ name: 'sync', handler: a }, { name: 'sync_backup', handler: b }]`, and point each hook, action or script node that meant `b` at `sync_backup` | + | two entries that are the same function under one name | one entry: delete the other | + + **The one-line fix: give each `functions` array entry its own `name`, or merge the duplicates into one entry.** + + No D2 conversion rewrites it: which entry keeps the name, and what every caller of the other one should name instead, is the author's call. Its D3 record is the semantic entry `stack-functions-array-duplicate-name-refused` (protocol 18). + + **Who is affected, measured.** On `main` at this change's base, no example app, fixture or test authors one name twice in a `functions` array: the four example apps author the map form or none, and every array-form fixture under `packages/` names distinct entries. `objectstack validate --json` on `examples/app-crm`, `app-todo`, `app-showcase` and `app-multi-package` answers byte-for-byte as it did without the check, apart from the `duration` value. Deployed metadata and other repositories were not measured. +- 1959aac: feat(spec)!: `defineStack` refuses two entries of one named collection under one identity, naming the collection, the identity and every index — for 27 collections (#22934) + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + The runtime registers the entries of these collections under one identity each, and a stack could author two entries under one identity with nothing refusing it: `defineStack` and `objectstack validate` accepted the pair (exit 0, `valid: true`, no finding naming it). Measured on a source boot (`objectstack serve`, no build) and on `objectstack build` followed by an artifact-backed boot: + + - `objects`, `pages`, `dashboards`, `reports`, `datasets`, `flows`, `jobs`, `emailTemplates`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `webhooks`, `mappings`, `analyticsCubes`, `connectors`, `picklists`, `tools`, `skills`, `views`, `apis`, `apps` and `objectExtensions` kept only the LAST entry on both paths, with no notice at the default log level (for `apis`, the first entry's path answered 404); + - `datasources`: the metadata described the last entry while the driver connected with the first one's configuration; + - `hooks`: the metadata listed one hook while both handlers were bound and ran; + - `docs`: `objectstack validate` and `objectstack build` refused the pair (`docs/duplicate-name`), while a source boot kept the last. + + **What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per collision, naming the collection, the identity and every index: + + ```text + ✗ Datasource 'crm_external' is declared twice in `datasources`: stack.datasources[0] and stack.datasources[1]. … + ``` + + The identity is what the runtime keys an entry by: its `name`; the `(name, locale)` pair for `emailTemplates` (one name across several locales is an i18n bundle, and an entry with no `locale` counts as `en-US`); the extended object (`extend`) for `objectExtensions`; and for `views` the object a container binds to (`object`, else `list.data.object`, else `form.data.object`, else its `name`). The check runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. On an `app` package, two apps under one name now get this refusal, which runs before the single-app count. + + **Unchanged.** Distinct identities; an email-template i18n bundle (one name, one entry per locale); the map form under distinct keys (two keys whose entries spell one explicit `name` are one identity, and are refused); the same name reused by another package of a multi-package project (`composeStacks(…, { manifest: 'preserve' })` keeps each package's collections in its own `packages[i]` body, which this check does not read); `plugins` (last-one-wins with a warning is the kernel's declared contract), `agents` (withdrawn for app packages) and `picklistExtensions` (merged by option value); and the existing `actions` and `functions` refusals. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `pages: [{ name: 'home', … }, { name: 'home', … }]` (any of the 24 name-keyed collections) | one entry per name: rename one (`home_admin`) and every reference that meant it, or merge the two into one entry | + | `emailTemplates: [{ name: 'acme.welcome', … }, { name: 'acme.welcome', locale: 'en-US', … }]` | one entry per name and locale: give the second its own `locale` (`zh-CN`), or merge the two | + | `datasources: [{ name: 'ext', config: a }, { name: 'ext', config: b }]` | one datasource per name — the driver used to connect `a` while the metadata described `b` | + | `hooks: [{ name: 'audit', object: 'a', … }, { name: 'audit', object: 'b', … }]` | one hook with `object: ['a', 'b']`, or two hooks with two names | + | `apis: [{ name: 'feed', path: '/api/v1/apps/x/a' }, { name: 'feed', path: '/api/v1/apps/x/b' }]` | give each endpoint its own name | + | `views: [defineView({ object: 'task', list }), defineView({ object: 'task', form })]` | one container per object: `defineView({ object: 'task', list, form })` (named views go in `listViews` / `formViews`) | + | `objectExtensions: [{ extend: 'task', fields: { a } }, { extend: 'task', fields: { b } }]` | one extension per target: `{ extend: 'task', fields: { a, b } }` | + + **The one-line fix: give each entry of a collection its own identity, or merge the duplicates into one entry.** + + No D2 conversion rewrites it: which entry keeps the identity, and what the other one becomes, is the author's call. Its D3 record is the semantic entry `stack-collection-duplicate-entry-refused` (protocol 18). + + **`@objectstack/metadata`.** `deriveViewContainerObject` — the "which object does a `views` container bind to" derivation the registrars file containers by — moved into `@objectstack/spec` (exported from its root and `./ui`), because the stack contract now judges the same slot. `@objectstack/metadata` re-exports that one declaration under the same name, from its root and from `@objectstack/metadata/view-container`; behaviour is unchanged. That subpath used to import nothing and now loads the `@objectstack/spec` root entry, which every in-repo importer of it already loads. + + **Who is affected, measured.** The four example apps (`examples/app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) carry no duplicate under these identities. Deployed metadata and other repositories were not measured. +- 55828c5: feat(spec): an empty state names its one next step — `emptyState.link: { label, url }` — and a list page carries its own `interfaceConfig.emptyState` + + Clause-②: yes (widening: two new accepted keys) + + - **`EmptyStateSchema.link`.** An empty state takes one optional link beside `title`, `message` and `icon`: `{ label, url }`, both required and nothing else, for example `link: { label: 'How to publish a package', url: '/docs/publish-a-package' }`. It opens a page or an address and runs no action, flow or script. The shape is closed: a missing `label` or `url`, any other key and a non-object are refused at parse. + - **The targets a `type: 'url'` action opens, and no others.** `link.url` admits an in-app path that starts with `/` or `./`, an address that opens with `http://`, `https://` or `mailto:`, and a template that opens with `${…}` (such as `${ctx.origin}/docs`), which is judged when it is clicked. Every other scheme (`javascript:`, `data:`, `tel:`, …), a protocol-relative `//host`, `/` followed by a backslash, a tab, line feed or carriage return, and a bare relative target such as `docs/x` are refused at `link.url` with the reason and the fix, in the same sentences a url action's `target` is refused with. An empty `url` is refused as empty. + - **Every door that carries the empty state takes the link:** a list view's `emptyState` (and so an object's `listViews` and a view document's), the `object-grid` page block's `emptyState`, and a list page's new `interfaceConfig.emptyState`. + - **`interfaceConfig.emptyState`.** A `type: 'list'` page takes its own empty state, the list view's shape itself: `{ title, message, icon, link }`, refused at parse exactly as the list view's is. + - **Guidance.** `action` or `button` written on an empty state is now answered with the link, and still with the list view's `addRecord` block for an add-record entry point; a `url` or `href` written flat on the empty state is pointed into `link`. + - **Not rendered yet.** No console renderer draws the link or a list page's own empty state today. Both parse and are not drawn, and their schema descriptions say so (`[PLANNED — not yet rendered]`). Until the console reads it, a list page still draws its source view's empty state, if any. + - **Published JSON Schema.** `link.url` publishes as a string with `minLength: 1`. The target rule is a parse-time refinement the published file does not express; the file names its position under `x-dropped-refinements`. +- 7856110: The RLS deny sentinel and the composed `ReadableIds` leaf are declared once in `@objectstack/spec/data`, and every place in `@objectstack/plugin-security` that handed out a copy of the sentinel now returns the object itself (ADR-0139 D2) + + Clause-②: yes (widening: new exported members of the published spec `data` entry) + + - **`RLS_DENY_FILTER` moves into the spec.** FROM `@objectstack/plugin-security`'s own frozen literal TO `RLS_DENY_FILTER` exported by `@objectstack/spec/data`. The value is unchanged: `{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`, an `id` equality no record satisfies, so a consumer that never heard of it still reads zero rows. `@objectstack/plugin-security` keeps exporting `RLS_DENY_FILTER` under the same name and type; it is now the spec's object, not a second copy. Nothing to change in code that imports it from either package. + - **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself, not a copy, for a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set and a resolution failure. It also returns the sentinel itself when the only layer that constrains is the tenant wall's deny, the RLS compiler's deny (every applicable policy dropped), or the deny of a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. Not every zero-row answer is the sentinel: a deny ANDed with another layer is returned as the composed `$and`, with the sentinel inside it, and the record-sharing layer's own deny, `{ id: '__deny_all__' }`, is returned unchanged when sharing is the only layer that constrains. ADR-0139 D2 step 2 has the engine's `ReadableIds` composition recognise the deny by `scope === RLS_DENY_FILTER` and answer it `$in: []`; any other zero-row answer composes like any scope and still matches no row. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. + - **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is neither `undefined` nor a non-null, non-array object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. + - **`isReadableIds` answers as before.** It answers `false` for a composed leaf, so it still means "an uncomposed leaf", and a driver refuses on it. The comparand-shape door's `admitReadableIds` admits the uncomposed leaf only: a composed leaf handed to the engine from outside is refused `INVALID_FILTER` / 400 as a non-list comparand. +- 9b37edb: feat(spec)!: a `type: 'url'` action's `target` declares its closed scheme set — `http://`, `https://`, `mailto:` and in-app paths — and refuses every other target at parse + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + `target` was a free string on every action type, so a url action whose target the console's action runner refuses — `javascript:`, `data:`, `tel:`, a protocol-relative `//host` — parsed clean, published, and failed only at the click. The scheme set is now stated once in the contract and the runtime opens exactly that set. On both url-action doors, `ActionSchema` (a registered action) and `InlineActionSchema` (an inline action, such as `element:button`'s `action`, including the legacy `type: 'navigation'` + `to` spelling), a `type: 'url'` target must be one of: + + - an `http://` or `https://` URL (the scheme compared case-insensitively); + - a `mailto:` address; + - an in-app path that starts with `/` or `./`; + - a template that opens with `${` (`${ctx.origin}/x`, `${param.url}`), judged by the runner after interpolation, at the click. + + Every other target is refused at `target`, with a message that names the reason and the admitted set. `target` on every other action type (`script`, `flow`, `modal`, `api`, `form`) names a handler, flow, page, endpoint or form and is untouched, and so are interpolation and `openIn`. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `target: 'javascript:…'` or `target: 'data:…'` | no url target runs or renders content. Move the work to a `type: 'script'` action (an inline `body`), or link to an `https://` page. | + | `target: 'tel:+1…'`, `target: 'ftp://…'` or any other scheme | an `https://` page that offers it, or a `mailto:` address. `tel:` is not admitted. | + | `target: 'https:example.com'` (no `//`) | `target: 'https://example.com'` | + | `target: '//cdn.example.com/x'` (protocol-relative) | `target: 'https://cdn.example.com/x'` for an external link, or `target: '/x'` for an in-app path | + | `target: '/\example.com'` (`/` then a backslash) | `target: 'https://example.com'`, or an in-app path written with forward slashes | + | a target holding a tab, line feed or carriage return | the same target without them; percent-encode one that belongs in the address (`%09`, `%0A`, `%0D`) | + | `target: 'thanks'`, `'../thanks'`, `'?tab=2'`, `'#top'` | the rooted in-app path, `target: '/thanks'`, or a `./` path | + + **The one-line fix: give every `type: 'url'` action an `http(s)://` URL, a `mailto:` address, or an in-app path that starts with `/` or `./`.** `os migrate meta --from 17` lists the D3 entry `ui-action-url-target-scheme-set-closed`; the rewrite is by hand, because which address a refused target meant is the author's call. + + **What an author now sees.** `os validate`, `os build`, `defineAction` and `defineStack` refuse such a target at `target`. For example, `javascript:alert(1)` gets: ``A `type: 'url'` action's `target` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted target parses unchanged. + + **Measured producers: none outside tests.** Every url-action target written outside tests is admitted: 9 sites in this repository at `df7a137923` (the showcase's `https://objectstack.ai` among them), and 8 in objectui at both its pin `9646e2ed2bf3` and its main `7578323`. One test fixture in `@objectstack/spec` gave the url arm of an all-types loop a bare `test_handler`; it now writes `/test_handler`. +- a22e67b: A `record:details` section heading can show how many values a multi-value field holds on the current record: `sections[].count: { field }`, with the section's `label` writing the `{{ count }}` hole where the number goes. + + Clause-②: yes (widening) + + ```ts + { + name: 'plan_gap', + label: '{{ count }} features need a higher plan', + fields: ['capabilities_not_on_plan'], + count: { field: 'capabilities_not_on_plan' }, + } + ``` + + - **`@objectstack/spec`.** `RecordDetailsProps.sections[]` accepts `count`, a closed object whose one key, `field`, is a bare machine name (snake_case, no dotted path). A section that declares `count` must write the `{{ count }}` hole in its `label`, in every locale of an inline label map. It is refused when it has no `label`, when the label has no hole, when the label writes any other `{{ … }}` hole (another variable, or the count with a formatter), when it writes the single-brace `{count}` / `${count}`, and beside `group` (a group-referencing section's heading is the group's own label). A translated `objects.._sections..label` writes the same `{{ count }}` hole, and `os i18n extract` seeds it from the authored label. `SECTION_HEADING_COUNT_HOLE` exports the hole's grammar (inner whitespace is free: `{{count}}` is the same hole). + - **`@objectstack/lint`.** The page field-binding pass resolves `count.field` against the bound object, at error severity, on `os validate`, `os lint` and `os build`: a field the object does not declare is `page-field-unknown`, and a declared field that holds one value is the new `page-section-count-not-multi-value`. A multi-value field is `multiselect`, `checkboxes` or `tags`, or a `select`, `lookup`, `user`, `file` or `image` field with `multiple: true`. A section's `fields[]` miss stays a warning. + - **Not rendered yet.** The key is declared ahead of its renderer and its description carries the `[EXPERIMENTAL — not enforced]` marker: until the record detail renderer reads it, the hole renders as literal text. + - **Nothing to migrate.** No stack that parsed before is refused: every new refusal needs the new key. A `{{ count }}` hole in a section that declares no `count` is still accepted. +- 62ce45d: feat(spec): a page element's `dataSource` can name an API endpoint — `{ provider: 'api', read: { url }, path? }` + + Clause-②: yes (widening: a new accepted shape) + + - **What is new.** `PageComponentSchema.dataSource` takes a second shape beside the object binding: the API binding, exported as `ElementApiDataSourceSchema` (type `ElementApiDataSource`). It reads one endpoint on the deployment's own origin and binds its answer, so a product page composes from a summary that is not an object — `{ provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' }`. `path` is an optional dotted path into the `{ success, data }` response; omitted, the element binds the whole response. + - **Same-origin only, refused at parse.** `read.url` must open with a single `/`. Five classes are refused with the reason and the fix, at `dataSource.read.url`: an absolute URL of any scheme (`https:`, `http:`, `mailto:`, `javascript:`, `data:`, …), a protocol-relative `//host`, a backslash the browser reads as a slash (`/` then a backslash, or a leading backslash), a space or ASCII control character (a tab, line feed or carriage return anywhere is deleted by the URL parser, so `/`, a tab, `/host` is `//host`), and a relative path such as `./home` or `api/v1/x`, which resolves against the page, never the API. The rule is a `pattern` in the published JSON Schema too, so a validator reading `json-schema/` refuses what the parse refuses. + - **No credentials in metadata.** The console reads the endpoint with its own signed-in fetch. The read block declares `url` and `method` only; `headers`, `credentials`, `auth`, `authorization`, `token` and `apiKey` are refused with that prescription, and so are `params` and `body`. + - **Reads only.** `read.method` is optional and accepts `GET` alone, the method the console's API adapter sends for every read. There is no `write` block. + - **The object binding is unchanged.** `ElementDataSourceSchema` accepts exactly what it did and gains no `provider` key; the two shapes meet as a `z.union` at `PageComponentSchema.dataSource`. One visible difference: a refused object binding parsed through `PageComponentSchema` now arrives as one `invalid_union` issue at `dataSource`, whose object branch carries the same issue as before. `os validate` and the API error envelope print one `Invalid input` line for the union at `dataSource`, then the object branch's issue at the same path as before (for example `dataSource.filter`). + - **Not rendered yet.** No console block reads an API binding today: the generic stat, usage-bar and checklist blocks that will are a console release still to come. Until then the binding parses and no block reads it; its schema description says so (`[PLANNED — not yet rendered]`). +- 8464380: fix(spec, objectql): a `tree` field with no `reference` points at its own object, so its `deleteBehavior` takes effect when a parent is deleted + + Clause-②: yes (widening) + + A `tree` field is a record's parent in a hierarchy on its own object. `reference` is optional on it, and when you write one it must be the object's own name — the object schema refuses any other target and tells you to drop the key. But a `tree` with no `reference` behaved as if it pointed at nothing. Deleting a parent answered success and left every child holding the deleted id, whatever `deleteBehavior` said (`set_null`, `restrict` or `cascade`). The same field also could not be expanded (`expand=parent` answered "declares no target object"), could not be filtered through (`{ parent: { title: … } }` was refused), and accepted a parent id that does not exist. + + Now a `tree` with no `reference` is a reference to the object that declares it, exactly as if you had written the object's name: + + - **`@objectstack/spec`.** `ObjectSchema` and `ObjectExtensionSchema` write the object's own name (an extension's `extend`) into such a field's `reference` when they parse it. `defineStack` and `ObjectSchema.create()` output, and built artifacts, now carry the object's name in `reference` on those fields, byte-identical to a field that wrote it. Nothing you could write before is refused now. A new export, `materializeTreeSelfReferences(objectName, fields)`, is the one helper both the parse and the engine use. + - **`@objectstack/objectql`.** The object registry writes the same self-reference when an object is registered. That covers an object saved through the metadata API (`PUT /meta/object/:name` stores the body as written) and a direct `registerObject` call, neither of which goes through the parse. + + What changes on a parent delete: + + - `set_null` (the default) clears the children's parent field and keeps the children. + - `restrict` refuses the delete with `DELETE_RESTRICTED` (409), naming the tree's own object as the dependent. + - `cascade` deletes the whole subtree. + + `expand`, nested relation filters, the write-time check that a parent id exists, the dangling-reference audit and `os verify --delete-behaviors` now treat the field as a self-reference too. A `tree` that already wrote its own name, and every `lookup` and `master_detail`, behave exactly as before. +- 252a582: feat(spec, metadata-protocol): the pending-drafts list and a package's commit timeline tell two languages of one email template apart — each entry of a template carries `member` (#23073) + + Clause-②: yes (widening: a new declared response key) + + **What changed for a reader of the two lists.** An email template kept in several languages is one item per language: two languages of `acme.welcome` are two drafts, and two items of one commit. Until now `GET /api/v1/meta/_drafts` and `GET /api/v1/packages/:id/commits` answered them as two entries with the same `type` and `name`, so a reader could not tell which entry was which language, and could not address the second one. + + - Each entry of `GET /meta/_drafts` (`ListDraftsResponseSchema`) and each item of a commit in `GET /packages/:id/commits` (`ListPackageCommitsResponseSchema`) now carries an optional `member` for a type whose identity is a pair (today only `email_template`, identified by `(name, locale)`). It is the language the entry is: `zh-CN`, and `en-US` for the canonical language, spelled out rather than left blank. Two languages of one template are therefore two entries with different `member` values. + - `member` is present on every entry of such a type and absent on every other type, so the lists are unchanged for every type whose identity is its name alone. + - The value is the one the by-name doors take back as their `member` selector: `GET /api/v1/meta/email_template/acme.welcome?state=draft&member=zh-CN` reads the `zh-CN` draft a list entry names, and `?member=` on publish, history, revert and reset addresses it the same way. For a commit item, `member` is the language the stored revert plan records, the one a revert of that commit acts on. + + **For code that uses these packages directly** (`@objectstack/metadata-protocol`): `ObjectStackProtocolImplementation.listDrafts` entries and `listCommits` items have the optional `member` described above. The types `ListDraftsResponse` and `ListPackageCommitsResponse` (`@objectstack/spec/api`, which `@objectstack/client`'s `meta.listDrafts` and `packages.listCommits` return) gain the same optional field. Code that does not read it behaves as before. +- f931e76: feat(spec)!: the page action rows' url `target`, a dashboard header action's `actionUrl` and an action's `onSuccess.navigate` declare the closed url scheme set — `http://`, `https://`, `mailto:` and in-app paths — and refuse every other target at parse + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on three published authoring surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. + + All three were free strings, so a target the console's action runner refuses — `javascript:`, `data:`, `tel:`, a protocol-relative `//host`, a bare relative path — parsed clean, published, and failed only at the click or after the action succeeded. A `type: 'url'` action's `target` already closed this set; the scheme set is stated once in the contract and the runtime opens exactly that set, so these doors now apply the same grammar, with no second one: + + - **The page action rows' `target`** on the url executor: an `action:button` or `action:icon` with `actionType: 'url'`, and an `action:group` / `action:menu` member with `type: 'url'`. On every other executor `target` names a script, flow, page, endpoint or form and is untouched, and a row with no `target` is not judged. A page's `properties` bag is outside the stack parse; `os validate` judges it through its props gate, which parses the bag against these same rows and so reports the refusal at the row's `target` with the same text, as a `component-props-invalid` finding (that gate's one tier, a warning). + - **`dashboards[].header.actions[].actionUrl`**, when `actionType` is `'url'` or omitted. An omitted `actionType` is judged as the url route, because the two readers that give it a meaning (the console's dashboard renderer and `os validate`'s dashboard action check) both read it as `'url'`. On `'flow'`, `'script'`, `'modal'`, `'api'` and `'form'`, `actionUrl` names the flow, script, page, endpoint or form and is untouched. + - **`actions[].onSuccess.navigate`**, on every value. + + The admitted set, at both doors: + + - an `http://` or `https://` URL (the scheme compared case-insensitively); + - a `mailto:` address; + - an in-app path that starts with `/` or `./` (`/apps/crm/tasks/${result.id}`); + - a template that opens with `${` (`${result.redirectUrl}`), judged by the runner after interpolation. + + Every other value is refused at the key, with a message that names the reason and the admitted set: `javascript:`, `data:`, `tel:` and every other scheme; `http:` or `https:` without the `//`; a protocol-relative `//host`; `/` followed by a backslash; a tab, line feed or carriage return anywhere; a bare relative value such as `forecast`, `../forecast` or `tasks/${result.id}`; and the empty string. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `target: 'javascript:…'` / `actionUrl: 'javascript:…'` / `navigate: 'javascript:…'`, or `data:…` | no url target runs or renders content. For a page action row or a header action, declare the executor that does the work (`'script'`, `'flow'`, `'api'`), or link to an `https://` page; for `onSuccess`, drop it or navigate to an `https://` page. | + | `'tel:+1…'`, `'ftp://…'` or any other scheme | an `https://` page that offers it, or a `mailto:` address. `tel:` is not admitted. | + | `'https:example.com'` (no `//`) | `'https://example.com'` | + | `'//cdn.example.com/x'` (protocol-relative) | `'https://cdn.example.com/x'` for an external link, or `'/x'` for an in-app path | + | `'/\example.com'` (`/` then a backslash) | `'https://example.com'`, or an in-app path written with forward slashes | + | a value holding a tab, line feed or carriage return | the same value without them; percent-encode one that belongs in the address (`%09`, `%0A`, `%0D`) | + | `actionUrl: 'forecast'` with `actionType` omitted, meant as a flow or script | `actionType: 'flow'` (or `'script'`, …) beside it — the declared type takes `actionUrl` off the url route | + | `'forecast'`, `'../forecast'`, `'?tab=2'`, `'#top'`, `navigate: 'tasks/${result.id}'` | the rooted in-app path, `'/forecast'` / `'/tasks/${result.id}'`, or a `./` path | + | `target: ''` / `actionUrl: ''` / `navigate: ''` | the address the button or the hop was meant to open; drop an `onSuccess` that navigates nowhere | + + **The one-line fix: give every url page action row, every url header action (`actionType: 'url'` or omitted) and every `onSuccess.navigate` an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — or, for a row or a header action meant to run something, the executor that names it.** `os migrate meta --from 17` lists the D3 entries `ui-page-action-row-url-target-scheme-set-closed`, `ui-dashboard-header-action-url-scheme-set-closed` and `ui-action-on-success-navigate-scheme-set-closed`; the rewrite is by hand, because which address a refused value meant is the author's call. + + **What an author now sees.** `os validate`, `os build`, `defineStack` and `defineAction` refuse such a value at `header.actions[].actionUrl` or `onSuccess.navigate`; on a page action row, `os validate`'s props gate reports it at the row's `target` (`properties.target`, or `properties.actions.N.target` for a member). For example, `javascript:alert(1)` on a header action gets: ``A dashboard header action's `actionUrl`, a url target when `actionType` is `'url'` or omitted, opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted value parses unchanged. + + **Measured producers: none outside tests.** Every url-executor page action row and every header action outside tests in this repository and in objectui (at its pin `7578323` and its main `7af0ab37`) names an admitted target or declares a non-url `actionType`, and the only `onSuccess.navigate` outside tests is the documented in-app route template `/apps/crm/tasks/${result.id}`. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- ace0a53: feat(lint)!: `relationship/master-detail-required` refuses the three unsafe master-reference shapes at `error` on a `controlled_by_parent` object (#9139) + + Clause-②: no (narrowing) + + A `controlled_by_parent` detail derives all of its record access from the master its `master_detail` reference names (ADR-0055). Three declarable shapes of that reference leave the security gate as the only thing refusing a detail record saved without its master, because record validation never checks a field that is not `required` and skips `readonly` and `system` fields before its required check: + + 1. `required` absent, or `required: false`; + 2. `required: true` with `readonly: true`; + 3. `required: true` with `system: true`. + + A record that lands without its master anyway is readable by nobody, and every later write to it by id is refused. Until now `relationship/master-detail-required` was a `warning` with the predicate "`required` is not `true`", on every object, so shapes 2 and 3 drew no finding at any severity. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary, scoped to `controlled_by_parent`. + + **BREAKING — what moves for consumers.** + + - `os lint` reports each of the three shapes at `error` when the object declares `sharingModel: 'controlled_by_parent'`, located at the defect (`…fields.FIELD.required`, `.readonly` or `.system`). It covers every `master_detail` field of such an object, the same scope the builder's `required: true` force already applies. `os lint` therefore exits non-zero on such a stack, and the metadata-generation rubric (`scoreMetadata`) weighs the finding as an error and marks the stack `valid: false`. + - `@objectstack/spec` gains the step-18 semantic migration entry `cbp-master-detail-required-lint-error`, so `os migrate meta` across protocol 18 prints the prescription below. + + **Remedy — the v18 upgrade-checklist line.** On every object with `sharingModel: 'controlled_by_parent'`, give each `master_detail` reference `required: true` and remove any `readonly: true` or `system: true` from it. `os lint` now refuses the missing-`required`, `required` + `readonly` and `required` + `system` shapes there at `error` (`relationship/master-detail-required`). An object authored through `ObjectSchema.create` already gets `required: true` when the key is omitted, so the edit there is dropping the flag. + + **Unchanged.** + + - On every object that is not `controlled_by_parent` the rule is exactly as before: a `warning` for a `master_detail` without `required: true`, the same message and fix, and no finding for the two flagged shapes. + - The rule is not in the authoring-rule registry. `os build`, `os validate` and the metadata save door do not run it, so a stack carrying one of the shapes still builds and publishes. Only `os lint`'s exit code and the generation rubric move. + - Runtime is untouched. The security gate keeps refusing an insert that omits the master FK on these shapes and keeps resolving the master for metadata already at rest, and stored metadata is neither rewritten nor refused on load. + - No export or signature moves in either package. + - Measured before crossing, at `b04a5295f`: 129 authored objects across the example apps, the platform, plugin and service objects and the CLI's golden eval corpus. 7 of them are `controlled_by_parent`, and 0 draw the new `error`. + + + +### Patch Changes + +- 0253192: The TSDoc on `RecordRelatedListProps.add` names its canonical example by name: "Assigned Users" on a permission set keys `sys_user_permission_set` on `permission_set` with `relationshipValueField: 'name'`, since a grant names its permission set (ADR-0131 D4). Text only; no schema changes. +- 7aa8d51: feat(plugin-security): the boot no longer writes `sys_capability` rows for the platform's curated capabilities + + Clause-②: no + + The curated and derived-default capability seeder (`bootstrapSystemCapabilities`) is deleted. On a fresh database the boot writes no `sys_capability` row for the nine curated platform capabilities (`manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`), and none for a name a permission set grants in `systemPermissions` without declaring it. The curated capabilities are served by the registry: `GET /api/v1/meta/capability` lists them and `GET /api/v1/meta/capability/:name` answers each one's definition, as before. Declare a capability your package grants with `defineCapability` (`stack.capabilities`); a declared capability is still seeded as a package row. + + No authorization decision changes: a grant is still resolved by the capability name in `systemPermissions`, and no runtime check reads a `sys_capability` row. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + + In `@objectstack/spec` (`PLATFORM_CAPABILITIES`) and `@objectstack/lint` (`validateCapabilityReferences`), the doc comments now name the registry instead of the retired seeder; no behaviour changes there. +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- d112087: docs: the suggested-binding acceptance is deployment-level and takes effect + + Clause-②: no + + Text only. These are the published sentences that describe what confirming a package's `isDefault` suggestion does. They now match the behaviour `@objectstack/plugin-security` ships in this release. + + - `@objectstack/spec`: `ISecurityService.listAudienceBindingSuggestions`, `confirmAudienceBindingSuggestion` and `dismissAudienceBindingSuggestion` keep "writes the binding" for the confirm. The text now also says what that means: a `manage_metadata` holder accepts once for the deployment, `everyone`'s definition is derived again, and a dismiss on an accepted suggestion revokes it. The package-level `isDefault` note keeps "an admin confirms in Setup" and adds who that admin is. + - `@objectstack/client`: the `security.suggestedBindings.confirm` and `.dismiss` TSDoc. + - `@objectstack/rest`: the route summaries of the confirm and dismiss routes. Paths and envelopes are unchanged. + - `@objectstack/cli`: the ownership-inventory citation for `sys_audience_binding_suggestion` now says the record is deployment-level and owned by the Default Organization. +- 7de0265: feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire + + Clause-②: yes (narrowing) + + + + **BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1. + + **What changes.** + + - **Templates resolve from the registry.** `sendTemplate` and `renderTemplate` read `email_template` metadata by `(name, locale)` at every send: the protocol's layered list (`GET /meta/email_template`'s items — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is unchanged: the named locale exactly, then `en-US`, then — only for a call that named none — the bundle's lowest locale tag. + - **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered at plugin init, so they now appear in Studio's email-template list. An app that declares the same `(name, locale)` wins over the built-in; the built-in still serves the locales the app leaves out; a Studio save overrides either at environment level. Every built-in renders byte-identical to the row it used to be seeded into, in every locale (pinned on the rendered subject, HTML and text). + - **A Studio save is live on the next send**, with no projection and no restart. The `PUT /meta/email_template/:name` response no longer carries `projectionApplied`, because nothing is projected. + - **No `sys_email_template` row is written at boot or read at send.** The table and its rows stay; the organization door stays closed. + - **A malformed template fails its send loudly** with the schema's own message, instead of falling through to the built-in copy of the same slot. + + **What moves for consumers.** + + - FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`. + - FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call. + - FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`. + - FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing. + - FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option. + - FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option. + + **What a deployment observes.** A `sys_email_template` row an organization customized before its door closed is no longer what sends: the template's code declaration (or an environment-level Studio save) is. The v18 migration ceremony promotes those rows to environment-level templates; to make such an edit live before it runs, re-save the template in Studio. + + **`@objectstack/spec`** (patch): the `email_template` liveness ledger is re-anchored to the registry loader, and `category` and `isSystem` are graded `dead` — their only consumer was the retired row projection. The `notify` node's `template` description and refusal messages, and the `IEmailService` contract's documentation, name the email templates rather than `sys_email_template` rows. No schema shape changes. + + **`@objectstack/service-automation`** (patch): the notify node's `template` help text served at `GET /api/v1/automation/actions` names the email templates rather than `sys_email_template` rows. +- 0af4f66: Three `ComponentPropsMap` read-point records now quote the objectui line they cite + + Clause-②: no + + The docblocks of `action:button`, `action:icon` and `element:definition-list` in + `src/ui/component.zod.ts` each quote the first line of the row's props-read site, re-read + against objectui at the pin this package builds against (`a58626c88`): the runner-forward + literal of the two action blocks, and the `readProps` call of the definition list. A pin bump + that moves one of those lines, or changes it, now fails `check:objectui-pin-citations` and + names where the quoted line went, where before only the cited sha was checked. The other + three rows of that section (`action:group`, `action:menu`, `element:repeater`) carry no quote + yet. Comment text only: no schema, key, type or export changes. +- 9a0401f: The `action:group`, `action:menu` and `element:repeater` read-point records are re-measured at the objectui pin and quote the line they cite + + Clause-②: no + + The docblocks of `action:group`, `action:menu` and `element:repeater` in `src/ui/component.zod.ts` + cited objectui lines that had moved without any gate noticing: the two containers' anchors by 3 to 8 + lines since objectui#11638, every repeater anchor by 28 lines since objectui#11168 slice 2, and the + repeater's `data-objectstack` filter and sort anchors since earlier pins. Each is now re-pointed at + the pin this package builds against (`a58626c88`), as are `action:button`'s `static-params.ts` + citation and the `element:definition-list` registration notes, which said the registration + publishes the strings `'1'` / `'2'` and marks `items` required (it no longer does either). Each of + the three rows now quotes the first line of its props-read site (the member forward of the two + containers, the `readProps` call of the repeater), so a pin bump that moves or changes one of those + lines fails `check:objectui-pin-citations`. All six rows of that section now carry a quote. Comment + text only: no schema, key, type or export changes. +- 9c93ce6: Source comments that cited a tracker number now say in words which decision they mean, and the pin tests that read those comments match the new words + + Clause-②: no + + The package ships its `src/**/*.zod.ts` sources, and `dist` carries the member docblocks. Four of those texts named a decision only by its tracker number. Each now names the decision in words. The pin test that reads each text matches the new words, at the places the old number stood: + + - `ai/build-progress.zod.ts`: the cloud card that ruled the build-progress phase vocabulary in, and that owns where the emitter sits, is now "the cloud build-progress card" in all seven places. The consumer's strict-parse carrier is "block 2 of the objectui build-panel card". The `ai/build-progress` reference page is regenerated from the module docblock, so its description now ends with "(the cloud build-progress card, ruling A)". + - `contracts/approval-service.ts`: the `IApprovalService.continueRestoredRun` docblock says the verb is declared "exactly as the automation-service contract declared" `IAutomationService.cancelRun` / `restoreConsumedSuspension`. + - `ui/notification.zod.ts`: the tombstone note for the removed `./ui` `Notification` / `NotificationConfig` wrappers is labelled `[dual-source removal]`. The `NotificationAction` removal is called "the no-door retirement". + - `ui/sharing.zod.ts`: `EmbedConfigSchema` is recorded as removed "at the no-door retirement (ADR-0049 enforce-or-remove)". The `ui/sharing` reference page is regenerated from the module docblock. + + Comments and docblocks only. No key, type, default, accepted value, `.describe()` string or export changes. +- 04e776b: `App.defaultAgent` docblock: the agent route is the one chat door, and the console is what reads the key. + + Clause-②: no + + - The docblock no longer says the assistant chat endpoint (`POST /api/v1/ai/assistant/chat`) resolves this agent from `context.appName`. That route, with `GET /api/v1/ai/assistant` and `GET /api/v1/ai/assistant/skills`, was retired in the cloud AI runtime (objectstack-ai/cloud#2621, objectstack-ai/cloud#2651), and no server route reads `defaultAgent`. + - It now says who does read it. The console's chat dock hands the active app's `defaultAgent` to its one surface-to-agent resolver, which honours only `ask` or `build` (legacy aliases included) and otherwise falls back to the surface default. The resolved agent is then called by name on `POST /api/v1/ai/agents/:agentName/chat`, where the path segment, not this key, selects the agent. + - The ADR-0063 surface-binding paragraph and the rule that only the two platform agents resolve are unchanged, as is the note that the bare `POST /api/v1/ai/chat` resolves no agent. + - The docs page `ai/actions-as-tools` lists the agent route as the only in-product chat route. + - ⛔ No schema, parse, `.describe()`, export, type or accept-set change. The docblock ships in the published package, in the `dist/ui` and `dist/browser` JavaScript bundles and in the shipped `src/ui/app.zod.ts`, which is why this is a patch. +- a7df552: `ObjectNavItemSchema.viewName`'s describe no longer says the default is "all". It now states what the console does when an object nav entry names no view: it opens the object's default list view, else its first declared list view. `all` is only the console's fallback tab, and it exists only for an object that declares no list view. + + Clause-②: no + + - The rule is read from objectui at the `.objectui-sha` pin. `ObjectView` opens `defaultViewId || views[0]`, where `defaultViewId` is the view `buildViewTabs` marks `isDefault` (the default `list`). `buildViewTabs` adds the `all` tab only when the object has no list view at all. + - An author who omitted `viewName` expecting all records got that default or first declared view instead. When the object declares more than one list view, name the one the entry should open in `viewName`. + - The generated app reference page follows (#21973). The lint header that quoted the old sentence follows too, as a comment only. + - ⛔ No schema, type, optionality, default, export or accept-set change. The console's behaviour does not change. +- d5a14dd: `EvalUserSchema.isPlatformAdmin` is no longer marked deprecated. Its describe and docblock now say what the key reports: the `PLATFORM_ADMIN` standing of ADR-0095 D3. + + Clause-②: no + + - The platform resolves that standing per request, from the deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) under every tenancy posture, or from an unscoped `admin_full_access` grant under the `single` posture. + - It is the predicate platform-operator gates read: `current_user.isPlatformAdmin == true` (ADR-0068 D4). The session payload emits it from the posture rung, and the platform-admin route gate reads it. + - The resolver projects the `platform_admin` name into `positions` from the same grant, so the name and the key agree for every genuine administrator. Gate on the key, never on `'platform_admin' in current_user.positions`. + - The old text, "DERIVED alias of 'platform_admin' in positions. Deprecated.", described a reading ADR-0095 D3 superseded. ADR-0068 carries dated notes under D2 and D4 that say so. + - ⛔ Nothing you author changes. There is no schema, type, optionality, default, export or accept-set change, and `createEvalUser` computes exactly what it did. A predicate that already reads `current_user.isPlatformAdmin` keeps working and is the supported form. +- 93125ae: A browser bundle that imports from `@objectstack/spec/shared` or the package root no longer keeps the ADR-0087 conversion table unless it uses it + + Clause-②: no + + Each published entry is one flat file, so a consumer's bundler keeps every top-level call it cannot prove pure, together with everything that call references. Two such calls built the conversion table when the module loaded: the major-18 list's `inApplicationOrder(...)` and the flattening into `ALL_CONVERSIONS`. So every bundle of an entry that reaches the table kept all of it: every conversion, plus the view, field, page-component, dashboard, chart and report schemas the conversions read. `./shared` reaches the table only through `normalizeStackInput`, so a bundle that imported an expression schema from it carried the table too, and 17.7.0's new conversions made that copy larger. Both calls now carry a `@__PURE__` annotation, so a bundle keeps the table only when something it keeps reads it, for example `defineStack`, `normalizeStackInput` or `applyConversions`. + + Measured on objectui's console (objectui `c0862c1c`), against the same build with this package's previous source: the first screen's eager closure is 226,238 bytes gzip smaller, all of it in the `vendor-objectstack` chunk. Every entry's export list, every declaration and every runtime value is unchanged. Only the bytes a bundler keeps change. +- d7c5c33: The `sharing.publicLink` describe says the value is a slug and names where it is served + + Clause-②: no + + `FormView.sharing.publicLink` described itself as a "Generated public share URL". It is + neither: the author chooses it, nothing generates it, and the server reads it as a slug, where + `/forms/x`, `forms/x` and `x` name one slug, `x` (`publicFormSlug`). The describe now says so, + and names where an anonymous visitor reaches the form while `enabled` and `allowAnonymous` are + both true: the REST form door `GET /api/v1/forms/:slug` (on the default API base), and, on a + host that serves the console, the console page `/_console/f/:slug`, with `/forms/:slug` + redirected there and its query string carried. The generated `ui/sharing` and `ui/view` + reference pages carry the new text. Describe text only: the accepted values, keys, types and + exports do not change. +- 15ec50e: A notify flow node's `title` / `message` refusal now names the key and prescribes `'{{ record.name }}'`, the `{{ }}` hole the notify executor renders. It used to answer with the shared template sentence, which names no key. + + Clause-②: no + + - Both slots take the same input as before: a bare, non-blank string or a `{ dialect: 'template', source }` envelope. Every value that parsed still parses, every value that was refused is still refused, with the same `invalid_union` code at the same path. Only the sentence changes. + - A blank bare string, a number, a non-template envelope and the like at `title` or `message` are refused with a sentence that names the key, prescribes `'{{ record.name }}'` or `{ dialect: 'template', source: '{{ record.name }}' }`, and says why: the notify executor renders `{{ }}` template holes, and a single-brace token is not one. The branch issue that `formatZodIssue` and the API error mapper print beneath it carries the same sentence. The build's flow judge (`FlowSchema`, flow registration, `os validate`) quotes the new sentence. + - Every other template slot keeps its sentence, which still prescribes `'{{record.name}}'`. That covers `titleFormat`, the prompt template's `system` / `user`, and any slot typed `TemplateExpressionInputSchema`. `TYPED_EXPRESSION_SOURCE_REQUIRED.template` and `TYPED_EXPRESSION_DIALECT_ONLY.template` are unchanged. + - The `tmpl` docblock no longer calls the envelope "Mustache" or shows only `{{record.x}}`. It now says which renderers read which braces: `{{record.x}}` for the formula template engine, the messaging, email and i18n renderers and a notify node's `title` / `message`, and either for `titleFormat`. The `TemplateExpressionInputSchema` docblock lists the notify slots the same way, and the generated expression reference page says a template slot's fix is written in the spelling its renderer reads. + - No export is added, removed or renamed, and no type changes. The notify slots take the same input as `TemplateExpressionInputSchema` from a constructor that stays internal to the package. +- 51290bc: Form help and refusals an author reads no longer carry service-interface names, ruling dates or another product's ids + + Clause-②: no + + Wording only: no schema, key, type, export or error-code change. + + - The notify node's Template help (the `NotifyConfigSchema.template` describe and the Studio + inspector's copy in `@objectstack/service-automation`) names the deployment's default locale in + product words instead of `II18nService.getDefaultLocale()`, and drops its ruling date. + - `MANIFEST_ID_EXAMPLES` is now `com.acme.crm` and `org.example.help-desk` (was `com.steedos.crm` + and `org.apache.superset`). The package-id refusal opens with the headline + "Invalid package id 'VALUE'." and names the key in the sentence after it, so the headline alone + carries no JSON path; the rule, the examples and the suggestion follow unchanged in substance. A + caller that matched the old "on KEY. Expected reverse-domain notation" wording matches the + headline, or compares against `manifestIdRefusal()` by reference, instead. + - Ruling dates leave the describes Studio renders as form help: field `required` and `multiple`, + form-view field and section `visibleWhen`, section `collapsible` / `collapsed`, the redirect + arm's `submitBehavior.url`, and page `kind` / `source` (the ADR citations stay). They also + leave the refusals for padded grouping field names, `submitBehavior.url`, `features.*` in a + form-view predicate, and the four filter comparand refusals (null ordering comparand, + `{ $field }` in a list position, null list member, blank `$between` bound). Each sentence still + states the rule, why it exists and the repair. + - The email-template form's Identity section help says how senders address a template instead of + naming `IEmailService.sendTemplate`, in all four shipped locales. + - The action `description` help no longer ends in a dangling dash left behind by an earlier + strip: "(one dialog, not two —)" now reads "(one dialog, not two)". +- ad381fd: The form view `submitBehavior` property help no longer ends in a ruling date (#22093) + + Clause-②: no + + Studio, the reference docs and the published `objectstack-ui` skill show this describe as the property help. It still says that on the `redirect` arm `url` is relative-only, interpolates only declared record fields as `{{record.field_name}}` and is URL-escaped; the "(ruled 2026-08-11)" parenthetical is gone, and the ruling's date and tracker reference live in the code comment above the describe. Wording only: no key, type, accept/reject or export change. The `react-blocks.md` row and the `view` / `protocol` reference pages are regenerated from it. +- 3b49318: fix(meta): a second unpinned draft save of a package-owned item is no longer refused 409, the item read serves that draft's `version`, and a package-less draft save keeps a package-owned datasource's stored credential + + Clause-②: yes (widening) + + - **The second draft save is accepted.** Save an item into a package (`PUT /api/v1/meta/:type/:name?package=`), then save a draft of it without `?package=` twice. The repository stores that draft in the item's package (the draft inheritance that keeps a pending change in the package it edits). The save door took its expected parent from the package-unbound draft row instead. It found none, so the second save, which sent no `If-Match`, was refused `409 METADATA_CONFLICT` ("Expected parent null but current is hmac-sha256:…"). ADR-0008 makes an unpinned save last-writer-wins, and it is accepted again. + - **One head row per write address.** `SysMetadataRepository.headAt(ref, { state, packageId })` answers `{ head, packageId }`: the row a `put` at that address upserts (`null` for a create), and the package binding that put writes under. Both come from the same resolution `put`'s optimistic lock runs. That resolution includes the draft inheritance and the adoption of a draft stored without a package. The save door's expected parent and the item read's `version` both come from it, so the token `GET /meta/:type/:name?state=draft` serves is the token a draft save at the same address accepts. Before this, that read served `version: null` while the inherited draft existed. + - **`?package=all` on the item read.** `GET /meta/:type/:name?package=all` now reads `all` as the save and publish doors read it: as naming no package. Before, the read forwarded the literal `all`. Its `version` was resolved at a package no save writes, so it served `null` beside a row the save then judged. Its `?state=draft` read answered `404 NO_DRAFT` for a draft stored in the item's package. The read now serves the item as a read with no `?package=` serves it, and `?package=all` still takes the uncached arm, so it keeps serving `version`. + - **The credential carry-forward reads the same row.** A save of a type whose read withholds a credential (`datasource`; `flow` with the automation plugin) carries the stored credential back into a body that returns as it was served. It compared that body against the row at the key the caller named. A package-less draft save of a package-owned item therefore found neither the inherited draft nor the package's active row, and compared against the code layer. The stored credential was persisted away on the first such save, measured on the real route with a datasource whose `config.url` holds one. Both reads now go through `headAt`: the draft the save overwrites, or with no draft yet the active row in the package the draft is stamped into. An item with no package binding is unchanged. + - **Descriptions only, in `@objectstack/spec`.** `SaveMetaItemRequestSchema.packageId` now states the draft inheritance, where it said "absent = env-local overlay". The `POST /meta/:type/:name/publish` route description spells the conflict code `METADATA_CONFLICT`, as the wire sends it. + - A stale `If-Match` is still refused `409`, and `If-None-Match: *` is still refused while a row exists. An item with no package binding is unchanged. No spec key, request field or response field is added or removed. + - The only change to the public surface is one added method on the exported `SysMetadataRepository`: `headAt`. No existing signature, schema or accept set changes. +- 9fe7448: fix(spec, lint): the functional-completeness findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** The nine rule ids the ADR-0078 completeness predicate emits (`field/summary-without-operations`, `field/formula-without-expression`, `field/relationship-without-reference`, `field/choice-without-options`, `view/layout-without-binding`, `view/tree-without-parent-field`, `view/row-color-without-colors`, `view/row-color-unresolvable-value`, `webhook/without-triggers`) printed a `message` of 239 to 793 characters. Each `message` is now one verdict sentence of at most 200 characters (for `view/row-color-unresolvable-value`, plus the offending `key` = "value" entries it lists). It still names the runtime site that makes the verdict true, for example `` A `summary` field with no `summaryOperations` reads 0/null everywhere: the engine's summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`) ``. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`) is unchanged, and so are every rule id, severity, `path`, and what each rule accepts or refuses. For `view/layout-without-binding`, the keys the old body told the author to declare are on that `fix:` line. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these nine ids**, for example `os explain view/layout-without-binding`. It prints the reasoning the verdicts no longer carry: each view type's measured renderer path, refusal screen and required keys, the exemptions (`user` relationships, `multiselect`, `gallery`), the hex trap behind the two `rowColor` rules, and why an empty `triggers` is not an off switch. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the nine ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the nine entries. + - **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face. `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate, the rule runs on `object` writes only (Studio, REST `/meta`, MCP), so only the `field/*` ids reach it. A `summary` field with no `summaryOperations` or a `formula` field with no `expression` is an `error`: the 422 issue's `message` changes, and so does the server's refusal log line under the unlinted-writes hatch. A `checkboxes` field with no `options` is a `warning`: the `message` in the 2xx response's `advisories` changes, and so does the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. A `lookup`/`master_detail` with no `reference` and a `select`/`radio` with no `options` never reach this gate: the object schema's own parse refuses them first, with its own message, which this change does not touch. + - **Unchanged doors.** The registry's boot-time line (`[Registry] Object "…" registered with N functionally-incomplete field(s) …`) prints each finding's rule id and `fix`, never its `message`, so it reads as before. The webhook auto-enqueuer's `webhook/without-triggers` boot warning has its own text, also unchanged. The `view/*` and `webhook/*` ids do not run at the runtime publish gate. +- 2e11d90: docs(spec): `ApproverType`'s description points at `os explain approval-approvers-may-resolve-empty` for the graded `manager` routes + + Clause-②: no + + - **What changes:** the `.describe()` text of `ApproverType` (`@objectstack/spec/automation`), which the generated reference page renders for every approver entry (`content/docs/references/automation/approval.mdx`). It said `os lint` "carries the graded population routes and the full remedy". Since `@objectstack/lint` shortened `approval-approvers-may-resolve-empty`'s fix line to one sentence, the finding names two routes in one line, and `os explain approval-approvers-may-resolve-empty` prints the graded routes and the full remedy. The description now says so. Its "the request waits" clause now says the request falls to the node's `onEmptyApprovers` policy; under the default policy that opens the request on the organization's administrators, so it no longer waits. + - **Unchanged:** the `ApproverType` values, the schema's shape, and what it accepts or refuses. Only the description text moves. +- d8d0711: fix(cli): `os migrate meta` lists and writes a conversion the load already applies inside a composed project's package body (#22256) + + Clause-②: no + + - **What was wrong.** In a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, each package body is assembled from the stack its input's `defineStack` call returns. That call runs the load-time ADR-0087 conversions, both when the schema accepts the input and when `os migrate meta` produces a refused input again in `strict: false` mode. So a spelling the load still converts inside a package body, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, and the run exited 0. + - **What changed.** `os migrate meta` now produces every input of the composition again from what its author wrote, with the load-time conversions skipped, before the inputs are composed. Composition then assembles each package body from the authored source. The conversion is listed under the body's path (for example `packages[0].manifest.datasources[0].driver`), `--write` writes it into the file that authored that input, and the next load converts nothing. This holds whether the input's `defineStack` call accepted its argument or was refused. + - **`@objectstack/spec`.** `defineStack` reads one internal parameter for this, which only the CLI passes: an undeclared, symbol-keyed entry on its options, honoured only together with `strict: false`, that skips the conversion pass. It is not part of `DefineStackOptions` and nothing new is exported. A strict call ignores it, and every call without it behaves exactly as before. + - **What did not change.** A project with nothing to convert gives the same `--json` summary and the same `--write` result and written bytes as before. This was measured on the four example apps, one of them composed, at `--from 16` and `--from 17`. Every other command still reads the stacks `defineStack` builds and the composition they make. + - **The `--out` snapshot of a composed project.** Each package body in it is now the body as written, with the chain's changes applied. That is what the snapshot of a one-package stack already is. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys. +- 40a6ee5: A value-slot `{…}` refusal now names a CEL spelling that evaluates when the path's head variable is named like a CEL type or keyword (`{list.0}` → `vars["list"][0]`) + + Clause-②: no + + The refusal for a `{…}` path token in a flow value slot (`valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals`, shown by `objectstack validate`, `registerFlow` and the executors) printed the path as bare CEL. For a head variable named `list`, that remedy was `list[0]`, and CEL read `list` as its own type, not the variable: `objectstack validate` refused the remedy it had just suggested (`Cannot index type 'type' with type 'int'`), and the bare `{list}` remedy `list` passed validation and evaluated to the type instead of the variable's value. A head that CEL claims for itself is now read through the flow scope's `vars` map, the route a `$`-named head already took: `{list.0}` → `vars["list"][0]`, `{list}` → `vars["list"]`. + + The claimed names are read off the CEL implementation the formula engine builds (cel-js 8.0.0): the type identifiers `bool`, `bytes`, `double`, `int`, `list`, `map`, `null_type`, `string`, `type` and `uint`; the namespaces `cel`, `google` and `optional`; the reserved words, such as `for`, `if` and `var`; and the keywords `true`, `false`, `null` and `in`. `timestamp`, `duration` and `dyn` are functions there, not bindings, so a variable with one of those names already read correctly and is unchanged. A later path segment that is a keyword is indexed by name (`{record.in}` → `record["in"]`). The `has()` guard the refusal suggests is now printed only where `has()` accepts it. CEL refuses `has()` over an index at run time (`has(rows[0].name)`, `has(vars["list"].tags)`), so a path with an index gets no guard, and a claimed head is guarded a step at a time from `vars`, like any other head: `has(vars.list) && has(vars.list.tags) ? vars.list.tags : null`. + + Ordinary heads (`record.owner`, `items[0]`) print the same remedy as before. Which strings are refused and which are kept is unchanged; only the remedy text changes. +- 081e6a0: docs(spec): `ISecurityService` no longer says a context with no principal is admitted or keeps its scope + + Clause-②: no + + The contract text on `ISecurityService.getReadFilter`, `ISecurityService.canExport` and `ISecurityService.canReadObject` (its verdict's arm list and its fails-closed paragraph) said that a context carrying no principal at all keeps the scope the other layers compose for it, or is admitted, as the security middleware hands it through. Since ADR-0096 D5 strict mode `plugin-security` does neither. A non-system context with no position, no named permission set and no user id now gets `false` from `canReadObject` and `canExport`, the deny filter from `getReadFilter`, and `403 PERMISSION_DENIED` (`PermissionDeniedError`) from the engine middleware's principal-less refusal. The text now says so, at each of the three methods, and cites ADR-0096 D5. + + The TSDoc on `ExecutionContext.flowRunId` and on the `ExecutionContext` input type (`kernel/execution-context.zod.ts`), and on `BaseEngineOptionsSchema.context` (`data/data-engine.zod.ts`), no longer teaches `{ flowRunId }` alone as an admitted, unscoped context: it carries no principal and is refused like any other. + + Only comments change. No behaviour, export or type moves, and the system bypass and the ADR-0056 D2 deny baseline for a caller that carries a principal and resolves no permission set read as before. The corrected text ships in the published `.d.ts`. + + What changes for you: nothing at runtime. If your code read the old text and passes the engine a context that carries neither the caller's principal nor `isSystem: true`, it is refused; pass the caller's own context or, for platform code whose own door already authorized the caller, the explicit system opt-in `{ isSystem: true }`. +- 440bed6: A select option whose `visibleWhen` cannot be evaluated refuses the write instead of admitting the value with one warn line (#22402) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing on `@objectstack/objectql`, shipped as `minor` under the launch-window convention for breaking changes (recorded in `scripts/check-changeset-no-major.mjs`). The governing decision is ADR-0137 D2, which the maintainer's ruling on #22402 (letter A) extended to the option gate on the write path. + + A select, multiselect, radio or checkboxes option may gate itself with a `visibleWhen` predicate, and the server re-checks it for every value a write picks. ADR-0137 D2 says a field-rule predicate that cannot be evaluated refuses the submit. The option gate is the server's enforcement of who may pick the option (ADR-0124 D1), so D2 now reaches it on the write path too. + + - **Before.** When an option's predicate faulted on a write (an undeclared key, a root or member the gate does not bind, a read through a reference, a computed key or receiver, `current_user.can()` with no permission data), the server logged "the option's gate was NOT enforced on this write" and admitted the value. A role-gated option whose rule was broken was open to every caller. + - **Now.** The write is refused with `VALIDATION_FAILED` (HTTP 400) and one field entry in the envelope a faulting `requiredWhen` or `readonlyWhen` already uses: `code: 'rule_violation'`, `constraint.reason: 'unevaluable'`, `constraint.rule: 'visibleWhen'`, `constraint.fault` naming the fault, and the picked option as `value`. The message names the option, the field and the fault, for example `Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected.` Nothing is written. This holds on insert (one row or a batch), by-id update and bulk update, and so on an import, which writes through them; `validate()` previews the same refusal. + - **Unchanged.** A predicate that evaluates is judged exactly as before: false refuses with `invalid_option`, true admits. A system write with no acting user whose predicate reads the acting user (`'admin' in current_user.positions` on a seed) is still admitted and logged with reason `no-acting-user`, because the gate has nobody to ask about. A system write whose predicate faults for any other reason is refused, as a faulting `requiredWhen` is. The form still offers an option whose predicate faults (ADR-0137 D3); the refusal comes at save. + - **`current_user.can()` with no permission data.** On an engine with no effective-permission resolver registered (no security plugin composed), a write by an acting user that picks a `can`-gated option is now refused, naming the missing permission data, instead of admitted. + - **A preview limit this inherits.** An `update`-mode `validate()` preview (an import dry run of a matched row) reads no stored row, so a cascade predicate whose parent column the patch omits faults there and the preview now refuses the row, as it already refuses a `requiredWhen` that reads an omitted column. The write itself reads the stored row and judges the predicate cleanly. + + **The remedy: fix the predicate, which `os build` names for every statically judgeable shape.** The shapes no build can judge (a computed key, a computed receiver, a row stored before the build verdicts, or a save under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`) now meet this refusal at the first write that picks the option, naming the fault. + + Measured before the change, on `origin/main` 3054516ef1: the objects this repository ships carry 5 option predicates, all on the showcase's `showcase_cascade` (four `record.country == …` cascades and one `'org_admin' in current_user.positions` role gate), and none of them faults on a write. Insert and update evaluate them over a total record, so a payload without `country` reads `null == 'cn'`, a clean false. No seed or dogfood write picks a gated option. + + Earlier entries in this release that say an option predicate which faults is still logged and admitted describe the gate before this change. + + `@objectstack/lint` and `@objectstack/spec`: the published text that said the server admits a faulting option predicate now says it refuses the write. This covers the `objectstack validate` finding for an option predicate that reads through a reference, a root or member the gate does not bind, and the `SelectOption.visibleWhen` description. No verdict moves. +- 96e4be4: feat(service-storage)!: the `sys_file` scope option `public` is retired, and rows already stored with it are rewritten to `user` by a one-time operator sweep (#22443) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on the `sys_file` object's `scope` select, shipped as `minor` under the launch-window convention for accept-set narrowings. It completes the retirement registered as `storage-scope-public-retired`: `StorageScopeSchema` and both upload doors already refuse `public`, and now the stored vocabulary does too. + + ### What changes + + - **`sys_file.scope` no longer lists `public`.** Its options are `user`, `tenant`, `private`, `temp` and `attachments`. The engine refuses a write of `public` to the column as it refuses any undeclared option: `VALIDATION_FAILED`, `invalid_option` on `scope`. No access behaviour changes: the only scope value any code reads is `attachments`, and whether a file can be read before sign-in is decided by `acl: 'public_read'` alone. + - **Rows already stored with scope `public` are rewritten to `user`**, by a one-time sweep the operator runs (below), never at boot. `user` is the scope the upload doors and the field-reference copy path default to, and no reader tells it apart from `public`. One column moves: the storage key keeps its `public/` prefix, the acl and ownership columns are untouched, and no byte moves in the storage backend. + + ### FROM → TO + + | before | write instead | + | --- | --- | + | a `sys_file` row written with `scope: 'public'` | `scope: 'user'`, or no scope; and `acl: 'public_read'` on the row if the file must be readable before sign-in | + | rows already stored with `scope: 'public'` | nothing by hand: the sweep below rewrites them to `user` | + + **The one-line fix: stop writing scope `public` on `sys_file`, and run the sweep once on each deployment that stored it.** + + ### The operator step: run the sweep once after upgrading + + Until the sweep has run on a deployment that stored `public` rows, a record write that names a `public` file another field already owns is refused. The field-reference copy path copies the file into a new row with the source row's scope, the engine refuses `public` there, and the write fails with `ERR_FILE_REFERENCE_COPY`. The data REST doors answer that `500 INTERNAL_ERROR` ("Internal server error"); the server log carries the full sentence, which ends `Scope must be one of: user, tenant, private, temp, attachments`. Reads, downloads and updates that do not write `scope` are unaffected. + + The sweep ships in `@objectstack/service-storage` itself: `planSysFilePublicScopeBackfill`, `applySysFilePublicScopeBackfill`, `runSysFilePublicScopeBackfill` and `formatSysFilePublicScopeBackfillReport` are exported from the package, with their report types. It has the shape of the `sys_file` organization backfill: an operator step, never a boot hook. Import it from the package you upgraded to and run it server-side, from a context that holds the engine. It is a dry run first, and by default: + + ```ts + import { + planSysFilePublicScopeBackfill, + applySysFilePublicScopeBackfill, + formatSysFilePublicScopeBackfillReport, + } from '@objectstack/service-storage'; + + const plan = await planSysFilePublicScopeBackfill(engine); // counts, writes nothing + console.log(formatSysFilePublicScopeBackfillReport(plan)); + const applied = await applySysFilePublicScopeBackfill(engine, plan); // one scope write per row + console.log(formatSysFilePublicScopeBackfillReport(applied)); // keep it: it is the rollback list + ``` + + It counts before it writes (`scanned`), writes nothing where the count is zero, and is idempotent: every write moves its row out of `scope = 'public'`, so a second run scans and writes zero. A row whose write fails is reported, never retried, and is picked up by the next run. + + ### Rollback + + The inverse is `user` back to `public` on exactly the ids the applied report lists. This release cannot write it through the engine, which refuses `public` like any undeclared option. So the inverse goes with a code rollback: on the previous release, which still declares the option, write `scope = 'public'` back to those ids, through the engine there or as one raw driver statement against `sys_file` filtered to them. + + ### The step-18 entry says so too (`@objectstack/spec`) + + The D3 entry `storage-scope-public-retired`, which the protocol upgrade guide is built from, said that files already stored with scope `public` are not touched. That no longer holds for the `scope` column: after the sweep they read `user`. Its reason now names the `sys_file` option's retirement and the operator sweep above, and says that the rewrite changes no access: the storage key and the bytes stay as they are, and those files still download exactly as before. Its acceptance criteria add the sweep's end state: a dry run on each deployment scans zero `sys_file` records with scope `public`. +- c76edeb: A declared read attachment resolves only where `record` is a row a service serves, so `os validate` refuses a flow condition that reads one + + Clause-②: no + + `ObjectSchema.attachedOnRead` declares the blocks a service attaches to the rows it serves, computed per caller and never stored. An earlier entry in this release adds each declared block to the names `record.` resolves to, at every expression site bound to the object. That covered the sites whose `record` is the stored row, which never carries a block. There `os build`, `os validate` and the object save door accepted a read such as `record.viewer.can_act`, and the expression then faulted with `No such key: viewer` on every row. A record-change flow on `sys_approval_request` whose start condition read `record.viewer.can_act == true` validated, then failed on every record it fired for. + + A declared block now resolves, and its leaves are judged, only at the sites whose `record` is a served row: an action's `visible` and `disabled` predicates. + + - **Refused now.** On an object that declares a block, `record.BLOCK` at any other site gets the refusal it gets on an object that declares none: ``unknown field `viewer` on `sys_approval_request` ``, at `error`. Those sites are a flow's node and edge conditions, a validation rule, a field's `requiredWhen`, `readonlyWhen` and `visibleWhen`, an option's `visibleWhen`, a field formula, a sharing-rule condition and a hook condition. The block is no longer offered as a "did you mean?" candidate there either. The object save door runs the same rule over an object's own slots, so an object write in publish mode that reads a block outside an action predicate is refused with an `expression-invalid` issue. + - **Unchanged.** At an action predicate a declared leaf is accepted, and a misspelt leaf is refused with a message that names the leaves the block declares. `sys_approval_request`'s eight action predicates pass. No refusal code is added, and no export or signature moves. + - **`@objectstack/spec`.** The description of `ObjectSchema.attachedOnRead`, which the JSON schema and the reference pages carry, now says where the validator reads the key: in an action's `visible` and `disabled` predicates `record.` resolves and its leaves are judged, and every other expression site binds the stored row and refuses `record.` as an unknown field. The schema and its parse verdicts are unchanged. + - **Reach.** The only shipped object that declares a block is `sys_approval_request`, and its action predicates are the only shipped expressions that read it. The earlier entry has not been released, so no released version accepted a block read at these sites. +- 514bf3c: docs(spec): the protocol upgrade guide has a public address, one docs-site page per protocol major + + Clause-②: no + + The guide is published on the docs site, one page per protocol major, at `https://objectstack.ai/docs/protocol-upgrade/N`; `https://objectstack.ai/docs/protocol-upgrade` lists them all. 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. + + `docs/protocol-upgrade-guide.md` in the repository, which earlier READMEs and changelog entries point at, is now a short pointer naming that address. + + What a release shipped is unchanged: `protocol-upgrade-guide.md` inside the package and attached to its GitHub Release. Its only change is the header comment, which now names the command that writes the file. + + Nothing changes for an author, and nothing is renamed or removed. +- b3a3634: docs: four contract docblocks now describe what the code decides. No type, schema or runtime change. + + Clause-②: no + + - **`ControlledByParentWriteDenialLeg` (`@objectstack/spec`).** The `master_chain` leg has three resolution refusals: no relation to derive from, a row that is not present, and an empty master reference. Each names the master whose own master the walk could not derive. That master is the record's own master, when it is itself `controlled_by_parent`, or any master above it. The docblock said only a master above the record's own master. The next sentence now says "past the record itself" where it said "above the first hop", so the two agree. The `record_not_found` bullet of `ControlledByParentWriteUnresolvedReason` had the same first-hop gap for a missing master row: it now says an absent row of a master that is itself `controlled_by_parent` is the `master_chain` leg, on the first hop or above it, and the legs judge a missing row only where the master governs its own rows. + - **`ISharingService.canEdit` (`@objectstack/spec`) and `SharingService.canEdit` (`@objectstack/plugin-sharing`).** The caller lists no longer name the `sys_attachment` and `sys_comment` parent gates. Those gates read `checkEdit` and the master-detail write check. The `ISharingService.canEdit` sentence on how they reach the service now names `checkEdit`. + + `ControlledByParentWriteDenialLeg`, `ControlledByParentWriteOutcome`, `canEdit` and `checkEdit` keep their declared shapes, and no verdict, outcome or refusal changes. +- 0e00d36: A locale bundle's page `label` no longer replaces a `page:header` title that says something else, such as a dynamic `'{name}'` + + Clause-②: no + + `translatePage` resolved a root-level `page:header` title as the bundle's `pages..title`, else the bundle's `pages..label`, and wrote it over whatever the header authored. A pack that translated the page label and left out the title therefore turned a record page's `'{name}'` heading into the page's static name in that locale, with no warning at build, lint or serve. + + The header title now resolves in this order, at a region root and a `slots.` root alike: + + 1. the bundle's `pages..title`; + 2. the title the header authors (a template, a plain string or an inline locale map), left as written; + 3. the bundle's `pages..label`, only where the header authors no title, or where its title restates the page's own `label`. That second case is the one `os i18n extract` already covers by offering `label` alone, so those headers keep their translation. + + The doors that serve the translated page are `GET /api/v1/meta/page/:name` and `GET /api/v1/meta/page`, through `translateMetadataDocument('page', …)`, and the console reads them. What changes for an author: a header whose title differs from the page label, in a locale whose pack carries `label` but no `title`, now shows the authored title. To translate that header, add `pages..title` to the pack (`os i18n extract` offers that key for such a header). No schema, key, accepted value or export changes. The `pages..title` describe and the reference page now state this order. +- d748ae8: A locale pack's page `label` no longer replaces the record-derived heading on a record page whose header authors no title + + Clause-②: no + + On a record page, a `page:header` with no `title` is the sanctioned spelling for "the renderer derives the heading from the record" (`PageHeaderProps.title`). Studio's record-page seed writes exactly that header. `translatePage` used to fill the absent title with the pack's `pages..label`, so the record's name was replaced by the page's static label. This happened in every locale whose pack carries the label, including the `en` skeleton that `os i18n extract` writes. Measured at `GET /api/v1/meta/page/:name` before this change: the header gained `title: "客户记录页"` in zh-CN, and `title: "Account Record"` in en with the extracted skeleton. + + The label now does not fill an untitled root `page:header` when all three of these hold: + + - the page's `type` is `'record'` as served (a document with no `type` is not a record page); + - the page's `object` is a non-empty string; + - the header keeps its record chrome (`properties.recordChrome` is not `false`). + + Everywhere else the label still fills an absent header title, as before. An authored header title follows the existing order, including one that restates the page's `label`. A pack's `pages..title` and `subtitle` apply as before. +- ed1de8c: The migration registry `src/migrations/registry.ts` is generated at build and no longer committed + + Clause-②: no + + `src/migrations/registry.ts` keeps its path and its exports, and the built package carries the same + `./migrations` entry as before. What changes is where the file comes from in this repository: it is + git-ignored and written whole by `gen:migration-registry` from two committed sources, + `src/migrations/registry.ts.template` (the hand-written step rationale, doc comments and table + skeleton) and `src/migrations/entries/` (one file per entry). The generator runs on `pnpm install` + (the package's new `prepare` script), as the first step of `build`, and before `typecheck`, `test` + and `test:repo`; its self-test runs inside every generation. `check:migration-registry` is removed, + since there is no committed copy left to compare. + + For a contributor: add a migration entry as a file under `src/migrations/entries/` and edit any other + registry prose in `registry.ts.template`. An edit to `registry.ts` itself is never committed, and the + next install or build overwrites it. + + Measured with `pnpm pack` against the previous build: 2,070 files each, and 4 differ. Those are + `package.json`'s `scripts` (`build`, `typecheck`, `test` and `test:repo` now start with the generation, + and `check:migration-registry` is gone; `pnpm pack` leaves `prepare` out of the packed manifest) and the + three `.build-input-hash` stamps that record the build's inputs. Every `dist` file, declaration, source + map and JSON Schema is byte-identical. No schema, type, export or runtime behaviour changes. +- 156ddfa: fix(spec): the error-code ledger records `@objectstack/service-analytics` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22634) + + The analytics door now answers the data door's two exposure codes for an object whose `enable` block refuses the aggregate operation, so `ERROR_CODE_LEDGER['@objectstack/service-analytics']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves. +- 5081090: docs(spec): a list view's and a dashboard's `requiredPermissions` is enforced — the describe and the liveness ledger say so (#22639) + + Clause-②: no + + - The shared description of `ListViewSchema.requiredPermissions` and `DashboardSchema.requiredPermissions` drops its "not enforced yet" clause and says what the server does: a user who does not hold every capability 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. + - The liveness ledger rows (`liveness/view.json` `list.requiredPermissions`, `liveness/dashboard.json` `requiredPermissions`) are `live`, citing the `/meta` read gate, and no longer carry `authorWarn`, so `os lint` / `os validate` stop warning `liveness-planned-property` when a view or a dashboard sets the key. + - The schemas themselves do not change: same shape, same accept set. +- b6eb25f: The `submitBehavior.url` docblock in `src/ui/view.zod.ts` no longer names a navigation mechanism + + Clause-②: no + + The docblock above the redirect `url` check said objectui's `FormPage` performs a verbatim + redirect on this value, and that the value reaching `window.location.assign` is the string with + its tokens substituted. Neither is how the Console behaves at the objectui pin this package + builds against (`20c6d351ad74`): it substitutes each `{{record.…}}` token, URL-escapes the + value, and navigates in-app through its router. The docblock now states the ruled contract + only: relative paths only, interpolation only from declared record fields with every value + URL-escaped, and the resolved relative path is the destination. It says that the navigation + mechanism belongs to each consumer, and marks the pre-ruling `FormPage` behaviour as history. + Comment text only: no schema, key, type, export or `.describe()` string changes. +- 165587e: New published package `@objectstack/skills`: the ObjectStack skills catalog (`skills/**` of the repository), shipped in the changeset `fixed` group so it always carries the version of the `@objectstack/*` packages it teaches. Its build copies the catalog into `dist/skills//…` byte for byte (the layout the skills CLI's `experimental_sync` reads from `node_modules`), and `files` lists only that tree. The repository's `skills/**` stays the one source of truth and the documented `next` channel; nothing a consumer writes changes. + + `@objectstack/spec`: `llms.txt`'s package-ecosystem count reads 69 with `@objectstack/skills` joining the scope. +- ea9fd99: `positionForm`: the `permissionSets` row now declares `widget: 'ref-multi:permission'` in place of `type: 'tags'`. ADR-0131 D4 resolves a reference to a declared item by machine name, registry-first, so the row names the permission-set registry (metadata type `permission`) for the designer to offer the declared sets and commit their names, instead of a free-text tag box where a name that does not exist can be typed. + + Clause-②: no + + - **The key.** `ref-multi:` followed by a metadata type is the list form of the forms' `ref:` pickers (`ref:object`, `ref:dataset`, `ref:component`, all single-valued). A distinct key, as `field-ref` / `field-multi` already are, because a single `ref:` picker writes one string and the widget registry declares one labelling per key. + - **Which renderer honours it.** A console whose metadata-admin widget registry has no `ref-multi:permission` entry renders this row as its announced fallback: a JSON editor with the note that no custom renderer is registered. The stored value is the same list of names either way. + - ⛔ Only this one form row changes. No schema, parse, export or accept-set change: `PositionSchema.permissionSets` stays a list of snake_case names, and `FormField.widget` stays a free string. +- e5899a6: `fileAccessDelegate` and the refused file marker now describe what the delegate decides on a record read, beside the download + + Clause-②: no + + Wording only: no key, type, schema shape or runtime behaviour changes. A record read now follows the download door's field-owned verdict for a file the record owns, so the texts an author reads understated what a declared delegate decides. + + - **`fileAccessDelegate`.** This covers the object schema's description and TSDoc, the object form's help text in `en`, `zh-CN`, `ja-JP` and `es-ES`, and the generated reference pages. The named service authorizes downloads of files owned by the object's media fields. It also decides whether a reader who may not read `sys_file` sees those files' name, size and type in a record read of the object. It is asked once per owning record on each such read, so an implementation runs on reads, not only on downloads. A reader who may read `sys_file` never reaches it on a record read. It fails closed on both paths. + - **`FileRefusedValueSchema` (`{ id, metadataRefused: true }`).** The TSDoc and the `metadataRefused` description now give two cases for a reader who may not read `sys_file`: the record being read does not own the file, or the download verdict on the owning record did not allow it. A file the record owns and the verdict allows is served as the full file object. + - **`IFileAccessDelegate`.** The interface's TSDoc and its `authorizeFileRead` doc now name both questions one verdict answers: the download, and whether a reader refused `sys_file` sees the file's name, size and type in a record read (asked once per owning record per such read). The warning now covers metadata too: a permissive implementation leaks the metadata as well as the bytes. + + **For authors.** Nothing to change in metadata. An existing delegate now also decides a refused reader's file metadata on every read. It should keep applying the same rule its service uses to read the record. +- ec7c7e0: fix(spec): the error-code ledger records `@objectstack/metadata-protocol` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22737) + + The generic data door now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, so `ERROR_CODE_LEDGER['@objectstack/metadata-protocol']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves. +- 322ff1e: docs(spec): `RuntimeAuthoringIssueSchema.path` states the name-keying rule for every collection-resident write type, so a `dataset` write's name-keyed paths are no longer described as positional + + Clause-②: no + + The description of `path` on a runtime publish-gate finding (the 422 `issues[]` and the 2xx `advisories[]`) named `object`, `permission` and `book` as the only write types whose top-level collection entry is keyed by name, and called every other write type positional. A `dataset` write's findings have been name-keyed too (for example `datasets.acme_invoice_metrics.dimensions[0].field`), so a consumer that followed the text would have expected `datasets[N]` and never received it. + + The description now states the rule instead of a list: a write type is collection-resident when the gate's per-write snapshot holds the tenant's other stored items of that type beside the written one, and there the top-level entry is keyed by name. Every other write type is the only member of its collection in that snapshot, so its `[0]` stays positional. The set is derived inside `@objectstack/lint`'s runtime gate, and the description names that derivation rather than copying its members. It also states the fallback the gate already applies: an entry whose name cannot be spliced into a dotted path keeps its index. + + Description text only: the schema's shape, accepted values and types are unchanged. The generated `content/docs/references/api/protocol.mdx` is regenerated. +- 581ca7d: fix(spec): the bulk action param's rejection text names the promoted field-backed route instead of saying the bulk surface has none (#22780) + + Clause-②: no + + `BulkActionParamSchema` still refuses `field`, and the widget-config keys, on an authored `bulkActionDefs[].params` entry. An authored param reaches the bulk dialog as written, and nothing on that path resolves it against the object's field definitions. What changed is the reason and the remedy in two rejection messages, which said the bulk surface has no field-backed param route at all. The console now has one. An action the object declares, named in a list view's `bulkActions`, is promoted with its params, and a field-backed param on it (`{ field }`, plus `objectOverride`) is resolved through the single-record dialog's resolver when the bulk dialog opens. + + - **`field`.** The rejection now offers two fixes. Declare the param inline (`name` + `type`, plus `object` and optionally `labelField` for a picker). Or declare the action on the object with the field-backed param and name it in `bulkActions`. The promoted form runs the action once per selected record. + - **Widget-config keys** (`min`, `max`, `step`, `accept`, `lookupFilters` and the rest of that family). The rejection states what the promoted route carries from the field: `type`, `label`, `options`, `required`, `description` (as the help text), `placeholder`, `defaultValue`, `multiple`, `accept` and `maxSize`, plus a reference field's `reference`, `displayField` and `dependsOn`. Of the refused keys, only `accept` and `maxSize` reach the bulk dialog that way. `lookupFilters`, `lookupColumns`, `lookupPageSize` and `descriptionField` are dropped on the bulk path, and the rest are never read off the field. + + Nothing to migrate: the accepted and refused keys are unchanged. Only the text of the two rejection messages, and two source comments, moved. +- 3f0b6f2: `HookSchema.onError` and `HookEvent` now say where an `after*` hook's `abort` rolls the write back, and what it means elsewhere + + Clause-②: no + + Text only: no key, shape, default or enum changes, and every hook that parsed before parses the same. + + - **`onError`** (its TSDoc and `describe()`): `abort` refuses the operation. A blocking hook's abort rolls the write back whenever the write runs inside a unit of work. Those units are a caller's `transaction()`, an atomic batch or cascade, and the unit the write door now opens for a blocking `abort` `after*` hook on an object on the default datasource whose driver declares transactions (`@objectstack/objectql`, same release). On an object on another datasource, or a driver without transactions, an `after*` hook's abort refuses the caller while the row stays stored. + - **`HookEvent`'s JSDoc** listed three ways a write ends up inside a unit of work. It now lists four: the write door's own unit is the new one. +- b1d701b: `security/explain` decides whether a record-grained explanation may say a record exists by the read access of whoever asked for it, not by the explained principal's. + + Clause-②: no + + - **An asker who cannot read the record** gets, for every operation, exactly the answer an id no row carries gets: `record` is `{ recordId, visible: false }` with no `decidedBy`, and every layer's per-record attribution is the missing record's. + - An asker explaining another user (`manage_users`, or a delegated administrator's scope) cannot read a record its own by-id read finds no row for, or refuses (a declared 4xx, such as no read grant on the object). + - A principal explaining itself cannot read a record its own by-id read finds no row for. A refused read does not count, so the explanation shows what that principal's own doors answer: a principal that may edit a record it may not read is told what its edit does. + - Before, a principal explaining itself a `read` of a row its read finds no row for was told the row's verdict and why, and an administrator with no read grant on the object was told any row's story; the by-id write verbs already answered a self-explanation of such a row as missing. + - **An asker who can read the record** gets the explained principal's full record story. For a `read`, that is the principal's own verdict, decider included, so the `controlled_by_parent` read verdict on the master leg (not visible, decided by the `sharing` layer) is what such an asker is told. For a by-id `update`, `delete` or `transfer` of a row the principal cannot read, the verdict stays `visible: false`, the answer that principal's write gets, and now names the layer that decided the principal's read; each row-scoped layer's `record` carries that read's attribution, prefaced with the write's `404 RECORD_NOT_FOUND`. Before, such an asker was told the missing record's answer. + - A system asker reads every record. The batch form (`recordIds`) answers each id as the singular form does. + - `@objectstack/spec`: the `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say which ids an asker cannot read, by asker, and that those answer as missing. Description text only; no schema shape changes. + - Nothing you author changes, and no exported type or signature moves. Object-level explanations (no `recordId`) are unchanged. +- 20028df: fix(spec): the error-code ledger records `@objectstack/runtime` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22823) + + The runtime dispatcher's `callData` now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, on its `query` fallback and its `aggregate` arm, so `ERROR_CODE_LEDGER['@objectstack/runtime']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered. No export, schema or generated artifact moves. +- 69d4218: feat(plugin-approvals): an approval step whose approvers resolve to no one opens on the organization's administrators under the default `onEmptyApprovers: 'admin_rescue'`, as ordinary approvers in their inbox + + Clause-②: yes + + **FROM → TO: who receives an `admin_rescue` request.** A node whose approvers resolve to no concrete person (an unstaffed position, an empty team or department, an unset manager, an expression that yields no one) under the default `onEmptyApprovers: 'admin_rescue'`: + + - **FROM:** the request opened on a slate that named no one, such as the literal `position:sales_manager`. It was in no one's "My Pending", the bell counted 0, no one was told, and a `lockRecord: true` record stayed locked until a platform or tenant admin found it under **All** and decided it through the privileged override. + - **TO:** the request opens on the request organization's administrators: the people who hold the organization-admin capability (`organization_admin` or `organization_admin_no_bypass`, ADR-0095 D3) in that organization. That is the same authority that lets a tenant admin override a request, resolved to the people who hold it, through the same grant resolution every request runs. An organization's owners and admins receive the capability automatically. + + They are ordinary approvers. The request is listed and counted for each of them under their own `approverId`, each is told once by `approval.requested`, out-of-office delegation applies to them, and a decision by one of them is an ordinary approval or rejection, recorded with `via_override: false`. The node's `behavior` reads them as one group. A platform administrator is not added for being one; one who also holds the organization's capability, such as its owner, is on the slate for that. + + The request says why. Its timeline carries a note, for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder.", and its node-config snapshot keeps the abandoned slate and the reason under `__resolvedFrom` (`admin_rescue:abandoned`, `admin_rescue:reason`). The abandoned slate is replaced, not kept beside the administrators, so a holder staffed into the position after the request opened does not receive it; an administrator can reassign it to them. + + **Unchanged.** A slate that names a person; `fallback`, `auto_approve` and `fail`; a `fallback` whose own list resolves to no one still degrades to `admin_rescue`, which now opens on the administrators. The privileged override stays for what the administrators' slate cannot reach: a request whose named approvers have all left after it opened, and an organization with no administrator (or a request with no organization), where the request still opens on the slate that names no one and the server logs a warning. + + **To name the person who should decide instead of the administrators,** declare `onEmptyApprovers: 'fallback'` with a `fallbackApprovers` list. + + **`@objectstack/spec`:** the `onEmptyApprovers` description says that `admin_rescue` opens on the organization's administrators and that `fallback` names who decides instead. + + **`@objectstack/lint`:** `approval-approvers-may-resolve-empty` no longer says the request "waits forever": it says the request opens on the organization's administrators, its remedy names `onEmptyApprovers: 'fallback'` as the way to name who decides, and its `os explain` text says the same. +- 5ce6773: feat(trigger-api)!: the inbound hook door refuses the body-only `x-objectstack-signature` form, so only the timestamped, replay-protected form verifies + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a runtime door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **What narrows, from → to.** + + - `POST /api/v1/automation/hooks/:flowName/:hookId`, through the self-hosted mount and through `ITriggerApiService.handleInboundHook` alike. A post whose `x-objectstack-signature` was the body-only value (`sha256=` and the hex HMAC-SHA256 of the raw body under the flow's secret) was accepted, `202` and enqueued, with a deprecation `warn` line once per armed hook. It now gets the same `401 INVALID_SIGNATURE` every post that does not verify gets, and nothing is enqueued, even when the value is the secret's HMAC of the body. The body-only form signs no time, so it carries no replay protection. + - The one accepted form is the timestamped one: `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock. + - The deprecation line is gone. The refusal's `warn` line carries the cause `signature-body-only`, beside `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` and `signature-outside-window`. It never carries the secret, the signature value or the body. + - `verifySignature(secret, rawBody, header)`, exported by `@objectstack/trigger-api`, answers as the door does: `true` only for the timestamped form inside its window, `false` for a body-only value. Its signature is unchanged. + + **Unchanged: the outbound contract.** `@objectstack/core`'s `verifyHttpSignature` still verifies both forms, and `signHttpBody` still writes the body-only value: the messaging outbox and the flow `http` node sign it for third-party receivers, as the outbound webhook contract publishes. Only this inbound door refuses it. + + **Who sees a difference.** A sender that signs the timestamped form, as the inbound-hook guidance teaches (the `objectstack-automation` skill's Inbound webhook section, `os explain flow-api-trigger-secret-missing`, `signHttpBodyAt` in `@objectstack/core`), sees none. A sender still on the body-only value now gets `401`, and the trigger's `warn` line for its post names `signature-body-only`, which tells an operator its secret is right and its signing form is the retired one. + + **`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock names the timestamped form as the only one accepted and the body-only form as refused. The type is unchanged. +- 3f69471: fix(spec): the `ui-bulk-action-param-unknown-keys-refused` upgrade guidance names the promoted field-backed bulk route instead of saying the bulk surface has none (#22858) + + Clause-②: no + + The major-18 semantic entry `ui-bulk-action-param-unknown-keys-refused`, which `objectstack migrate meta` prints, said two things that stopped being true once the console resolved a promoted bulk action's params through the single-record dialog's resolver. It called `field` / `objectOverride` contracts "the bulk surface does not implement", and it told the reader that declaring a widget-config key on the object's field cannot help because "the bulk surface has no field-backed param route". The entry now uses the wording the `BulkActionParamSchema` rejection messages already use. + + - **`replacement`.** An authored `bulkActionDefs[].params` entry has no field-backed route: it reaches the dialog as written. The `field` rejection offers two fixes: declare the param inline, or declare the action on the object with the field-backed param and name it in the view's `bulkActions`. That second form is the promoted route, whose params are resolved when the bulk dialog opens. Of the widget-config keys, only `accept` / `maxSize` declared on the field reach the dialog, through a promoted action. `carryOver` / `defaultFromRow` / `requiresFeature` are still ACTION-param contracts the bulk surface does not implement. + - **`reason`.** Declaring the key on the field and keeping the authored def still does not reach the dialog. A promoted action is the one route that consults the field. Of the refused keys, only `accept` / `maxSize` arrive that way. `lookupFilters` / `lookupColumns` / `lookupPageSize` / `descriptionField` are resolved and then dropped on the bulk path, and the rest are never read off the field. + + Nothing to migrate: the accepted and refused keys are unchanged. Only the guidance text of one migration entry moved. +- 1badd31: The description of `branding.logo` on an app now states the shape of the one asset it takes: a 1:1 square app icon, SVG preferred, at least 64 px, which each surface scales to the size it needs, with no second size and no wordmark slot. Text only; no schema changes: the key stays an optional string, the accept set is byte-identical, and a non-square value is not refused because a URL string does not carry its ratio. +- 39e93ef: `PageHeaderProps.actions` now says what its list is: the whole set the header draws. An action that declares `record_header` (or `record_more`) in its `locations` but is not named in an authored `page:header`'s `actions` is not drawn in that header, and a header with no `actions` draws none of the object's own actions; a named id whose action declares neither location renders nowhere, and the host's own Edit / Share / Delete chrome is separate from the list. The `.describe()` was "Action IDs to show in header", which read as a filter over the declared placements. Text only: the accepted set of `actions` is unchanged, and no rule or renderer behaviour moves. +- 02bec6c: fix(spec): a page component's `visibleWhen` describe no longer says the renderer binds `data` — the shipping renderer mounts no `data` root there (#23036) + + Clause-②: no + + The `.describe()` on `PageComponentSchema.visibleWhen` said "The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here", and the TSDoc above it said the same. The console's renderer stopped binding `data` on this surface (objectui ruling on the renderer seam's data scope, 2026-09-13): a `data.*` read there now faults, and a faulting `visibleWhen` renders the component, so a gate written from the old text, such as `data.total > 0`, shows the component whether it is true or false. + + The describe now names what the renderer mounts beyond the contract-bound roots (`features` and `os.user`), states that no `data` root is mounted, and says the one declared `data` read on this surface is the PLANNED API binding's (`dataSource: { provider: 'api' }`), once that binding's renderer ships. The `page:tabs` item-level `visibleWhen`, a different evaluator that binds `data` to the record row, is still called out. Nothing an author can write changes: the accept set is the same, and only the description text and the generated reference page move. + + **If you wrote a page component gate on `data.*`:** gate on the page's row as `record.*`, or on page state as `page.`. +- 719183f: The TSDoc on `MetadataConversion.retiredAfter` now says what a retirement stamps under a prerelease package label. Under a stable label `x.y.z` it is the label itself, as before. Under a prerelease label `x.y.z-PRERELEASE` (Changesets pre mode, such as `18.0.0-next.N`) it is the newest published stable release, which sits below `x.y.z`: never the prerelease label, which is not a stable `x.y.z`, and never `x.y.z`, the first stable release that carries the entry retired. The old wording, "the package's version label at the moment the retirement lands", had no value that type-checks under a prerelease label. A stable release published later that does not carry the retirement (a `17.x` maintenance release during the 18 prerelease) still accepted the old shape, so it becomes the value. Text only: `retiredAfter` keeps its `${number}.${number}.${number}` type, and the accepted set does not change. +- e5f9f01: fix(metadata-protocol)!: the metadata save door refuses a `protection` block with a located `400 VALIDATION_ERROR` instead of storing a lock that binds nothing (ADR-0010 §3.5, Phase 4.3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `protection` is the package author's lock declaration. ADR-0010 states one door for it: a code package's loader translates it into the item's private lock envelope when the package loads, and strips it (Phase 4.3). An item saved through the metadata API is authored in the environment, and ADR-0010 §3.5 states that such an item takes no package lock. + + **This entry supersedes one sentence of the entry `15213-unmanaged-lock-strip`, in this same release.** That entry says a malformed `protection` block is not stripped and "the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back". What holds now: on a type whose schema declares `protection`, an item the unmanaged install hands on with its block still in it (a malformed block, or a block beside a `_lock*` value its declaration refuses) meets this entry's door refusal, `400 VALIDATION_ERROR`, and the install is still rolled back. An item carrying a refused `_lock*` value and no block, and a block on a type that does not declare one (`action`, `hook`, `page`), still answer `422 INVALID_METADATA`. Where the two entries disagree, this one is current. + + **FROM.** `PUT /api/v1/meta/{type}/{name}` with a `protection` block answered `200` and stored the block verbatim. The item was then served with `protection.lock: 'full'` while it read `lock: 'none'`, `editable: true` and `deletable: true`, and a second save and a delete both answered `200`. Measured on the showcase for an `app` and a `dashboard`. An author, or an AI, reading that item was told it was protected when anyone holding the authoring capability could change or delete it. + + **TO.** The same save answers `400 VALIDATION_ERROR` and writes nothing. The message names the block and the door that does honour it, and the envelope's `issues[]` carries `protection` as the path, so the Studio designer can highlight it. + + - **Every type that declares the block**, in draft and in publish mode, for a new item and for an overlay of a packaged one. A malformed block is refused the same way, because the block is not accepted at this door at all. On a type whose schema does not declare `protection`, the schema's own `422 INVALID_METADATA` answers, as before. + - **Every authoring write**: the REST and dispatcher saves and the domain doors that save through them. The unmanaged install already drops a block it can read before it saves a copy, and a malformed one is refused here. + - **Server-side rewrites strip it.** A row stored before this change may carry the block. When the server itself rewrites such a row (`os migrate meta --stored --apply`, the stored flow credential move, package duplication), it drops the block instead of failing the rewrite: the block never bound, so nothing changes but the stored bytes. + - **Unchanged.** A body without `protection` saves as before. A packaged item's lock still binds: its loader translated the block at load time, and a save against it still answers `403 ITEM_LOCKED`, or `403 NOT_OVERRIDABLE` on a type with no environment overlay, before this refusal is asked. A row stored before this change keeps its bytes. + - **Package export.** `GET /packages/:id/export` copies a writable base's rows into a manifest, so a block saved into a base row rode into that manifest, where the package loader translates it on install. A block can no longer be saved into a base row. Declare it in the manifest you install instead. + - **Remedy.** Delete the `protection` block from the body you send to the metadata API. To lock an item, declare `protection` on it in a code package's source. + + `@objectstack/spec`: the `protection` TSDoc on every type that mounts it (app, view, dashboard, report, dataset, object, flow, webhook, agent, tool, skill, position, permission set, email template) now says that only the package loader translates the block, and that the metadata API's save door refuses it. +- de70e97: Retirement prescriptions name `os migrate meta --write`: "… to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." + + Clause-②: no + + Wording only: no schema, key, type, export or error code changes, and every input that parsed or was refused before gets the same verdict at the same path. Only the closing sentence of the message changes. + + - A prescription covered by an ADR-0087 conversion used to close with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand." It now closes with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." The default run still only lists. `os migrate meta --write` rewrites in place each edit it can trace to one literal in one project file and lists every other edit with the reason it was not written. The sentence never says the tool rewrites your sources automatically, because `--write` does not write every edit. Text up to and including "for existing sources;" is unchanged, so a caller matching that prefix still matches. + - The three prescriptions for conversions that cover only part of a value (dashboard `compareTo.offset`, the script flow node's `config.actionType` and the package manifest `permissions` case) carry the same `--write` clause before their second clause. + - The `record:chatter` / `record:discussion` `position` value prescriptions (`'sidebar'`, `'inline'`, `'drawer'`) used to tell the author to run a bare `os migrate meta`, which the command refuses because `--from` is missing. They now name `os migrate meta --from 17` and close with the same sentence. + - `@objectstack/lint`: the script node's retired dispatch-key diagnostic and the dataset widget's `chartConfig.xAxis.field` hint close with the new sentence. + - `@objectstack/driver-turso`: the `timeout`, `localPath` and `wasm` config tombstones close with the new sentence. +- 74c4b34: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `088e0f705520`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `e5311165a8d6..088e0f705520`: 54 paths over 3 commits (objectui#12110's rich-text hint lines, objectui#12174 and objectui#12175). Files that three records cite changed on the hop, all for objectui#12175, and every cited line in them held or moved with its text byte-identical: + + - `form.tsx` (+3, registering the `position-ref` / `permission-ref` widget hints): `spanLadderFor` held at `:275-302`, and its one call site moved `:3326` -> `:3329` with the call unchanged. So `FormField.span: 'full'` still takes the whole row at every multi-column tier. + - `data-objectstack/src/index.ts` (+33 at `:5335`, the adapter's `listMetadataItems`): this lands below both reads the curated-public-blocks record cites, so `serializeOrderBy` `869-895` and the `translateFilterArray` arm `5066-5077` are unmoved. + - The `en` / `zh` / `de` packs (`fields.registryRef` keys, +4 each): the calendar record's `calendar.configRequired` strings moved by +4 and did not change. + + Every other record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `e5311165a8d6` number. The corpus is now 8519 tracked files, and every retired name each entry counts still reads 0. The lit controls moved with the corpus, for example `objectstack` from 18355 to 18400 and `useState` from 2659 to 2661. + + No key, default, enum member or export moves. +- e040b8c: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `0df67f237c5a`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `20c6d351ad74..0df67f237c5a`: 293 paths over 21 commits, none deleted and one renamed (`resolveActionParams.ts`, which no record cites). Seventeen files a record cites changed on the hop, plus the `en` / `zh` / `de` packs (whose `calendar.configRequired` strings did not change): `ObjectGrid.tsx`, `ListView.tsx`, `ObjectCalendar.tsx`, `plugin-calendar/src/index.tsx`, `ObjectTimeline.tsx` and plugin-timeline's `renderer.tsx`, `ObjectKanban.tsx`, `KanbanBoardCore.tsx`, `KanbanImpl.tsx`, `plugin-kanban.mdx`, `MePermissionsProvider.tsx`, `PermissionProvider.tsx`, `record-details.tsx`, `basic/data-list.tsx`, `data-objectstack/src/index.ts`, `form.tsx` and plugin-view's `ObjectView.tsx`. Every cited line in them moved or held with its text byte-identical, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. + + Three readings changed around a cited read, and each hop sentence says what it found: + + - `basic/data-list.tsx`: the `element:repeater` registration no longer publishes the flat `object` / `filter` / `sort` / `limit` inputs; the node-level `dataSource` binding is its one published query input (objectui#12085). The renderer still reads the four as the binding's fallback. The row's two sentences that named the published inputs are rewritten where they stand. + - `ObjectGrid.tsx`: the `delete` verdict the implicit bulk-delete is gated on now comes from the affordance-to-grant map's `rowDelete` row (objectui#12082). It still asks the managed-object policy and the object's effective API operations, now with the caller's delete grant beside them, so the `sys_api_key` reading holds. The `inlineEditable` verdict reads the same map's `listInlineEdit` row. + - `ListView.tsx`: the calendar arm gained objectui#12081's fetch window. Its date-field reads are byte-identical and still declared-only, with no floor. + + The re-read also corrects two anchors the previous hop left on the `object-tree` flat-config record: its opening still named the `case 'tree'` arm at its `47b1f0bb7` span, and its hop cited the `labelField` line one line low. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `20c6d351ad74` number. The corpus is now 8424 tracked files. Every token an entry counts as zero still reads zero (98 tokens). The controls moved with the corpus, for example `objectstack` from 18047 to 18248 and `timeout` from 1694 to 1705. + + No key, default, enum member or export moves. +- f451a59: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `1fb1ced36243`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `7578323a544e..1fb1ced36243`: 116 paths over 11 commits. One set of cited files changed on the hop, and every cited line in it held its text: + + - The `en` / `zh` / `de` packs (two commits each: objectui#12248, objectui `51c661e38`, and objectui#12268, objectui `7af0ab378`) gained keys below line 1100 only (`en.ts` +9, `zh.ts` +6, `de.ts` +6). Their `calendar.configRequired` strings did not change and held at `en.ts` `:953`, `zh.ts` `:726` and `de.ts` `:706`. The functional-completeness `calendar` row says so. + + Every other asserting record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. That covers `form.tsx`, `autoLayout.ts` and `field-type-alias.ts`, so `FormField.span: 'full'` still takes the whole row at every multi-column tier, and `ObjectGrid.tsx` and `data-table.tsx`, so the `keyboardNavigation` read still finds 15 hit lines against 3 for the control (no line of the hop's diff names `keyboardNavigation`). + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `7578323a544e` number. The corpus is now 8642 tracked files, and every retired name each entry counts still reads 0 (a superset of 99 names: the 13 lifecycle, 31 metrics and 37 tracing exports, and 18 key names the entries retire or count; the unrelated `Span` 519 and `SpanSchema` 57 are unchanged). The lit controls moved with the corpus, for example `objectstack` from 18552 to 18601, `timeout` from 1686 to 1700 and `tenant` from 1404 to 1415. + + No key, default, enum member or export moves. +- ba95607: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `20c6d351ad74`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `47b1f0bb7174..20c6d351ad74`: 320 paths over 24 commits, 2 of them deleted test files, none renamed. Ten files a record cites changed on the hop, plus the `en` / `zh` / `de` packs: `ListView.tsx` and `ObjectTimeline.tsx` (objectui#6152 rounds 14 and 15), plugin-view's `ObjectView.tsx`, `objectql.ts` and `objectql.zod.ts` (the same rounds and objectui#12063), `KanbanImpl.tsx` and `plugin-grid/src/index.tsx` (objectui#6349 batch 7), `ActionRunner.ts` (objectui#6349 batch 4), `data-objectstack/src/index.ts` (objectui#5144) and `previews/block-config.ts` (objectui#12056). Every cited line in them moved or held with its text byte-identical, except in the four readings below, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. + + Three readings changed around a cited read, and each hop sentence says what it found: + + - The calendar, gantt and tree arms of `ListView.tsx` changed inside (objectui `3fd862510` retired their raw block spreads, the calendar's `defaultView` lift and the tree's `titleField` label rung). The calendar and gantt rows of `functional-completeness.ts` still hold: their date bindings stay conditional, with no floor. + - `previews/block-config.ts` now writes the repeater's object and limit into the node-level `dataSource` (objectui#12056); the two inspectors span `311-351`. + - `ObjectTimeline.tsx`'s start-date chain lost its nested `timeline.dateField` rung; it still ends without a literal rung, and the component's `schema.*` read set is unchanged. + + The fourth reading: one record names a read that is gone at this pin. The `OBJECT_TREE_FLAT_CONFIG_GUIDANCE` docblock keeps `titleField` in the flat tree key set because `ListView`'s flatten resolved `treeCfg.titleField` into `labelField`. objectui `3fd862510` removed that rung. The hop sentence records it; the paragraph and the key set are left as they are, because changing the key set is a schema decision this re-read does not make. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `47b1f0bb7174` number. The corpus is now 8351 tracked files. Every token an entry counts as zero still reads zero (97 tokens). The controls moved with the corpus, for example `objectstack` from 17980 to 18047 and `timeout` from 1674 to 1694. + + No key, default, enum member or export moves. +- b53b949: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `47b1f0bb7174`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `f0268ad78485..47b1f0bb7174`: 129 paths over 14 commits, none deleted or renamed. Two files a record cites changed on the hop, each for objectui#11865 (a picker drawn with the shared `Select`): `ObjectGrid.tsx` gained one import line and redrew its grouped pager's rows-per-page picker, and `ListView.tsx` redrew its "Color by field" and rows-per-page pickers. Every cited line in those two files moved with its text byte-identical, so the nine records that cite them are re-pointed, and each hop sentence says by how much. The other 34 records cite only files that are byte-identical across the hop, and each gains a hop sentence that says so. + + No record says anything false at the new pin, so no reading is rewritten. + + The `FormField.span` describe changes its sha only: `WIDE_FIELD_TYPES`, the field-type alias table and `spanLadderFor` are byte-identical at the new pin. The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `f0268ad78485` number. The corpus is now 8281 tracked files. Every token an entry counts as zero still reads zero: none of them occurs on a line the hop adds or removes. The controls moved with the corpus, for example `objectstack` from 17956 to 17980 and `timeout` from 1658 to 1674. + + No key, default, enum member or export moves. +- ea9fd99: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `4997995022f8`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `0df67f237c5a..4997995022f8`: 80 paths over 7 commits, none deleted and one renamed (a schema-catalog example, which no record cites). Three files a record cites changed on the hop, plus the `en` / `zh` / `de` packs (whose `calendar.configRequired` strings did not change): `ObjectGrid.tsx` (objectui#12081 item 3 and objectui#12082), `ObjectGantt.tsx` (objectui#12082) and `form.tsx` (objectui#12121). Every cited line in them moved or held with its text byte-identical, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. + + Two readings sit beside a cited read, and each hop sentence says what it found: + + - `ObjectGrid.tsx`: a bulk `update` def is now withheld unless the edit verdict allows it (objectui#12082), which narrows the declared defs and adds no implicit one, so the `sys_api_key` reading holds. The selection block still hashes to `c88443302d40`. + - `ObjectGantt.tsx`: the chart's in-chart write handlers are withheld from a caller without the grant (objectui#12082). No `schema.*` read moved; the renderer reads the same 49 names at both pins. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `0df67f237c5a` number. The corpus is now 8441 tracked files. Every token an entry counts as zero still reads zero (96 tokens; `Span` and `SpanSchema` read 517 and 57 at both pins, as the tracing entry records). The controls moved with the corpus, for example `objectstack` from 18248 to 18271 and `timeout` from 1705 to 1704. + + No key, default, enum member or export moves. +- 1e44436: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `7578323a544e`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `9646e2ed2bf3..7578323a544e`: 288 paths over 25 commits. Three files that records cite changed on the hop, and every cited line in them held its text; where it moved, it moved byte-identical and is re-pointed: + + - `plugin-list/src/ListView.tsx` (+34/-79, objectui#12217, objectui `f5b66bf97`: an empty gallery draws the grid empty state). One import line was added at `:16` and a four-line docblock near `:1040`; the other hunks change lines in place (`:10`, `:12`, `:1044`) or sit at `:5677` and below. So `resolveListMapConfig` moved `:209` -> `:210`, `FLAT_MAP_CONFIG_SPELLING` `:95` -> `:96` and `resolveTimelineDateBinding` `695` -> `696`. The `kanban` / `calendar` / `timeline` / `gantt` / `map` / `tree` arms moved by five, to `3767` / `3818` / `3907` / `3964` / `4084` / `4125-4149`. The functional-completeness `calendar`, `gantt`, `timeline` and `map` rows, the `object-gantt` and flat-`TreeConfig` records and the map-config record say so, and the tree record's `4120-4144` and the map-config record's two quoted anchors are re-pointed. The `kanban` row-cap record names `ListView.tsx` and `ObjectView.tsx` only as an earlier re-read's record, and its sentence says so. + - `core/src/actions/ActionRunner.ts` (+39/-23, objectui#12191, objectui `334f0cff9`): eleven import lines were added above `composeSuccessMessage`, which moved `1498` -> `1509` with its body byte-identical, and every other hunk lies below it. The `outcomeMessages` record in `action.zod.ts` re-points its anchor, and it and the header of `action-outcome-messages.test.ts` say so. The four action renderers are byte-identical. + - The `en` / `zh` / `de` packs (five commits each): the `calendar.configRequired` strings did not change and moved `en.ts` `:949` -> `:953`, `zh.ts` `:725` -> `:726` and `de.ts` `:705` -> `:706`. + + Every other record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. That covers `form.tsx`, so `FormField.span: 'full'` still takes the whole row at every multi-column tier. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `9646e2ed2bf3` number. The corpus is now 8600 tracked files, and every retired name each entry counts still reads 0. The lit controls moved with the corpus, for example `objectstack` from 18438 to 18552, `timeout` from 1707 to 1686 and `tenant` from 1393 to 1404. + + No key, default, enum member or export moves. +- 2acff87: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `9646e2ed2bf3`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `088e0f705520..9646e2ed2bf3`: 64 paths over 9 commits. One file that any record cites changed on the hop: `plugin-list/src/ListView.tsx` (+9/-9, objectui#12189, objectui `26b0bd2d1`, which renames the FilterBuilder group type to `FilterBuilderRowGroup`). All nine lines were replaced in place, the last at `:1558`, so no line in the file moved and no cited range contains a changed line: + + - the `kanban` / `calendar` / `gantt` / `timeline` / `map` / `tree` arms of its per-type props builder held at `3762` / `3813` / `3959` / `3902` / `4079` / `4120-4144`; + - `resolveListMapConfig` (`:209-214`) and `resolveTimelineDateBinding` (`:695-699`) held, and each ends before the next changed line below it (`:324`, `:756`). + + That file is cited by the four view-type rows of the functional-completeness table (`calendar`, `gantt`, `timeline`, `map`) and by the `object-gantt` and flat-`TreeConfig` records. Each of those gains a hop sentence naming it. The `kanban` row-cap record names `ListView.tsx` only as an earlier re-read's record, and its sentence says so. + + Every other record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. That covers `form.tsx`, so `FormField.span: 'full'` still takes the whole row at every multi-column tier. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `088e0f705520` number. The corpus is now 8540 tracked files, and every retired name each entry counts still reads 0. The lit controls moved with the corpus, for example `objectstack` from 18400 to 18438, `tenant` from 1365 to 1393 and `useState` from 2661 to 2662. + + No key, default, enum member or export moves. +- 299a2c6: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `a58626c88dc8`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `0abd4f9f8769..a58626c88dc8`, 252 paths over 36 commits. Fifteen of those paths are files an asserting record cites: `ObjectKanban.tsx`, `KanbanImpl.tsx`, `ObjectTree.tsx`, `ObjectTimeline.tsx`, `record-details.tsx`, `action-group.tsx`, `action-menu.tsx`, `static-params.ts`, `MetricWidget.tsx`, `MetricCard.tsx`, `form.tsx`, `plugin-kanban.mdx` and the `en` / `zh` / `de` packs. In each, every cited line is byte-identical at the new pin, so each anchor that moved was re-pointed to its new line and the record says by how much; no read point an asserting record cites changed content or died. Every other cited file is byte-identical across the hop (`git diff --quiet`). The seven quoted anchor lines verify against objectui at the new pin. Three records carry a count, and each count was re-taken by its record's own method with the same reading: the `keyboardNavigation` hit lines (15, against 3 for the `schema.editable` control), `ObjectKanban.tsx`'s `quickAdd` / `onQuickAdd` (2 each, against 11 for `onCardClick`), and the `ElementDataSourceGate` occurrences in five `src/index.tsx` shells (0, 3, 3, 3 and 4). + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that first reproduced every `0abd4f9f8769` number. The corpus is now 7754 tracked files. All 99 checked tokens (the export lists of `plugin-lifecycle-advanced.zod.ts`, `tracing.zod.ts` and `metrics.zod.ts`, plus every named key) still read zero, except `Span` / `SpanSchema`, which read 509 / 57: the one new `Span` hit is a `colSpan`. + + No key, default, enum member or export moves. +- e23eccb: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `e5311165a8d6`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `4997995022f8..e5311165a8d6`: 287 paths over 24 commits, four deleted (`StudioHomePage.tsx`, its two tests and `permission-set-clone-dispatch.ts`, none of which a record cites) and none renamed. Five files a record cites changed on the hop, plus the `en` / `zh` / `de` packs (whose `calendar.configRequired` strings did not change): `ObjectGrid.tsx` (objectui#12110 and objectui#12143), `data-table.tsx` (objectui#12124 and objectui#12145), `form.tsx` (objectui#12110, objectui#12130 and objectui#12155), `record-related-list.tsx` (objectui#12082) and `data-objectstack/src/index.ts` (objectui#12112). Every cited line in them moved or held with its text byte-identical, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. + + Two readings sit beside a cited read, and each hop sentence says what it found: + + - `record-related-list.tsx`: the list's fallback remove now reads the affordance-to-grant map's `relatedRowDelete` row (objectui#12082). The hunks sit above and below the `requiredPermissions` gate; the gate and the related object's read gate are byte-identical, 17 lines down. + - `form.tsx`: the built-in branches now settle the widget a field hint renders and the props they hand their control (objectui#12110, objectui#12155, objectui#12130). `spanLadderFor` and its one call site are byte-identical, so the `span` describe's reading holds. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `4997995022f8` number. The corpus is now 8506 tracked files. Every token an entry counts as zero still reads zero: 124 candidate tokens (every export of the four spec files the entries name, and every key they name) read zero at both pins. `Span` reads 519 against 517, both new hits `colSpan`, and `SpanSchema` reads 57 at both pins, as the tracing entry records. The controls moved with the corpus, for example `objectstack` from 18271 to 18355 and `timeout` from 1704 to 1706. + + No key, default, enum member or export moves. +- bf492c8: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `f0268ad78485`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `a58626c88dc8..f0268ad78485`, 1061 paths over 170 commits. Where a cited line moved with its text byte-identical, the record is re-pointed and its new hop sentence says by how much. Where objectui rewrote a cited line around the same read, the range is re-read and the hop sentence says so. + + Three records said something that is false at the new pin, and only those sentences are rewritten: + + - `element:repeater` reads the node-level `dataSource` binding first since objectui#11880 (objectui `aaba8655a`), with its flat `object` / `filter` / `sort` / `limit` keys as the fallback. The row's docblock said the binding was not read. It now states the precedence the renderer applies, and that a node naming its object only through `dataSource` renders while this row still requires `object`: that requirement can follow the renderer only by a schema change, which is objectstack#11509's. + - `action:button` now publishes `undoable` and hands the runner the record in scope as its Undo baseline (objectui#11168, objectui `f0496bdf1`). The row's docblock said the input stayed unpublished because the block wrote no row stash. + - `action:icon` reads `label` six times, not five: the tooltip of a predicate-disabled icon takes it as its heading (objectui#11839, objectui `cef0eeec0`). + + The `FormField.span` describe changes its sha only: `WIDE_FIELD_TYPES`, the field-type alias table and `spanLadderFor` are byte-identical at the new pin. The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that first reproduced every `a58626c88dc8` number. The corpus is now 8234 tracked files. All 99 checked tokens still read zero, except `Span` / `SpanSchema`, which read 517 / 57: the eight new `Span` hits are each a `colSpan`. + + No key, default, enum member or export moves. + ## 17.7.0 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 9eba400e99c..73244d5e854 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.7.0", + "version": "18.0.0-next.0", "description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md index e1ec6b43ea0..98767fdc4be 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,256 @@ # @objectstack/trigger-api +## 18.0.0-next.0 + +### Major Changes + +- 5ce6773: feat(trigger-api)!: the inbound hook door refuses the body-only `x-objectstack-signature` form, so only the timestamped, replay-protected form verifies + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a runtime door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **What narrows, from → to.** + + - `POST /api/v1/automation/hooks/:flowName/:hookId`, through the self-hosted mount and through `ITriggerApiService.handleInboundHook` alike. A post whose `x-objectstack-signature` was the body-only value (`sha256=` and the hex HMAC-SHA256 of the raw body under the flow's secret) was accepted, `202` and enqueued, with a deprecation `warn` line once per armed hook. It now gets the same `401 INVALID_SIGNATURE` every post that does not verify gets, and nothing is enqueued, even when the value is the secret's HMAC of the body. The body-only form signs no time, so it carries no replay protection. + - The one accepted form is the timestamped one: `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock. + - The deprecation line is gone. The refusal's `warn` line carries the cause `signature-body-only`, beside `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` and `signature-outside-window`. It never carries the secret, the signature value or the body. + - `verifySignature(secret, rawBody, header)`, exported by `@objectstack/trigger-api`, answers as the door does: `true` only for the timestamped form inside its window, `false` for a body-only value. Its signature is unchanged. + + **Unchanged: the outbound contract.** `@objectstack/core`'s `verifyHttpSignature` still verifies both forms, and `signHttpBody` still writes the body-only value: the messaging outbox and the flow `http` node sign it for third-party receivers, as the outbound webhook contract publishes. Only this inbound door refuses it. + + **Who sees a difference.** A sender that signs the timestamped form, as the inbound-hook guidance teaches (the `objectstack-automation` skill's Inbound webhook section, `os explain flow-api-trigger-secret-missing`, `signHttpBodyAt` in `@objectstack/core`), sees none. A sender still on the body-only value now gets `401`, and the trigger's `warn` line for its post names `signature-body-only`, which tells an operator its secret is right and its signing form is the retired one. + + **`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock names the timestamped form as the only one accepted and the body-only form as refused. The type is unchanged. + +### Minor Changes + +- 7b0a9c9: feat(core,trigger-api): a timestamped `X-Objectstack-Signature` form with a five-minute tolerance window, accepted by the inbound flow hook beside the body-only form + + Clause-②: yes + + - **The new form.** `x-objectstack-signature: t=,v1=`, where the hex is the lowercase HMAC-SHA256 of `.` under the flow's secret. The timestamp is inside the signed material, so it cannot be changed without the secret. The inbound hook (`POST /api/v1/automation/hooks/:flowName/:hookId`) refuses the value once `t` is more than 300 seconds from the server's clock, in either direction, so a captured post stops verifying five minutes after it was signed. + - **Refusals.** A value that starts with `t=` is judged by the timestamped form alone. If it is malformed, carries the wrong HMAC, carries the body-only HMAC, or has a `t` outside the window, the answer is the same `401 INVALID_SIGNATURE` a bad signature gets, and nothing is enqueued. + - **Body-only signatures keep working.** `sha256=` is still accepted, so no existing sender breaks. It signs no time, so a captured post can be replayed, and it is now deprecated: the trigger logs one `warn` line per armed hook that names the flow and the hook, the first time that hook accepts a body-only post. + - **`@objectstack/core` exports.** `signHttpBodyAt(body, secret, timestampSeconds)` signs the new form. `verifyHttpSignature(body, secret, header)` verifies both forms and returns an `HttpSignatureVerdict` (`{ valid: true, scheme: 'v1' | 'body-only' }`, or `{ valid: false, reason }`). `HTTP_SIGNATURE_TOLERANCE_SECONDS` is `300`. They sit beside `signHttpBody` and `HTTP_SIGNATURE_HEADER` in the one definition of the scheme. `@objectstack/trigger-api`'s `verifySignature` keeps its signature and now verifies through it, so it accepts both forms. + - **Unchanged.** ObjectStack's own senders, the messaging HTTP outbox (webhooks) and the flow `http` node, still send the body-only form. Receivers outside ObjectStack verify exactly that today. + + **To adopt it as a sender.** Sign at send time, not at enqueue: `t = Math.floor(Date.now() / 1000)`, then send `t=,v1=.">` in `x-objectstack-signature`. From TypeScript, call `signHttpBodyAt(rawBody, secret, t)`. +- 75b6d6a: feat(trigger-api): the `trigger-api` service serves the inbound hook from a web-standard `Request` (`ITriggerApiService.handleInboundHook`), and the self-hosted route is found under `http.server` too (#22774) + + Clause-②: yes + + - **What is new.** Wherever `ApiTriggerPlugin` installs its `api` trigger, it now registers the `trigger-api` service and implements its optional `ITriggerApiService.handleInboundHook(request, hook)` member. That member is the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`, taking a `Request` and returning a `Response`, with no raw app, no Hono context and no `http.server`. A hosted tenant kernel owns no socket, so this is how the door reaches it. Its one caller is the runtime HTTP dispatcher's exact `POST /automation/hooks/:flowName/:hookId` domain, which lands separately. The flow name and the hook id come from `hook`, the route the caller matched, and are never parsed again from the URL. The body is read once, from the request itself, and verified as the sender's own bytes. + - **One door, two faces.** The member and the self-hosted route run the same code: the same hook match, the same per-hook secret, read at that moment, the same constant-time verification of `x-objectstack-signature` (the timestamped form and the deprecated body-only form alike), done before anything is enqueued, and the same answer for every outcome. For the same request they answer the same status, the same `Content-Type: application/json` and the same bytes: `401 INVALID_SIGNATURE` for every post that does not verify, `503 SERVICE_UNAVAILABLE` when the hook's secret cannot be read or no queue service is registered, `400 INVALID_REQUEST` for a body that is not a JSON object, `503 ENQUEUE_FAILED`, or `202` with `{ accepted: true, messageId }`. + - **No trigger, no service.** Where the automation service is absent, no trigger is installed, so the `trigger-api` slot stays empty (as no route is mounted). The member's caller then answers its typed absence. The boot `warn` line says so. + - **The route is found under `http.server` too.** The plugin reads `http.server` first, then the deprecated `http-server` alias. A host that registers its server only as `http.server` (the runtime's `config.server` path) now gets the self-hosted route. One that registers a single server under both names still gets it once. Where the two names hold two servers, the route goes on the `http.server` one. + - **The self-hosted route is otherwise unchanged.** Where it was mounted before, it answers the same status, headers and bytes for the same request, and the path is never mounted twice. +- efcbac7: fix(trigger-api,spec): every inbound hook post that does not verify gets one answer, `401 INVALID_SIGNATURE`, whatever its flow and hook id + + Clause-②: yes + + - **FROM → TO.** On `POST /api/v1/automation/hooks/:flowName/:hookId`, a post that named no armed flow, or the wrong hook id, answered `404 RESOURCE_NOT_FOUND`, while a post naming an armed flow and its hook id with an absent or bad `x-objectstack-signature` answered `401 INVALID_SIGNATURE`. Now all four answer `401 INVALID_SIGNATURE` with the same body, so the answer no longer tells an armed flow name from an unarmed one, whichever hook id a flow is armed with. + - **Who sees a difference.** A correctly configured sender (the flow's URL, its hook id, its secret) sees none: it still gets `202`. A sender posting to a flow that is not armed, or with a hook id that was rotated away, now gets `401` where it got `404`. A sender with a wrong secret or signing code still gets `401`, as before. + - **The cause is in the server log.** The trigger logs one `warn` line per refused post, with the cause and the flow name the post carried as structured fields: `cause` is one of `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` or `signature-outside-window`. The line never carries the secret, the signature value or the body. + - **Unchanged: the `503` residual.** A post that names an armed flow and its hook id while that hook's secret cannot be read still answers `503 SERVICE_UNAVAILABLE`, signed or not, without verifying. It stays a `503` on purpose: it is the deployment's fault, a sender retries it, and it lasts only while the secret cannot be read. A wrong hook id never reaches the secret read, so it gets the uniform `401` even then. + - **`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock states the one refusal and names the `503` as its residual. The type is unchanged. + + **To adopt it.** A sender or monitor that read `404` from this route as "wrong URL" can no longer tell that from a bad signature by the answer: read the trigger's `warn` line for the post instead, whose `cause` names which one it was. + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 380a808f435..407a77a34a4 100644 --- a/packages/triggers/trigger-api/package.json +++ b/packages/triggers/trigger-api/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-api", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Inbound HTTP/webhook flow trigger for ObjectStack — per-flow HMAC-verified endpoints with queue-backed ingestion (ADR-0041)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-record-change/CHANGELOG.md b/packages/triggers/trigger-record-change/CHANGELOG.md index 2143e70430d..0c20a2968ea 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,199 @@ # @objectstack/plugin-trigger-record-change +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index c949f625626..47c7aba6c74 100644 --- a/packages/triggers/trigger-record-change/package.json +++ b/packages/triggers/trigger-record-change/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-record-change", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Record-change flow trigger for ObjectStack — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks (ADR-0018)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-schedule/CHANGELOG.md b/packages/triggers/trigger-schedule/CHANGELOG.md index 5e3bf460d9a..94072983226 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,212 @@ # @objectstack/plugin-trigger-schedule +## 18.0.0-next.0 + +### Patch Changes + +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/metadata-core@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 30182b6137d..b5fadcac341 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-schedule", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Schedule flow trigger for ObjectStack \u2014 auto-launches flows on a cron/interval/once schedule via the IJobService (ADR-0018)", "main": "dist/index.js", diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index 3b488d8d6a9..6864fbebb5f 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,242 @@ # @objectstack/types +## 18.0.0-next.0 + +### Minor Changes + +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. +- 8f2e808: feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data + + Clause-②: yes (narrowing) + + **BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. + + ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. + + - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. + - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. + - **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. + - **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. + + +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- 6d5191e: An import row for the sandbox's own fault reads the create door's fault sentence, not the runner's diagnostic text (#22741) + + Clause-②: yes (widening) + + The QuickJS runner raises some `SandboxError`s itself: a hook or action body that runs past its CPU budget or past the wall-clock ceiling, calls a capability it was not granted, or meets a failed install or marshal. These carry no `innerMessage`. `POST /api/v1/data/:object` and `/createMany` answer them `500 INTERNAL_ERROR` `Internal server error`. The sync `/import` row and the `/import/jobs` stored row answered the runner's text instead, for example `hook 'guard' exceeded CPU budget of 50ms (after 3 pump iterations)`. + + - **`@objectstack/types`** exports `isSandboxOwnFault(error)`. It is `true` for an error whose `name` is `'SandboxError'` and whose `innerMessage` is not a non-empty string, which is how the runner marks the faults it raises itself. A body's refusal or crash carries `innerMessage` and answers `false`. + - **`@objectstack/core`**'s import row applies the rule a crashed body already gets: when the sandbox raised the error and the create door answers it with a 5xx, the row's `error` is the door's sentence. The row's `code` is unchanged (`IMPORT_ROW_FAILED`). A driver error, and an error a code-defined hook threw, keep the row text they had. + +### Patch Changes + +- ec7c7e0: fix(types): a thrown exposure refusal about a second object names that object on the REST body (#22737) + + The shared REST error classification gains one arm for `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED`. Both REST error doors ask it: `mapDataError`, and `resolveErrorResponse` behind `sendThrownError`. + + When the thrown error names an object other than the one the route addressed, the body's `object` is that object, and a `405` carries the effective operation set as `allowed`. The generic data door throws this when a nested-relation condition's related object is not served by the API. The new body is the same one the REST door already sends for these codes about the addressed object. Before, the body named the route's object beside a code saying that object was switched off. + + An error naming the route's own object, or a door given no route object, answers exactly as before. No code is added. +- 8bc7836: fix(types): the data door answers the sandbox's own fault as a fault, not as "this object does not exist" (#22868) + + Clause-②: no + + **What was wrong.** The QuickJS runner raises some `SandboxError`s itself. These carry no `innerMessage`. Two of them name the object the hook body addressed: a denied capability on `ctx.api.object(...)` (`capability 'api.read' not granted to hook 'guard' (called ctx.api.object('account').find)`) and a `ctx.api` method the host does not implement (`ctx.api.object('account').find not implemented`). When that object was the one the request named, `mapDataError` read the quoted object name plus the word `not` as an unknown object. `POST /api/v1/data/account` then answered `404 OBJECT_NOT_FOUND` with `Object 'account' is not registered`. Every other runner fault already answered `500 INTERNAL_ERROR`, which is what the runner's contract says these errors are. + + **What changes.** `mapDataError` checks `isSandboxOwnFault` first, next to the existing check for a sandboxed body that crashed. An error the sandbox raised itself now answers `500 INTERNAL_ERROR` with `Internal server error`, whatever its text names. The runner's text goes to the server log only. The sync `/import` row and the `/import/jobs` stored row read that same sentence. + + **Unchanged.** A driver's unknown-object text (`no such table: account`, `relation "account" does not exist`) still answers `404 OBJECT_NOT_FOUND`. So does the same wording thrown by anything other than the sandbox: the check reads the error's producer, not its words. A hook body's refusal keeps its sentence and status, and a crash keeps its `500`. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [e5f9f01] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0-next.0 + ## 17.7.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 0e073172c85..37aace6f219 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Shared interfaces describing the ObjectStack Runtime environment", "main": "dist/index.js", diff --git a/packages/verify/CHANGELOG.md b/packages/verify/CHANGELOG.md index ee85359dc62..0e13e2b5b17 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,783 @@ # @objectstack/verify +## 18.0.0-next.0 + +### Minor Changes + +- 34dba5a: `bootStack` boots the production `single` shape: the Default Organization exists from the boot, every sign-up is its member, and the harness admin is its owner + + Clause-②: yes (narrowing) + + + + **BREAKING** for fixtures that relied on an organization-less `single` boot, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D11). + + - **What `bootStack` boots now.** Under `single`, `@objectstack/plugin-auth` creates the Default Organization before the seeds load, so every boot has one, and `bootStack` no longer turns off the platform admin's owner bind. The harness admin is the Default Organization's `owner`, every user a fixture signs up is its `member`, and their sessions carry it as the active organization. This is the shape `objectstack dev` and `objectstack serve` boot. Before, `bootStack` pinned `autoDefaultOrganization: false` and booted a `single` stack with no organization at all, a shape no deployment runs after this release. + - **`orgContext` asserts, it no longer switches.** `orgContext: true` keeps its refusal: the boot fails when the harness admin holds no membership, and it still refuses to compose with `multiTenant`. The bind itself happens on every boot. + - **Unchanged.** `multiTenant` boots a walled posture as before, and the open default-organization bootstrap still abstains under it. + + **The remedy.** A fixture that needs a principal outside the organization's `org_member` domain removes that user's membership (an administrator's act a real deployment performs) instead of booting without an organization. A fixture that minted its own `slug: 'default'` organization reads the one the boot created. A fixture that ran on `databaseDriver: 'memory'` and signs a user in meets the memory driver's tenant-scope refusal (`503`) until ADR-0131 C8; run that leg on the SQL in-memory driver. +- aa71c4d: `sys_user_permission_set` gains `permission_set`, the name of the permission set a grant holds, written beside `permission_set_id` (ADR-0131 D4) + + Clause-②: yes (widening) + + - **The column.** `permission_set` is a read-only text column, at most 100 characters, holding the `name` of the `sys_permission_set` row that `permission_set_id` points at. It is readable everywhere the grant row is readable. A grant written before this release has `NULL` here until the backfill stage rewrites it. No reader uses the column yet: the grant is still resolved from `permission_set_id`, which stays until it is dropped in a later major (ADR-0131 D10). + - **The platform writes it, on every write that carries `permission_set_id`, for every caller.** Two `@objectstack/plugin-security` engine hooks (`beforeInsert` and `beforeUpdate` on `sys_user_permission_set`) look up the set by id and store its name. A write that sends only the id, which is how the data door and the Setup forms write, gets the name filled in. + - **A name that names a different set is refused** with `400 VALIDATION_FAILED`, `invalid_value` at `permission_set`. This covers a name that disagrees with the id written beside it, or with the id already stored when only the name is written. For a non-system caller it also covers a name beside an id that names no set this caller's organization can see. A name that agrees is accepted. A cleared name (`null`) is not stored as a clear: the derived name is written back. Before this change the column did not exist, so a write naming it was refused with `400 INVALID_FIELD`. No write that was accepted before is refused now. + - **Every platform grant writer writes both columns:** the organization-admin reconcile and the platform-admin promotion in `@objectstack/plugin-security`, the self-registration grant in `@objectstack/plugin-auth`, and the RLS probe persona in `@objectstack/verify`. + - **Nothing to migrate.** No principal's grants change. To fill the column on grants written by your own code, write the set's name as `permission_set`, or leave it out and the platform fills it in. Do not write any other value there. +- 54f062b: feat(verify): the dashboard date-window family — every widget a dashboard's `dateRange` binds counts, under each preset of the picker, the rows that window selects, runnable as `os verify --dashboard-windows` (#15953) + + Clause-②: yes + + A seventh derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs`, `runUniqueScopeProofs` and `runParentWriteReachProofs`. It needs no hand-written test: from the app's own dashboards and datasets, read through the spec's own parse (`DashboardSchema`, `DatasetSchema`), it takes every dashboard that declares a `dateRange` and every widget that window binds, and asks the dataset door what each one counts. + + Where each half of the promise is read from: + + - the field a widget's window lands on is `DashboardWidgetSchema.filterBindings`' contract: `filterBindings.dateRange` names it, `false` opts the widget out (reported `unbound`), and absent means the dashboard's `dateRange.field` (`created_at` when unauthored). It resolves against the object behind the widget's dataset, as the stack registers it, and must be a `date` or `datetime` field; + - a preset's window is `DATE_RANGE_PRESET_MACRO_WINDOWS`, the `{date-macro}` pair each of the 13 `DATE_RANGE_PRESETS` names. It is handed to `POST /analytics/dataset/query` as written (`{ $gte: '{7_days_ago}' }`, `{ $gte: '{month_start}', $lte: '{month_end}' }`), ANDed with the widget's own filter as `runtimeFilter`, and the server resolves the tokens; + - the days a window selects, for the ground truth, are that same window resolved by `@objectstack/core`'s `resolveFilterTokens` on the caller's reference timezone (UTC unless the `ExecutionContext` carries one), first and last day inclusive. + + Per bound widget and count measure, fixture rows are written through the seed door on every preset's first and last day and the day either side (never after today), each stamped at UTC noon (a `date` field takes the day itself), owned by the caller, with every column the widget's filter, the dataset's filter and the measure's filter compare held to a value they select, and read back before anything is judged. The widget is asked as the signed-in caller with no window and under every preset, before and after the fixtures, so each count is a difference and the app's own rows cancel out. With no window it must count every fixture row before any window is judged. Under each preset it must count exactly the fixture rows that window selects: a widget that shows nothing there is `blank`, one that counts another number is contradicted, and one whose every preset answers what no window answers is named inert. + + A widget is `proven`, `contradicted` (a preset went blank or counted other rows), `failed` (the door refused or faulted on a windowed query while the widget's own query answered), `inconclusive` (a fixture could not be written or did not read back as written, the widget with no window did not count every fixture row, its own query is refused, the day turned mid-probe, or the caller's timezone is not UTC), `unbound` or `excluded` (`excludedKind` names why: no count measure, a filter the ground truth cannot model, a window on a missing or non-temporal field, an undeclared or unparsed dataset or dashboard, an object the app does not declare or the CRUD derivation cannot synthesize). `contradicted + failed` widgets are the report's `hardFailures`. An average, a sum or a derived measure is counted as excluded; a held column is an equality, an `$in`, a `$nin` or `$ne` on a field that declares its options, or `{current_user_id}`. + + **`@objectstack/verify` — new exports.** + + - `runDashboardWindowProofs(stack, token, config, { now? })` → `DashboardWindowReport`. Run it on a fresh stack of its own, as a principal that reads the dashboards' objects: it writes fixture rows. + - `deriveDashboardWindowCases(config)`, `formatDashboardWindowReport(report)`. + - Types: `DashboardWindowExclusion`, `DashboardWindowMeasureCase`, `DashboardWindowCase`, `DashboardWindowDashboard`, `DashboardWindowPreset`, `DashboardWindowAnswer`, `DashboardWindowPresetProbe`, `DashboardWindowMeasureResult`, `DashboardWindowWidgetStatus`, `DashboardWindowWidgetResult`, `DashboardWindowReport`, `DashboardWindowProofOptions`. + + ```ts + import { bootStack, runDashboardWindowProofs, formatDashboardWindowReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runDashboardWindowProofs(stack, await stack.signIn(), myApp); + console.log(formatDashboardWindowReport(report)); // report.presets names what each preset handed the door + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --dashboard-windows`.** Opt-in, like `--unique-scopes` and `--parent-write-reach`, on a fresh boot of its own, as the signed-in admin. A widget that went blank or counted other rows than its window selects, or a windowed query the door refused or faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `dashboardWindows` when the flag is given; without the flag the run and its document are unchanged. +- 9f4b652: feat(verify): the delete-behaviour family — every `deleteBehavior` a reference field declares is what a delete of the referenced record does, runnable as `os verify --delete-behaviors` (#15953) + + Clause-②: yes + + A fourth derived proof family beside `runCrudVerification`, `runRlsProofs` and `runReferenceShapeProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`) and reads what the declaration states through the spec's own parse of the field (`FieldSchema`), which is the value a built artifact carries: a `lookup` or `tree` that authors nothing states `set_null`, an authored value is kept, and a bare `master_detail` states none. For each field it creates a fresh target record and a holder referencing it, deletes the target by id, and reads back what the engine left, through two doors: the REST data route as the signed-in caller, and the engine's own delete as the system principal. + + - `cascade` holds when the holder is gone with the target. + - `set_null` holds when the holder survives and no longer references the deleted record. + - `restrict` holds when the delete is refused with `DELETE_RESTRICTED` naming the holder's object, and both records are still there. + - In every case a second holder, referencing another record of the same target object, must be untouched. + + A refusal for any other reason (a hook's veto, a validation rule, a permission) is `unattributed`, so an engine that refused every delete would not read as a proven `restrict`. A field is `proven`, `contradicted` (a delete did something else), `failed` (a door faulted), `inconclusive` (an unattributed refusal, or a fixture the app refused; the report says why), `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. + + Two shapes are `excluded` rather than given an expectation, because an engine rule decides what a delete does to them, not the declaration: a `set_null` on a required reference (it cannot be cleared), and a field whose parse carries no `deleteBehavior` (a bare `master_detail`, a `user`). A field is `not-derivable` when no fixture can be built from the app's metadata: its target is not an object the app declares (such as `sys_user`), it is `readonly` or `system`, or the CRUD derivation cannot synthesize its holder or target. Only by-id deletes are asked; a predicate or multi-row delete is outside the family. + + **`@objectstack/verify` — new exports.** + + - `runDeleteBehaviorProofs(stack, token, config, { doors? })` → `DeleteBehaviorReport`. Run it on a fresh stack of its own: every `restrict` probe leaves its records behind. + - `deriveDeleteBehaviorCases(config)`, `formatDeleteBehaviorReport(report)`. + - Types: `DeclaredDeleteBehavior`, `DeleteBehaviorCase`, `DeleteBehaviorDoor`, `DeleteBehaviorAnswer`, `DeleteBehaviorProbeResult`, `DeleteBehaviorFieldStatus`, `DeleteBehaviorFieldResult`, `DeleteBehaviorReport`, `DeleteBehaviorProofOptions`. + + ```ts + import { bootStack, runDeleteBehaviorProofs, formatDeleteBehaviorReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runDeleteBehaviorProofs(stack, await stack.signIn(), myApp); + console.log(formatDeleteBehaviorReport(report)); + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --delete-behaviors`.** Opt-in, like `--reference-shapes`, on a fresh boot of its own. A contradicted declaration, or a door that faulted, counts in `hardFailures` and fails the run. A `tree` field's delete is not honoured today (deleting the parent leaves the child's reference in place), so an app that declares one fails the run with the flag. The `--json` document carries the report as `deleteBehaviors` when the flag is given; without the flag the run and its document are unchanged. +- 965a1bf: feat(verify): the parent-derived write reach family — a write on a `controlled_by_parent` detail is admitted exactly when the caller holds that verb on the detail and can edit its master, runnable as `os verify --parent-write-reach` (#15953) + + Clause-②: yes + + A sixth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs` and `runUniqueScopeProofs`. It needs no hand-written test: from the app's own objects it takes every object the spec's own parse (`ObjectSchema`) declares `sharingModel: 'controlled_by_parent'` with exactly one `master_detail` field, walks the chain of masters above it to the first one that governs its own rows, and asks the engine what a detail write meets as a signed-in caller: the insert, update and delete by id the REST data route makes, with the caller's context, where a refusal keeps the object and the operation it names (the REST envelope names the route's object whatever was refused). The system door bypasses authorization and is not asked. + + The promise is ADR-0055 §3 and its chain amendment: editing, deleting or creating a detail requires edit access to its master, and the detail's own object permission is the first door. So each write is expected admitted exactly when both halves hold: + + - the caller holds the verb on the detail's object — read from the server's effective permissions for the caller (`GET /auth/me/permissions`), folded by `objectPermissionGrants`; + - the caller can edit the master — measured, never re-derived: an empty update of the master as the caller, which runs every write gate the master has, the gate above a master that is itself controlled by its parent included. + + The writes are made by three verifier personas whose permission sets the family authors from the app's own objects — the root master by the all-records grant (`modifyAllRecords`) with every verb on the detail; the root master readable but not editable with every verb on the detail; the all-records grant with no delete on the detail — and by one persona per position the app declares, whose capability is what the app binds to the position. Each writes under a master chain another principal owns (so the all-records grant, not ownership, is exercised) and one it owns, on a detail another principal owns and one it owns. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. An object is `proven`, `contradicted` (a write met the other answer), `failed` (a door faulted), `inconclusive`, `excluded` (no one master: none, two, an undeclared one, a cycle, or a chain at the write gate's depth bound) or `not-derivable`. `contradicted + failed` objects, plus personas that could not be provisioned, are the report's `hardFailures`. + + A detail two levels below its root master (a line under a quote under an account) is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so the family reports such a chain `proven`. + + **`@objectstack/verify` — new exports.** + + - `runParentWriteReachProofs(stack, config)` → `ParentWriteReachReport`. Run it on a fresh stack of its own, booted with `parentWriteReachSecurity(config)`: it signs up personas and writes, deletes and creates details. On a stack booted without it the verifier personas are reported not run, with the reason, and counted. + - `parentWriteReachSecurity(config)` → the `SecurityPlugin` that registers the verifier personas' permission sets beside the app's declared default profile. + - `deriveParentWriteReachCases(config)`, `formatParentWriteReachReport(report)`. + - Types: `ParentWriteReachHop`, `ParentWriteReachCase`, `ParentWriteReachVerb`, `ParentWriteReachOwner`, `ParentWriteReachAnswer`, `ParentWriteReachProbeResult`, `ParentWriteReachReach`, `ParentWriteReachObjectStatus`, `ParentWriteReachObjectResult`, `ParentWriteReachPersona`, `ParentWriteReachReport`. + + ```ts + import { bootStack, runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity } from '@objectstack/verify'; + + const stack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) }); + const report = await runParentWriteReachProofs(stack, myApp); + console.log(formatParentWriteReachReport(report)); // report.personas names who wrote + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --parent-write-reach`.** Opt-in, like `--delete-behaviors` and `--unique-scopes`, on a fresh boot of its own with the verifier personas' sets registered. A write whose answer disagrees, a door that faulted, or a persona that could not be provisioned counts in `hardFailures` and fails the run. The `--json` document carries the report as `parentWriteReach` when the flag is given; without the flag the run and its document are unchanged. + + **The unique-scope family — one change and one correction.** `runUniqueScopeProofs` is new in this same release, and two sentences of its own entry in these notes no longer hold: + + - **Attribution changed.** A unique refusal that names no field is now `unattributed` where a unique `indexes[]` entry covers the probed column. That entry refuses the same second holder, naming no field either, and admits the twin, so there the refusal may be the entry's rather than the field's own declaration. This is a change to the family's attribution rule: a field-less refusal is attributed to the field only when its twin with only the probed value changed is admitted AND no unique `indexes[]` entry covers the field. + - **How the second organization is provisioned, corrected.** The family provisions it by `signUp`, then `POST /auth/organization/create`, not by a sign-up that creates its own organization as that entry's parenthetical says. +- eab7236: feat(verify): the reference-value shape family — every client-writable reference field refuses a value that is not a record id (ADR-0104 D1), runnable as `os verify --reference-shapes` (#15953) + + Clause-②: yes + + A third derived proof family beside `runCrudVerification` and `runRlsProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), reads what the declaration promises from `valueSchemaFor(field, 'stored')` (a record-id string, or an array of them when the field is multi-valued), and asks the engine whether three wrong-shaped values are refused: a boolean, a number, and an embedded record object (the expanded read shape written back). A multi-valued field gets each one as an array element. Every probe is written on insert and on update, through two doors: the REST data routes as the signed-in caller, and the engine's own write as the system principal. + + A probe counts as refused only when the engine names the probed field in its refusal. A refusal that names something else is reported `unattributed`, so an engine that refused every write would not read as proven. A field is `proven` (every probe refused), `holes` (a probe was admitted), `failed` (a door faulted), `inconclusive` (a probe could not be attributed or written; the report says why) or `excluded` (declared `readonly` or `system`, which the caller does not write). `holes + failed` is the report's `hardFailures`. + + **`@objectstack/verify` — new exports.** + + - `runReferenceShapeProofs(stack, token, config, { doors? })` → `ReferenceShapeReport`. Run it on a fresh stack of its own: a door that admits a probe lands a row. + - `deriveReferenceShapeCases(config)`, `referenceShapeProbeValue(kind, multiple, id)`, `formatReferenceShapeReport(report)`, `REFERENCE_SHAPE_PROBE_ID`. + - Types: `ReferenceShapeCase`, `ReferenceShapeDoor`, `ReferenceShapeOperation`, `ReferenceShapeProbeKind`, `ReferenceShapeProbeResult`, `ReferenceShapeFieldStatus`, `ReferenceShapeFieldResult`, `ReferenceShapeReport`, `ReferenceShapeProofOptions`. + + ```ts + import { bootStack, runReferenceShapeProofs, formatReferenceShapeReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runReferenceShapeProofs(stack, await stack.signIn(), myApp); + console.log(formatReferenceShapeReport(report)); + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. It is opt-in because the engine still admits wrong-shaped elements on a `multiple: true` `lookup` or `user` field (#22908), so an app that declares one would fail the run until the engine refuses them. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged. +- f93bf12: feat(verify): the unique-scope family — every field declared `unique` collides as its declared scope says, runnable as `os verify --unique-scopes` (#15953) + + Clause-②: yes + + A fifth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs` and `runDeleteBehaviorProofs`. It needs no hand-written test: from the app's own objects it takes every field whose `unique` the spec's own parse of the field (`FieldSchema`) declares — the value a built artifact carries, so an `autonumber` that authors no `unique` states `'organization'` — and asks the engine what a second holder of one value meets, through two doors: the REST data route as a signed-in caller, and the engine's own write as the system principal. + + - **Within one organization** — by insert and by update — the second holder must be refused, whatever the scope. On a `single`-posture boot that organization is the install's one: the untenanted install, where every write lands in it. + - **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (a sign-up that creates its own) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. + + A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none and the same record with only the probed value changed is admitted. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. + + A field the caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is probed through the system door only, which keeps an explicit value. A derived value (`formula`, `summary`) is `excluded`; a field with no fresh values to offer (a `boolean`, a `select`, a multi-valued reference, a target the app does not declare) is `not-derivable`. The population is the field-level declaration: a unique `indexes[]` entry is a different surface, with its own meaning of bare `true`, and is not in it. + + **`@objectstack/verify` — new exports.** + + - `runUniqueScopeProofs(stack, token, config, { doors? })` → `UniqueScopeReport`. Run it on a fresh stack of its own: it writes duplicates on purpose. Boot the stack with `{ multiTenant: true }` to run the cross-organization leg. + - `deriveUniqueScopeCases(config)`, `formatUniqueScopeReport(report)`. + - Types: `DeclaredUniqueScope`, `UniqueScopeCase`, `UniqueScopeDoor`, `UniqueScopeLeg`, `UniqueScopeOperation`, `UniqueScopeAnswer`, `UniqueScopeProbeResult`, `UniqueScopeFieldStatus`, `UniqueScopeFieldResult`, `UniqueScopeReport`, `UniqueScopeProofOptions`. + + ```ts + import { bootStack, runUniqueScopeProofs, formatUniqueScopeReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp, { multiTenant: true }); + const report = await runUniqueScopeProofs(stack, await stack.signIn(), myApp); + console.log(formatUniqueScopeReport(report)); // report.boot names the legs this boot ran + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --unique-scopes`.** Opt-in, like `--reference-shapes` and `--delete-behaviors`, on a fresh boot of its own; with `--multi-tenant` it runs both legs. A second holder met wrongly, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `uniqueScopes` when the flag is given; without the flag the run and its document are unchanged. +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. +- 97610a5: `bootStack` composes what `objectstack serve` composes from the same configuration: the providers the app's `requires` names, and the plugins in the app's own `plugins` array + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — one composition rule.** For one configuration, `bootStack(config, opts)` now mounts what `objectstack serve` mounts from it, so an app's tests boot the composition its users get: + + - **The providers the app's `requires` names**, by `serve`'s own reader and table (top-level `requires`, otherwise each package body's), plus the always-on providers a mounted plugin hard-depends on. An app no longer lists them in `extraPlugins` by hand. + - **The plugins in the app's own `plugins` array**, by `serve`'s rule for an entry: an instance is mounted as written, a plain bundle is wrapped into `AppPlugin`, a package name is loaded from the app's root (`hostRoot`). + - **The caller wins by identity.** An `extraPlugins` instance takes precedence over a `requires` provider it is (exact `name` or class name) and over an app plugin with the same `name`; that app plugin is not mounted. `security` and `analytics` instances take precedence over an app plugin of the same `name` the same way. + - **`hostRoot` is the app's root** in the two places `serve` uses the config's directory: the automation service's `packageRoot` (where a declarative connector's package-relative file ref is read) — now also when `automation: true` asks for the service — and the root a string `plugins` entry is resolved from. A suite that does not run from the app's directory passes it. + - **Offline CI.** An app whose `plugins` array wires the marketplace-facing `@objectstack/cloud-connection` plugins gets them mounted, pointed at `OS_CLOUD_URL` (by default the public catalog). Set `OS_CLOUD_URL=off` in the test environment, before the configuration module is imported, to keep the suite offline. + + **What now fails that booted before (the narrowing).** + + - **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again; or share one boot with `bootStackOnce(config, opts)`; or, to keep two stacks of one app live at once, boot the second on a configuration built again (call its builder once more, or import a fresh module instance of it). A `{ ...config }` spread is not a configuration of its own: a live boot keeps references into the configuration's nested definitions. + - **An app `plugins` entry that cannot be loaded or registered fails the boot**, naming the entry (`plugins[i]`) and its remedy. `serve` logs such an entry and boots on; a test boot does not, so a plugin the app declares is never silently absent from its tests. + - **A provider or app plugin that refuses to start fails the boot** where the fixed plugin set never mounted it — for example a declarative connector whose package-relative file ref does not resolve from `hostRoot`. + + **`@objectstack/core`** exports the pieces both boots read: `CAPABILITY_PROVIDERS`, `CapabilitySpec`, `CapabilityIdentities` and `providesCapability` (the `requires` token → provider table and its exact identity match); `stackDeclaredCapabilities`, `resolveStackCollection`, `declaredPackageEntries`, `stackPackageBodies` and `collectFromPackageBodies` (the package-owned collection reader); and `materializeStackPlugin` with `StackPluginLoaders` (what a `plugins` entry becomes). + + **`@objectstack/cli`**: `os verify` boots the app anchored at the directory holding its config (`hostRoot`), as `serve` anchors it, so `os verify --app path/to/objectstack.config.ts` run from another directory reads the app's package-relative files (a declarative connector's spec, a string `plugins` entry) and resolves `--multi-tenant`'s organizations package from the app, not from the working directory. `serve` itself does not change: `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. +- fbb065f: feat(verify): the handle gains a door to the automation engine's condition evaluator + + Clause-②: yes (widening) + + An app's tests can evaluate a flow condition on the booted stack's own evaluator through the in-process handle, so a suite no longer calls the booted kernel's `automation` service by hand for a condition's truth table. + + - **`stack.automation.evaluateCondition(condition, variables)`** calls the booted `automation` service's `evaluateCondition`, the method a flow's start gate, its edges and its `decision` branches are decided by. It resolves with the engine's boolean, unchanged. It is for a truth table over variable shapes no write produces. + - **`condition`** takes the shapes the engine takes, and is handed over as given: an envelope (`{ dialect: 'cel', source }`) or a string, which the engine reads as CEL unless it carries a `{var}` hole (the legacy template dialect). The engine's own flow sites hand the evaluator an envelope (a start node's string `condition` and a `decision` branch's `expression` are wrapped as `{ dialect: 'cel', source }`, and an edge's condition already is one), so pass the envelope to model any of them. + - **`variables`** is a plain object. Each own key becomes one entry of the `Map` the engine takes: `{ record, previous }` binds `record` and `previous`. + - **Refusals are not the handle's.** A condition the engine cannot evaluate rejects with the engine's own error, never `false`. On a stack with no automation service it rejects with the kernel's own `SERVICE_NOT_REGISTERED` error, whose `serviceName` is `'automation'` (`isServiceNotRegisteredError` from `@objectstack/core` answers `true`). Boot with `automation: true`, or have the app declare `requires: ['automation']`. No error code is added. +- 27f0d83: feat(verify): `bootStack(config, { artifact })` boots the compiled artifact as the app, so a test runs each hook's and action's lowered `body` in the sandbox, as a deployment does + + Clause-②: yes (widening) + + `objectstack build` lowers each inline hook `handler` and action `target` it can into a metadata-only `body`, and a deployment runs that body in the runtime's QuickJS sandbox, against the sandbox's `ctx`. `bootStack` mounted the source configuration only, so a handle test ran the function in-process, and a handler that refuses once lowered passed every test. + + - **`BootOptions.artifact`** names the compiled `objectstack.json`: a path, resolved against `hostRoot` (so `'dist/objectstack.json'` names the app's own build), or an `http(s)://` URL. The artifact is read by the runtime's own artifact loader (`loadArtifactBundle`, with the options every artifact boot reads it with) and mounted as the app, in place of `config`. `AppPlugin` binds each hook's `body` ahead of its `handler`, through the same sandbox runner every artifact boot uses, so every door (`hooks.run`, `actions.run`, `api`, …) runs what the build shipped. + - **A refused body answers as the body-only path answers.** The handle rejects with the sandbox's `SandboxError`, which carries a `code` and `status` the body declared on its throw. A body that crashes (a `TypeError`) carries none, and REST answers it `500` `INTERNAL_ERROR`. For example, a handler that writes to `ctx.dispatch.scope` passes in-process and refuses every insert once lowered, because the sandbox hands a body `ctx.dispatch` without `scope`. + - **The rest of the stack is still composed from `config`**: its `requires`, its own `plugins`, its declared default profile, its datasources. `objectstack serve ` composes the same way. So `config` is the configuration the artifact was built from. + - **A local artifact gets its `objectstack-runtime.*.mjs` merged**, so a callable the build could not lower runs in-process from it, as under `objectstack start`. `objectstack build --strict-body` refuses such a callable at build time, and its artifact is body-only. + - **Refused before anything boots**, each with its `code` and `status`: an artifact that cannot be loaded (`RESOURCE_NOT_FOUND`, 404; the boot never falls back to the source), an artifact built from another app (`RESOURCE_CONFLICT`, 409), and a configuration carrying `onEnable` (`INVALID_REQUEST`, 400). No artifact carries `onEnable`; for a configuration that mounts no plugin instance of its own, `objectstack serve ` grafts it from the configuration module, a `@objectstack/cli` rule this package does not re-derive. No error code is added. + - The handle lowers nothing: build the artifact first. A stale artifact boots stale bodies. +- a8f24b0: feat(verify): the handle gains a door that observes the writes the engine receives, a hook's refused write included + + Clause-②: yes (widening) + + An app's tests can see what a hook handed the engine through the in-process handle, so a suite no longer wraps the booted engine's write methods in a spy to find a refused write, to wait for a fire-and-forget hook, or to stage a refusal. + + - **`stack.observeWrites(fn)`** runs `fn` and resolves with every insert, update and delete the booted engine received meanwhile, in the order each reached it. Each entry (`ObservedWrite`) carries `object`, `operation`, the payload (`data`), the update or delete selection (`where`), `multi`, the execution context the write carried (`context`), and the engine's answer (`outcome`): `pending`, then `resolved` with its result, or `refused` with the error the engine threw, unchanged, and that error's own `code`. + - **What it is.** One global middleware on the engine's own chain (`registerMiddleware`), added on the first call. It passes every operation on unchanged and decides nothing. So it sits after the write gates the boot registered. Every write those gates let through is recorded, from any door (`hooks.run`, `seed`, a flow, a route, a hook's `ctx.api`), and so is every refusal the engine raises past them: a declared validation rule (`VALIDATION_FAILED`), a hook's throw, a unique conflict (`DUPLICATE_RECORD`). A write a gate refuses, the permission check among them, never reaches it and leaves no entry. Its refusal still reaches whoever made the write. + - **An outcome is the engine's answer to that call, not a statement about stored rows.** A write an `after*` hook refuses was refused after the driver wrote, and its row can stay. Read rows back with `stack.rows`. + - **The window is time, not cause.** A write the stack makes for its own reasons while `fn` runs is recorded too, so filter by `object`. + - **An `async: true` hook.** Its write reaches the engine after the write that fired it has returned. `fn` is handed the live observation (`WriteObservation`), and `observation.settled(match)` resolves with the first write `match` accepts once the engine has answered it. It rejects after `timeoutMs` (default 2000), naming every write observed, or when the observation closes first. A hook that writes nothing leaves nothing to observe. + - **Staging a refusal.** Arrange one the engine makes itself: seed the row that already holds a unique value the hook will write, or set up the state a declared validation rule refuses. The handle has no switch that refuses a write in the engine's place. + - A `fn` that is not a function, a `match` that is not a function, and a `timeoutMs` that is not a positive number are refused with `code: 'INVALID_REQUEST'`, `status: 400`. A rejection of `fn` is rethrown unchanged once the observation has closed. No error code is added. + - New exported types: `ObservedWrite`, `ObservedWriteOutcome`, `WriteObservation`. +- b4ce5e2: feat(verify): `hooks.run` takes the system principal on an insert and a delete, so a user-less record trigger can be driven + + Clause-②: yes (widening) + + An app's tests can drive a record-change flow fired by a write with no user (an integration's or a system job's insert or delete) through the in-process handle, so a suite no longer hands a flow a record through the booted kernel's `automation` service by hand. + + - **`hooks.run(object, 'insert' | 'delete', input, { system: true })`** is the engine's own `insert` / `delete` under `{ isSystem: true }`, the same call the person's write makes with only the context changed. `{ system: true }` on `update` is unchanged. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires the object's flows with no trigger user. + - **A record the engine no longer holds.** A system (or a person's) delete hands a `record-after-delete` flow the row's pre-image as `record` and as `previous`, and by then the engine no longer holds the row: the flow's `get_record` of that id finds nothing. A `record-before-delete` flow on the same delete still finds it. + - **Not `seed`.** `seed(object, rows)` also runs as the system, and it sets `skipTriggers`, so it fires no record-change flow: use it for end-state fixture data, and `{ system: true }` for a write the app should react to. + - A call naming both callers (`{ as: token, system: true }`) is still refused with `code: 'INVALID_REQUEST'`, `status: 400`, before anything is written. Every other refusal is the engine's own error, unchanged: assert on its `code` and `status`. +- 5910b5e: feat(verify): the handle gains a system-context update door and a predicate update door + + Clause-②: yes (widening) + + An app's tests reach two more engine write paths through the in-process handle, so a suite no longer calls the booted kernel's `objectql` service by hand for them. + + - **The system principal on an update.** `hooks.run(object, 'update', { id, ...fields }, { system: true })` writes one row by id under `{ isSystem: true }`, the context a system job or an integration writes under. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires its flows with no trigger user. The new type is `AsSystem` (`{ system: true }`), exported beside `AsUser`. + - `{ system: true }` beside an `as` token is refused with `code: 'INVALID_REQUEST'`, `status: 400` before anything is written. Fixture rows are still `seed(object, rows)`, which also skips record triggers. (`insert` and `delete` take `{ system: true }` too: see the system insert and delete entry.) + - **The predicate update.** `hooks.updateWhere(object, where, data, opts)` is the engine's own predicate path, `update(object, data, { where, multi: true, context })`. It writes one payload to every row `where` selects and resolves with the affected-row count. The engine dispatches `beforeUpdate` and `afterUpdate` once per matched row, each bound to that row's own pre-image as `previous`, so record-change flows evaluate and fire per row. No REST door reaches this path: `POST /data/:object/updateMany` writes by id. The caller is `{ as: token }` or `{ system: true }`. + - It refuses with `code: 'INVALID_REQUEST'`, `status: 400`, before the engine is touched, a `where` that is not an object, and a call the engine's own update dispatch would write by id: a `where` that names only an `id`, or an `id` in `data` beside a `where` that selects by nothing else. Write one row by id through `hooks.run(object, 'update', { id, ...fields }, opts)`. An `id` inside a larger predicate (`{ id, status: 'open' }`) stays a predicate update. An `id` in `data` beside a real predicate is refused by the engine itself, as it is on every caller. + + Every other refusal from either door is the engine's own error, unchanged: assert on its `code` and `status`. +- a6f3a13: fix(verify)!: `hooks.run(object, 'delete', …)` resolves with what the engine's `delete` answers, not with a row + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that reads the answer of a `hooks.run` delete. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes. + + `hooks.run(object, 'delete', { id }, opts)`, for a person (`{ as }`) and for the system (`{ system: true }`), was declared to resolve with a row (`Promise`). The engine's `delete` answers `true` for the row it removed. So a test that read a field of the answer type-checked, and then read `undefined`. + + **What `hooks.run` declares now.** Both overloads are generic over the operation. + + - An `insert` or an `update` resolves with the row, as before. + - A `delete` resolves with the engine's own declared delete answer, `boolean | number`. It is read off `ObjectQL['delete']` rather than re-declared, and a `hooks.run` delete, which is always by id, answers `true`. + - A call whose operation is the union `'insert' | 'update' | 'delete'` still type-checks, and resolves with the union of the answers. + + **If your code read a field of a delete's answer.** That read was always `undefined` at run time, and it is now a compile error. Assert the answer itself, `expect(await stack.hooks.run(object, 'delete', { id }, opts)).toBe(true)`. If the test needs the deleted row, read it with `stack.rows(object, { id })` before the delete. + +### Patch Changes + +- 0253192: Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4) + + - `@objectstack/plugin-auth`: the self-registration grant names the declared set alone. Admission asks the security catalog and the activation ledger whether the set grants, and the settlement writes `{ user_id, permission_set }` with no row read, so a deployment with no `sys_permission_set` row for the set still grants it. The last-administrator guard reads which set a grant holds from its name only; writing `permission_set_id` moves no standing. + - `@objectstack/verify`: the RLS probe persona's grant names the registered probe set alone; it no longer needs, or refuses for want of, a boot-seeded `sys_permission_set` row. + - `@objectstack/platform-objects`: Setup's user page grants a permission set by name: the Add picker stores the set's machine name (`valueField: 'name'`, `linkField: 'permission_set'`), and the list shows the name column. The picker still lists `sys_permission_set` rows; listing the security catalog itself is a console change. +- e078506: fix(verify): the RLS probe persona is provisioned through the registry — `provisionRlsProbePersona` no longer mints a `sys_permission_set` row + + Clause-②: no + + `provisionRlsProbePersona` used to find-or-create a `sys_permission_set` row named `verify_rls_probe` and grant it. Under ADR-0131 D3/D4 a permission set's one home is the registry: the authorization resolver reads the grant's `permission_set` name and takes the definition from the security catalog, never from a row. So a row this function minted was a copy of a definition nothing read. + + It now: + + - asks the security catalog (`securityCatalogReaderOf`, the read the resolver itself uses) whether `verify_rls_probe` is registered. `rlsProbeSecurity(config)` registers it at boot; + - reads the id of the row the boot already seeded for that registered set, because `sys_user_permission_set.permission_set_id` is still a required column; + - writes the one grant, carrying both columns. + + It throws, writing nothing, when the catalog does not serve the probe set or the boot seeded no row for it. `objectstack verify --rls` reports either as a degraded probe persona and fails the run, as it already does for any provisioning failure. Fix for a caller that hits it: boot the stack with `rlsProbeSecurity(config)` as the `security` option of `bootStack`. +- 37c7114: docs(verify): `hooks.run` names the anonymous public-form door a booted stack already serves + + Clause-②: no + + An app's web-to-lead or web-to-case branch runs on an anonymous public-form submission, and a booted stack already serves that write: it is the form's own route, `api('/forms/:slug/submit', { method: 'POST', body })` with no token. `bootStack` mounts that route, and the in-process handle has no method for it, by design: its one owner is `@objectstack/rest`. Nothing changes in what the package does. `hooks.run`'s documentation now names the door, so a suite reaches it instead of calling the engine with a hand-built guest context. + + What the door does, as the package's own tests now pin it on a booted stack: + + - It hands the engine the route's own context: the form's one-object grant, the `guest_portal` permission set, and `anonymous`. There is no user and no system principal. + - A bound hook sees no session and no user, so an app's guest branch runs, and the record-change trigger fires the object's flows with no trigger user. + - It keeps only the fields the form collects and answers `201` with `{ id }`. Read the row back with `rows(object, { id })`. + - An unknown slug, or a form that is shared but not anonymous, is answered `404` with `code: 'FORM_NOT_FOUND'`, and nothing is written. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- de24373: fix(verify): `bootStack` composes the artifact-less `MetadataPlugin` that `objectstack serve` gives a host configuration, so a stack's declared `apis` endpoints answer under the harness as they answer under `serve` (#23088) + + Clause-②: no + + `bootStack`'s `metadata` slot was the kernel's in-memory fallback, which has no endpoint matcher (`matchEndpoint`). So every `apis` endpoint a stack declared answered `404 ENDPOINT_NOT_FOUND` in a test boot while `objectstack serve` of the same stack served it, and the boot logged `AppPlugin`'s "no endpoint matcher" warning. The harness now composes `@objectstack/metadata`'s `MetadataPlugin` the way `serve` composes it for a host configuration: no artifact, `bootstrap: 'lazy'`, and `rootDir` set to `BootOptions.hostRoot`. It reads none of the stack, so `AppPlugin` stays the single registrar of the stack's metadata. + + - A declared endpoint answers as it does under `serve`: `401` without a session where it is `authRequired`, and its declared answer with one. An undeclared path under the same mount keeps the bare `404`. + - A `MetadataPlugin` you pass in `BootOptions.extraPlugins`, or one your app declares in its own `plugins`, takes the composed one's place. Pass your own to boot an artifact-backed one. + - Two other answers of a test boot move with the slot: `GET /api/v1/discovery` reports the `metadata` service as `available` instead of `degraded`, and `GET /api/v1/dev/metadata-events` (the development-only metadata reload stream, mounted because `bootStack` boots in the development posture) answers `200` instead of `404`, as under `objectstack dev`. The `GET /api/v1/meta/TYPE` answers are unchanged, measured over 22 types on the showcase and CRM examples. +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [c28f317] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [e7e6544] +- Updated dependencies [cd3d391] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [8a399b2] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [f0022c4] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [0db5ad5] +- Updated dependencies [93125ae] +- Updated dependencies [29678f2] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [56bf27a] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [98998ca] +- Updated dependencies [3aa0c7b] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [036bf1d] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [7518ee3] +- Updated dependencies [4049cac] +- Updated dependencies [7ebbc01] +- Updated dependencies [0e9371f] +- Updated dependencies [0b7ba4d] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [3513ac7] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [79c35d4] +- Updated dependencies [f6e1d49] +- Updated dependencies [f2626c7] +- Updated dependencies [e175479] +- Updated dependencies [59d993c] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [e36ee53] +- Updated dependencies [1884948] +- Updated dependencies [3599fef] +- Updated dependencies [8a995b8] +- Updated dependencies [54c3ce1] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [43fc500] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [3403be8] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [166a94f] +- Updated dependencies [dd986d8] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [f782f17] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [5495331] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d1d42ec] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [e194ab4] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [b98e984] +- Updated dependencies [76bc1e0] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [874a38d] +- Updated dependencies [3d0eeef] +- Updated dependencies [cb3bb93] +- Updated dependencies [83b8b80] +- Updated dependencies [1b99388] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [e8c6666] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [87cd458] +- Updated dependencies [86da194] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [243dd3c] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [7399080] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [896a434] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [0984817] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [8bd0fcd] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [75b6d6a] +- Updated dependencies [c4e7fa5] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [f313fbc] +- Updated dependencies [9814035] +- Updated dependencies [f9c7588] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [bb0db13] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [3fdb92d] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [6a40a09] +- Updated dependencies [1959aac] +- Updated dependencies [e1deaef] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [8e4a230] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [a22e67b] +- Updated dependencies [62ce45d] +- Updated dependencies [84c4fff] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [0aef788] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [0511341] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [5d63c37] +- Updated dependencies [dc93781] +- Updated dependencies [20949e7] +- Updated dependencies [3d6392f] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/spec@18.0.0-next.0 + - @objectstack/runtime@18.0.0-next.0 + - @objectstack/plugin-security@18.0.0-next.0 + - @objectstack/objectql@18.0.0-next.0 + - @objectstack/plugin-auth@18.0.0-next.0 + - @objectstack/core@18.0.0-next.0 + - @objectstack/platform-objects@18.0.0-next.0 + - @objectstack/service-automation@18.0.0-next.0 + - @objectstack/plugin-sharing@18.0.0-next.0 + - @objectstack/rest@18.0.0-next.0 + - @objectstack/plugin-email@18.0.0-next.0 + - @objectstack/metadata@18.0.0-next.0 + - @objectstack/plugin-audit@18.0.0-next.0 + - @objectstack/service-settings@18.0.0-next.0 + - @objectstack/service-realtime@18.0.0-next.0 + - @objectstack/service-storage@18.0.0-next.0 + - @objectstack/service-messaging@18.0.0-next.0 + - @objectstack/service-datasource@18.0.0-next.0 + - @objectstack/plugin-approvals@18.0.0-next.0 + - @objectstack/service-analytics@18.0.0-next.0 + - @objectstack/types@18.0.0-next.0 + - @objectstack/plugin-hono-server@18.0.0-next.0 + - @objectstack/mcp@18.0.0-next.0 + - @objectstack/plugin-webhooks@18.0.0-next.0 + - @objectstack/service-sms@18.0.0-next.0 + - @objectstack/trigger-api@18.0.0-next.0 + - @objectstack/service-mailbox@18.0.0-next.0 + - @objectstack/service-package@18.0.0-next.0 + - @objectstack/service-cache@18.0.0-next.0 + - @objectstack/service-job@18.0.0-next.0 + - @objectstack/service-queue@18.0.0-next.0 + - @objectstack/trigger-record-change@18.0.0-next.0 + - @objectstack/trigger-schedule@18.0.0-next.0 + - @objectstack/plugin-pinyin-search@18.0.0-next.0 + ## 17.7.0 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index 63a0bcf6a49..0a201a468cc 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.7.0", + "version": "18.0.0-next.0", "license": "Apache-2.0", "description": "Boot any ObjectStack app in-process and verify it through the real HTTP stack — auto-derived CRUD round-trip fidelity plus the cross-owner RLS invariant. Catches runtime regressions that static checks miss.", "type": "module", diff --git a/skills/objectstack-ai/SKILL.md b/skills/objectstack-ai/SKILL.md index ceb3db82a5f..91b6835f845 100644 --- a/skills/objectstack-ai/SKILL.md +++ b/skills/objectstack-ai/SKILL.md @@ -9,10 +9,10 @@ description: > not by authoring `*.agent.ts`. Do not use for general LLM prompting questions unrelated to ObjectStack metadata. license: Apache-2.0 -compatibility: Requires @objectstack/spec 17.x (Zod v4 schemas) +compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: ai tags: agent, tool, skill, knowledge, llm, embedding, mcp --- diff --git a/skills/objectstack-api/SKILL.md b/skills/objectstack-api/SKILL.md index efa62df50a6..ff1cf589c7a 100644 --- a/skills/objectstack-api/SKILL.md +++ b/skills/objectstack-api/SKILL.md @@ -11,10 +11,10 @@ description: > syntax (see objectstack-query). CEL expressions in route guards or auth predicates: load objectstack-formula alongside. license: Apache-2.0 -compatibility: Requires @objectstack/spec 17.x (Zod v4 schemas) +compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: api tags: rest, endpoint, auth, realtime, server --- diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index c59d3ca28d0..dc98bbe0e5c 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -10,10 +10,10 @@ description: > / plugin events (see objectstack-platform). CEL expressions in flow conditions / edge guards: load objectstack-formula alongside. license: Apache-2.0 -compatibility: Requires @objectstack/spec 17.x (Zod v4 schemas) +compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: automation tags: flow, workflow, trigger, approval, state-machine, scheduled, webhook --- diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 12c50d4bb8f..104c4b80992 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -12,10 +12,10 @@ description: > or for plugin / kernel hooks (see objectstack-platform). CEL expressions: load objectstack-formula alongside. license: Apache-2.0 -compatibility: Requires @objectstack/spec 17.x (Zod v4 schemas) +compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: data tags: object, field, validation, index, relationship, hook, schema, permission, rls, security, seed, fixture --- diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index 7f7796c43f3..4e6118cc27c 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -10,10 +10,10 @@ description: > express X as a formula / predicate". Do not use for SQL fragments (driver-native) or L2 hook bodies (those belong in objectstack-data). license: Apache-2.0 -compatibility: Requires @objectstack/spec 17.x and @objectstack/formula 17.x (CEL) +compatibility: Requires @objectstack/spec 18.x and @objectstack/formula 18.x (CEL) metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: expression tags: cel, formula, predicate, condition, validation, visibility, seed-dynamic --- diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index 53df4e66e6a..aec1a03531e 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -10,10 +10,10 @@ description: > warnings. Do not use for general i18n library questions unrelated to ObjectStack bundles. license: Apache-2.0 -compatibility: Requires @objectstack/spec 17.x (Zod v4 schemas) +compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: i18n tags: i18n, translation, locale, l10n, bundle, coverage --- diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index f681797ac71..ba917ef6773 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -14,10 +14,10 @@ description: > objectstack-query); data lifecycle hooks (beforeInsert / afterUpdate) belong in objectstack-data — only kernel / service-level events live here. license: Apache-2.0 -compatibility: Requires @objectstack/spec 17.x and @objectstack/core 17.x (Zod v4 schemas), Node 22+ +compatibility: Requires @objectstack/spec 18.x and @objectstack/core 18.x (Zod v4 schemas), Node 22+ metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: platform tags: project, defineStack, driver, hono, plugin, kernel, service, requires, capability, DI, lifecycle, cli, deploy, ops --- diff --git a/skills/objectstack-query/SKILL.md b/skills/objectstack-query/SKILL.md index 5e7ca0008a7..726c15b8551 100644 --- a/skills/objectstack-query/SKILL.md +++ b/skills/objectstack-query/SKILL.md @@ -8,10 +8,10 @@ description: > designing the API endpoint that exposes a query (see objectstack-api), or for a list view's filter rules / dashboard datasets (see objectstack-ui). license: Apache-2.0 -compatibility: Requires @objectstack/spec 17.x (Zod v4 schemas) +compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: query tags: query, filter, sort, paginate, aggregate, ObjectQL, full-text --- diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index fca5c279e5e..4c168fcf230 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -15,10 +15,10 @@ description: > ships with the platform). CEL expressions in visibility/conditional rules: load objectstack-formula alongside. license: Apache-2.0 -compatibility: Requires @objectstack/spec 17.x (Zod v4 schemas) +compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: ui tags: view, app, page, dashboard, report, chart, action, widget, doc --- diff --git a/skills/objectstack-upgrade/SKILL.md b/skills/objectstack-upgrade/SKILL.md index f1b37485be4..8cc5d581588 100644 --- a/skills/objectstack-upgrade/SKILL.md +++ b/skills/objectstack-upgrade/SKILL.md @@ -16,7 +16,7 @@ license: Apache-2.0 compatibility: Requires `@objectstack/spec` and `@objectstack/cli` at the TARGET major; the chain replays from the spec's `MIGRATION_SUPPORT_FLOOR`. No network access required. metadata: author: objectstack-ai - version: "17.7.0" + version: "18.0.0-next.0" domain: process tags: upgrade, migration, protocol, major, retired-keys, tombstone, conversions, validate, report ---